Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aaac88b56 | ||
|
|
671323b9ac | ||
|
|
2e4fe2a8fe | ||
|
|
27c690285a | ||
|
|
8e7ab9158c | ||
|
|
03285c6ff6 | ||
|
|
d424ef8c15 | ||
|
|
2a228ea659 | ||
|
|
96e35df535 | ||
|
|
fa90bcc93b | ||
|
|
3117e80027 | ||
|
|
2f7ca0f5a8 | ||
|
|
2367e8a0ff | ||
|
|
b168c68264 | ||
|
|
aeec0b703c | ||
|
|
2ff26241d7 | ||
|
|
f0b5fb8a21 | ||
|
|
1e155f6ffb | ||
|
|
8cee828212 | ||
|
|
b23d15453d | ||
|
|
c4e71475ce | ||
|
|
bf5d38df14 | ||
|
|
9278d48a6d | ||
|
|
4062029939 | ||
|
|
f017867d5f | ||
|
|
f1c533488a | ||
|
|
f9bb655f6a | ||
|
|
c1bfabb033 | ||
|
|
ff3df907f4 | ||
|
|
39fe84ce99 | ||
|
|
b273614128 | ||
|
|
1a0f57a247 | ||
|
|
c67dff4725 | ||
|
|
58da09cdc0 | ||
|
|
ccdf0b68ea | ||
|
|
3b072ceefa | ||
|
|
68a8dae0dc | ||
|
|
e13f977c4d | ||
|
|
6f428b2297 | ||
|
|
67f98c1f66 | ||
|
|
85d7ffaa14 | ||
|
|
088fe39bab | ||
|
|
9b43d4e827 | ||
|
|
5630beee7c | ||
|
|
90a27b893e | ||
|
|
2878041687 | ||
|
|
262f4c6ca5 | ||
|
|
063ffd15ef | ||
|
|
fa11d3e7e0 | ||
|
|
d46827dfdf | ||
|
|
601db41a09 | ||
|
|
8259c48f9e | ||
|
|
1163407440 | ||
|
|
1cefcab697 | ||
|
|
c228d7a8ca | ||
|
|
c997a22a3c | ||
|
|
4d016cdab5 | ||
|
|
177d3f1902 | ||
|
|
ef0bb9f27e | ||
|
|
126b567b7b | ||
|
|
e4038051e9 | ||
|
|
c7e42a923a | ||
|
|
ebda3ecf23 | ||
|
|
cc4b14f862 | ||
|
|
5459d8c325 | ||
|
|
5ca03bd662 | ||
|
|
734e4266e6 | ||
|
|
c690bd9a24 | ||
|
|
3eb364fba5 | ||
|
|
1f85a84e06 | ||
|
|
abbff0bfbf | ||
|
|
19eda2ffa3 | ||
|
|
9e5038c553 | ||
|
|
9ab2bd7ef2 | ||
|
|
3d1bd49ad6 | ||
|
|
f51d5fcba4 | ||
|
|
5d919c3c90 | ||
|
|
4b94efbb43 | ||
|
|
c8c40d80d6 | ||
|
|
acc5434aca | ||
|
|
ff527ca577 | ||
|
|
b54fa84ac7 | ||
|
|
ca386c9d98 | ||
|
|
a05f0f1665 | ||
|
|
1d0793d2bb | ||
|
|
74c2f9aa30 | ||
|
|
1074adee0d | ||
|
|
a5103ee738 | ||
|
|
4fb52e9626 | ||
|
|
06c6e242e7 | ||
|
|
91686f5999 | ||
|
|
03015bbe9d | ||
|
|
af07703e7f | ||
|
|
0dbe78bfe6 | ||
|
|
b22cd6b4da | ||
|
|
ba18cb07f0 | ||
|
|
26f12b01a0 | ||
|
|
1b372b93ea | ||
|
|
56bad52de5 | ||
|
|
a80905755d | ||
|
|
ea6c943a51 | ||
|
|
c0e1b718e6 | ||
|
|
fbcd5a8939 | ||
|
|
f5ec9fa0f2 | ||
|
|
fa8f0efc65 | ||
|
|
2efd1c6df4 | ||
|
|
305c3fd97b | ||
|
|
6c186acc94 | ||
|
|
c055bf8267 | ||
|
|
094c190ae1 | ||
|
|
55a2de3a04 | ||
|
|
1398d4b4dc | ||
|
|
9aa90ff5f4 | ||
|
|
225cf56433 | ||
|
|
58a3bcad70 | ||
|
|
ca55529ff6 | ||
|
|
b76731d790 | ||
|
|
05f25931c9 | ||
|
|
5855c94b50 | ||
|
|
81adebd63a | ||
|
|
16db585022 | ||
|
|
645d0127ec | ||
|
|
c550763f2a | ||
|
|
bd658cd00c | ||
|
|
efaf49e9df | ||
|
|
cc5ffb27b9 | ||
|
|
9dbb5dbaab | ||
|
|
ab7e1eda41 | ||
|
|
7a21c85cce | ||
|
|
a353178c2d | ||
|
|
eeac0c6b8b | ||
|
|
e511e203b9 | ||
|
|
9791166ed6 | ||
|
|
a853d12d9c | ||
|
|
6bc4a22338 | ||
|
|
859ae1a458 | ||
|
|
090cfd7400 | ||
|
|
bb7e53f47a | ||
|
|
d2e368a05e | ||
|
|
b5df88b62d | ||
|
|
116797ca17 | ||
|
|
ed4f1efd03 | ||
|
|
6b76d4336e | ||
|
|
a8c0e413f4 | ||
|
|
596d21712a | ||
|
|
b504d9d0b2 | ||
|
|
e082a6ef06 | ||
|
|
1662453d9c | ||
|
|
9dd2e07f19 | ||
|
|
18064a972c | ||
|
|
996835dae5 | ||
|
|
c73b980ce7 | ||
|
|
dafa6d6aab | ||
|
|
80803cc868 | ||
|
|
55fa4045ad | ||
|
|
7f156cc721 | ||
|
|
e0d2fccf50 | ||
|
|
a1c251650b | ||
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 | ||
|
|
fdf180809d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ dist-client/
|
|||||||
# Claude worktrees
|
# Claude worktrees
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Superpowers brainstorm mockups
|
||||||
|
.superpowers/
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -104,7 +104,7 @@ TOTP_ENCRYPTION_KEY=<64-char hex string>
|
|||||||
Optional (with defaults):
|
Optional (with defaults):
|
||||||
|
|
||||||
```
|
```
|
||||||
PORT=3001 # Production port (dev default: 3000)
|
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
||||||
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
||||||
@@ -274,6 +274,19 @@ When entity A embeds/references entity B, A's mutation handlers invalidate B's d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Frontend — MUI Migration (in progress)
|
||||||
|
|
||||||
|
The admin frontend is being migrated off ~7,100 lines of hand-written CSS onto **MUI (Material UI v7)** — a "Soft-SaaS shell + dense tables" look, dark/light preserved, **incremental and always shippable**.
|
||||||
|
|
||||||
|
- **Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md`. **Per-phase plans:** `docs/superpowers/plans/2026-06-06-mui-migration-phase-*.md`.
|
||||||
|
- **Progress is tracked in agent memory** (`project_mui_migration.md` — what's merged, what's next). **Keep it updated as each phase merges.** Memory practice generally: durable, non-obvious facts about ongoing work / decisions / gotchas belong in the agent memory store (one fact per file, indexed in `MEMORY.md`), not left only in chat.
|
||||||
|
- **Migrating a page:** mirror `src/admin/pages/Vehicles.tsx` (the pilot). Import only from the **`src/admin/ui/` kit** — never `@mui/*` directly in pages. **Preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation; drop the page's `admin-*` CSS usage. Per page: plan → subagent-driven build → spec + code-quality review → user visual check → merge.
|
||||||
|
- **Kit (`src/admin/ui/`):** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField (date-fns v4, `dd.MM.yyyy`, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable via `sortKey`/`sortBy`/`onSort`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField, SwitchField, Field, Alert. Theme + tokens in `src/admin/theme.ts`; one `data-theme` attribute drives MUI **and** legacy CSS. Dev-only `/ui-kit` showcase route.
|
||||||
|
- **Gates every step:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
|
||||||
|
- **Dialog gotchas (solved in the kit — don't reintroduce):** dialogs lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroll container); Modal/ConfirmDialog freeze title/label/loading through the close fade (derive-state-from-props) so nothing flashes during the transition. Login renders OUTSIDE the AppShell (sibling route).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Database Conventions
|
## Database Conventions
|
||||||
|
|
||||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
||||||
|
|||||||
@@ -0,0 +1,680 @@
|
|||||||
|
# MUI Migration — Phase 0: Theming Foundation — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Stand up the MUI theming foundation — install MUI, build `theme.ts` (light+dark from existing tokens), wire the provider + `data-theme` color-scheme bridge + `ThemeContext` sync, apply the token-level palette refresh app-wide, and ship a dev-only `/ui-kit` route proving the theme on the first wrappers (`Button`/`Card`/`TextField`).
|
||||||
|
|
||||||
|
**Architecture:** MUI's CSS-variable theme is configured with `colorSchemeSelector: "[data-theme='%s']"` so it reads the same `data-theme` attribute `ThemeContext` already writes — one toggle drives both MUI and legacy CSS. A tiny sync component mirrors the attribute into MUI's JS color-scheme state. `ScopedCssBaseline` scopes MUI's reset to migrated subtrees (here, only `/ui-kit`); legacy pages keep `base.css`.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18.3, Vite 8, TypeScript (strict), MUI v7 (`@mui/material`) + Emotion + MUI X v8 pickers, Vitest (node).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§3, §4, §7 token-level layer, §8 Phase 0).
|
||||||
|
|
||||||
|
**Scope note:** This plan is Phase 0 ONLY. The rest of the `ui/` kit (Modal, Toast, Select, Switch, Tabs, DataTable, DatePicker), the AppShell (Phase 1), and the Vozidla pilot (Phase 2) are separate plans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Action |
|
||||||
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------ |
|
||||||
|
| `package.json` | MUI/Emotion/X-pickers deps; `@types/react` reconciled to 18 | Modify |
|
||||||
|
| `src/admin/theme.ts` | The MUI theme: cssVariables + colorSchemeSelector, light/dark palettes, typography, shape, component overrides | Create |
|
||||||
|
| `src/admin/theme.test.ts` | Unit test asserting theme palette/typography/shape values | Create |
|
||||||
|
| `src/admin/ui/MuiProvider.tsx` | Wraps children in MUI `ThemeProvider` + renders the sync component | Create |
|
||||||
|
| `src/admin/ui/MuiColorSchemeSync.tsx` | Mirrors `useTheme()` (custom) → MUI `useColorScheme().setMode` | Create |
|
||||||
|
| `src/admin/ui/Button.tsx`, `Card.tsx`, `TextField.tsx` | Thin brand wrappers over MUI (stable app-facing API) | Create |
|
||||||
|
| `src/admin/ui/index.ts` | Barrel export for the kit | Create |
|
||||||
|
| `src/admin/pages/UiKit.tsx` | Dev-only showcase, wrapped in `ScopedCssBaseline` | Create |
|
||||||
|
| `src/admin/AdminApp.tsx` | Mount `MuiProvider`; add DEV-only `/ui-kit` route | Modify |
|
||||||
|
| `src/admin/variables.css` | Token-level palette refresh (canvas, chip tints) — app-wide | Modify |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Install dependencies and reconcile React types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `package.json` (via npm)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install MUI + Emotion + X pickers**
|
||||||
|
|
||||||
|
Run (date-fns ^4 is already a dependency):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @mui/material@^7 @emotion/react@^11 @emotion/styled@^11 @mui/x-date-pickers@^8
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: packages added to `dependencies`; no peer-dependency errors that block install.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reconcile `@types/react` to the React 18 runtime**
|
||||||
|
|
||||||
|
The repo runs `react@^18.3.1` but pins `@types/react@^19`. MUI's component types are sensitive to the `@types/react` major. Align the types to the runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @types/react@^18.3 @types/react-dom@^18.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: devDependencies now show `@types/react@^18.3.x`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the project still type-checks and builds**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both succeed with no errors (this is the pre-change baseline; if `tsc` was already failing, capture that before proceeding).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json
|
||||||
|
git commit -m "build(mui): add MUI v7 + Emotion + X pickers; align @types/react to 18"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create `theme.ts` with light/dark palettes (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/theme.ts`
|
||||||
|
- Test: `src/admin/theme.test.ts`
|
||||||
|
|
||||||
|
Token values are taken from `src/admin/variables.css` (`[data-theme="light"]` / `[data-theme="dark"]`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/admin/theme.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { theme } from "./theme";
|
||||||
|
|
||||||
|
describe("MUI theme", () => {
|
||||||
|
it("uses the brand fonts", () => {
|
||||||
|
expect(theme.typography.fontFamily).toContain("Plus Jakarta Sans");
|
||||||
|
expect(String(theme.typography.h1.fontFamily)).toContain("Urbanist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the brand red per color scheme", () => {
|
||||||
|
expect(theme.colorSchemes.light.palette.primary.main).toBe("#c73030");
|
||||||
|
expect(theme.colorSchemes.dark.palette.primary.main).toBe("#d63031");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the refreshed light canvas + paper", () => {
|
||||||
|
expect(theme.colorSchemes.light.palette.background.default).toBe("#f4f3f1");
|
||||||
|
expect(theme.colorSchemes.light.palette.background.paper).toBe("#ffffff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps semantic colors per scheme", () => {
|
||||||
|
expect(theme.colorSchemes.light.palette.error.main).toBe("#b91c1c");
|
||||||
|
expect(theme.colorSchemes.dark.palette.success.main).toBe("#22c55e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the base radius of 10", () => {
|
||||||
|
expect(theme.shape.borderRadius).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `Cannot find module './theme'` (or `theme` is undefined).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `theme.ts` (palettes/typography/shape only — overrides come in Task 3)**
|
||||||
|
|
||||||
|
Create `src/admin/theme.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
||||||
|
const FONT_HEADING = "'Urbanist', sans-serif";
|
||||||
|
const FONT_MONO = "'DM Mono', Menlo, monospace";
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
cssVariables: {
|
||||||
|
// Read the SAME attribute ThemeContext already writes (`data-theme`).
|
||||||
|
// The "%s" placeholder is required; value must start with "." or "[".
|
||||||
|
colorSchemeSelector: "[data-theme='%s']",
|
||||||
|
},
|
||||||
|
colorSchemes: {
|
||||||
|
light: {
|
||||||
|
palette: {
|
||||||
|
mode: "light",
|
||||||
|
primary: { main: "#c73030" },
|
||||||
|
success: { main: "#15803d" },
|
||||||
|
warning: { main: "#b45309" },
|
||||||
|
error: { main: "#b91c1c" },
|
||||||
|
info: { main: "#1d4ed8" },
|
||||||
|
background: { default: "#f4f3f1", paper: "#ffffff" },
|
||||||
|
text: { primary: "#1a1a1a", secondary: "#555555" },
|
||||||
|
divider: "rgba(0,0,0,0.1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
palette: {
|
||||||
|
mode: "dark",
|
||||||
|
primary: { main: "#d63031" },
|
||||||
|
success: { main: "#22c55e" },
|
||||||
|
warning: { main: "#f59e0b" },
|
||||||
|
error: { main: "#ef4444" },
|
||||||
|
info: { main: "#3b82f6" },
|
||||||
|
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
||||||
|
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
||||||
|
divider: "rgba(255,255,255,0.08)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: { borderRadius: 10 },
|
||||||
|
typography: {
|
||||||
|
fontFamily: FONT_BODY,
|
||||||
|
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
|
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
|
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
|
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
button: { textTransform: "none", fontWeight: 600 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-exported for tasks/components that need the mono stack.
|
||||||
|
export const fonts = {
|
||||||
|
body: FONT_BODY,
|
||||||
|
heading: FONT_HEADING,
|
||||||
|
mono: FONT_MONO,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||||
|
git commit -m "feat(mui): theme.ts with light/dark palettes, brand fonts, data-theme selector"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add component overrides (the "Soft-SaaS" defaults)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/theme.ts`
|
||||||
|
- Test: `src/admin/theme.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a failing assertion for the overrides**
|
||||||
|
|
||||||
|
Append to `src/admin/theme.test.ts` (inside the `describe`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("makes buttons pill-shaped and cards 16px by default", () => {
|
||||||
|
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
||||||
|
expect(btn?.borderRadius).toBe(999);
|
||||||
|
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
|
||||||
|
expect(card?.borderRadius).toBe(16);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `theme.components` is undefined / `borderRadius` is undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `components` to the `createTheme` call**
|
||||||
|
|
||||||
|
In `src/admin/theme.ts`, add a `components` key to the `createTheme({...})` object (after `typography`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true },
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 999, paddingInline: "0.95rem" },
|
||||||
|
containedPrimary: {
|
||||||
|
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
|
||||||
|
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow:
|
||||||
|
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: { root: { borderRadius: 999, fontWeight: 700 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (6 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||||
|
git commit -m "feat(mui): soft-SaaS component defaults (pill buttons, 16px cards, pill chips)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: MUI provider + color-scheme sync
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/ui/MuiColorSchemeSync.tsx`
|
||||||
|
- Create: `src/admin/ui/MuiProvider.tsx`
|
||||||
|
- Modify: `src/admin/AdminApp.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the sync component**
|
||||||
|
|
||||||
|
Create `src/admin/ui/MuiColorSchemeSync.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useColorScheme } from "@mui/material/styles";
|
||||||
|
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeContext is the single owner of the `data-theme` attribute. This mirrors
|
||||||
|
* its value into MUI's JS color-scheme state so theme.applyStyles('dark') and
|
||||||
|
* useColorScheme().mode stay in sync with the attribute the CSS selector reads.
|
||||||
|
*/
|
||||||
|
export default function MuiColorSchemeSync() {
|
||||||
|
const { theme } = useAppTheme();
|
||||||
|
const { mode, setMode } = useColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== theme) setMode(theme as "light" | "dark");
|
||||||
|
}, [theme, mode, setMode]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the provider wrapper**
|
||||||
|
|
||||||
|
Create `src/admin/ui/MuiProvider.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
import MuiColorSchemeSync from "./MuiColorSchemeSync";
|
||||||
|
|
||||||
|
export default function MuiProvider({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme} defaultMode="dark">
|
||||||
|
<MuiColorSchemeSync />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Mount the provider at the top of `AdminApp`**
|
||||||
|
|
||||||
|
In `src/admin/AdminApp.tsx`, add the import after the other component imports (e.g. after line 10):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiProvider from "./ui/MuiProvider";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then wrap the existing returned tree. Change:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<MuiProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
and change the matching closing tag at the end of the JSX:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
</AuthProvider>
|
||||||
|
</MuiProvider>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the app builds and renders with no console errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both succeed. (The user runs the dev server separately — do not start it. Verification of live render happens via the `/ui-kit` route in Task 6.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/ui/MuiProvider.tsx src/admin/ui/MuiColorSchemeSync.tsx src/admin/AdminApp.tsx
|
||||||
|
git commit -m "feat(mui): mount MUI provider + sync color scheme to data-theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: First UI-kit wrappers + barrel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/ui/Button.tsx`, `src/admin/ui/Card.tsx`, `src/admin/ui/TextField.tsx`, `src/admin/ui/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the wrappers**
|
||||||
|
|
||||||
|
Create `src/admin/ui/Button.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiButton, { type ButtonProps } from "@mui/material/Button";
|
||||||
|
|
||||||
|
/** App Button: defaults to the brand primary contained style. */
|
||||||
|
export default function Button(props: ButtonProps) {
|
||||||
|
return <MuiButton variant="contained" color="primary" {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/admin/ui/Card.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiCard, { type CardProps } from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
|
||||||
|
export default function Card({ children, ...props }: CardProps) {
|
||||||
|
return (
|
||||||
|
<MuiCard {...props}>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</MuiCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/admin/ui/TextField.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||||
|
|
||||||
|
/** App TextField: small + outlined + full width by default. */
|
||||||
|
export default function TextField(props: TextFieldProps) {
|
||||||
|
return <MuiTextField size="small" variant="outlined" fullWidth {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the barrel**
|
||||||
|
|
||||||
|
Create `src/admin/ui/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { default as MuiProvider } from "./MuiProvider";
|
||||||
|
export { default as Button } from "./Button";
|
||||||
|
export { default as Card } from "./Card";
|
||||||
|
export { default as TextField } from "./TextField";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify type-check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/ui/Button.tsx src/admin/ui/Card.tsx src/admin/ui/TextField.tsx src/admin/ui/index.ts
|
||||||
|
git commit -m "feat(mui): first ui-kit wrappers (Button, Card, TextField) + barrel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Dev-only `/ui-kit` showcase route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/pages/UiKit.tsx`
|
||||||
|
- Modify: `src/admin/AdminApp.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the showcase page**
|
||||||
|
|
||||||
|
Create `src/admin/pages/UiKit.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import { Button, Card, TextField } from "../ui";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
export default function UiKit() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<Box sx={{ p: 4, minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
|
||||||
|
<Typography variant="h4">UI Kit</Typography>
|
||||||
|
<Button color="inherit" variant="outlined" onClick={toggleTheme}>
|
||||||
|
Theme: {theme}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={3} sx={{ maxWidth: 520 }}>
|
||||||
|
<Card>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Faktury
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
12 vystavených tento měsíc
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} mb={2}>
|
||||||
|
<Chip label="Zaplaceno" color="success" size="small" />
|
||||||
|
<Chip label="Po splatnosti" color="error" size="small" />
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Hledat fakturu"
|
||||||
|
placeholder="Číslo nebo klient…"
|
||||||
|
/>
|
||||||
|
<Box mt={2}>
|
||||||
|
<Button>Nová faktura</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the route, DEV-only**
|
||||||
|
|
||||||
|
In `src/admin/AdminApp.tsx`, add the lazy import alongside the other lazy pages (after line 11's eager imports / with the `lazy(...)` block):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const UiKit = lazy(() => import("./pages/UiKit"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, inside `<Routes>`, add a sibling route to `login` (so the showcase renders without the shell), guarded so it is dropped from production builds:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
import.meta.env.DEV && <Route path="ui-kit" element={<UiKit />} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build (prod) excludes it and type-check passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both succeed. `import.meta.env.DEV` is `false` in the production build, so the route is tree-shaken.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual + Playwright verification**
|
||||||
|
|
||||||
|
The user runs the dev server. Once it's up, verify at `/ui-kit`:
|
||||||
|
|
||||||
|
- Card, button (red gradient, pill), chips (tinted), and text field render in the Soft-SaaS style.
|
||||||
|
- Clicking **Theme: dark/light** flips `data-theme` and **both MUI components AND the page background** swap — proving the `colorSchemeSelector` bridge works.
|
||||||
|
- No new console errors.
|
||||||
|
|
||||||
|
Capture baseline screenshots with the available Playwright tooling (light + dark) and save under `docs/superpowers/visual-baselines/phase-0-ui-kit-{light,dark}.png`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/pages/UiKit.tsx src/admin/AdminApp.tsx
|
||||||
|
git commit -m "feat(mui): dev-only /ui-kit showcase proving the theme + data-theme bridge"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Token-level palette refresh (app-wide)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/variables.css`
|
||||||
|
|
||||||
|
Per spec §7, the token-level palette refresh is applied app-wide so migrated and legacy pages share the refreshed palette. The only token change for Phase 0 is the light canvas (the new look's `#f4f3f1` vs current `#f5f4f2`); chip/structure changes are component-level (MUI) and need no token edit.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the light canvas token**
|
||||||
|
|
||||||
|
In `src/admin/variables.css`, inside the `[data-theme="light"]` block, change:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--bg-primary: #f5f4f2;
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--bg-primary: #f4f3f1;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual check**
|
||||||
|
|
||||||
|
With the dev server (user-run), confirm in light mode the app canvas is the refreshed `#f4f3f1` and nothing else shifted structurally. (This is the deliberate, minor app-wide color change noted in the spec — not a no-op.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/variables.css
|
||||||
|
git commit -m "style(tokens): refresh light canvas to #f4f3f1 (two-layer refresh, token level)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Phase-0 release verification
|
||||||
|
|
||||||
|
**Files:** none (verification + tag)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full type-check, unit tests, and build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `tsc` clean; 6 theme tests pass; server + client build succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Backend test suite still green (server untouched)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: existing `auth` + `numbering` suites pass (Phase 0 changed no server code).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Definition-of-done checklist (manual, dev server)**
|
||||||
|
|
||||||
|
- `/ui-kit` renders Button/Card/TextField/Chip in the Soft-SaaS style in **both** themes.
|
||||||
|
- Theme toggle flips `data-theme` and swaps MUI + legacy colors together; MUI's `useColorScheme().mode` matches (no console warnings).
|
||||||
|
- No new console errors on any existing page (the provider mount + token refresh are non-breaking).
|
||||||
|
- Light canvas is `#f4f3f1`.
|
||||||
|
- Production build excludes the `/ui-kit` route.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tag/release per the project release process**
|
||||||
|
|
||||||
|
Follow `CLAUDE.md` "Release Process" (bump `package.json`, build, tag, deploy) when shipping Phase 0. No Prisma migration is involved (frontend-only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (for the controller)
|
||||||
|
|
||||||
|
- **Spec coverage:** Phase 0 items from spec §8 are all covered — deps + `@types/react` (Task 1), `theme.ts` light/dark (Tasks 2–3), provider + `ScopedCssBaseline` + `ThemeContext`↔`setMode` sync (Tasks 4, 6), dev-only `/ui-kit` (Task 6), token-level refresh (Task 7). The full `ui/` kit, AppShell, and Vozidla are explicitly deferred to later plans (scope note).
|
||||||
|
- **Type consistency:** `theme` (named export) is used consistently in `theme.test.ts`, `MuiProvider.tsx`. `useTheme` refers to the app's `ThemeContext` (aliased `useAppTheme` in the sync component to avoid colliding with MUI's `useTheme`). Wrappers are default exports re-exported via the barrel.
|
||||||
|
- **Open risk to verify in Task 1/6:** exact MUI v7 API for `cssVariables.colorSchemeSelector` + `ThemeProvider` `defaultMode` — confirm against the installed version's docs; if `defaultMode` belongs on a different provider in the installed version, adjust in Task 4.
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# MUI Migration — Phase 1: AppShell — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Replace the admin chrome (`AdminLayout` + `Sidebar`) with a MUI `<AppShell>` in the Soft-SaaS look — a `Drawer`-based sidebar (permanent on desktop, temporary on mobile) with a white active "pill", a minimal topbar (hamburger + theme toggle), reusing the existing permission-filtered nav data and preserving all behavior (auth/2FA guards, logout scale+blur animation, ShortcutsHelp).
|
||||||
|
|
||||||
|
**Architecture:** The valuable, well-tested parts are **reused, not rewritten**: the `menuSections` nav data (icons, permissions, active-match rules) and the active/permission logic move into `navData.tsx`. `SidebarNav` renders that data with MUI `List`. `AppShell` provides the responsive `Drawer` + topbar + auth guards + the framer-motion logout wrapper, and renders `<Outlet/>`. `AdminLayout`/`Sidebar`/`layout.css` are deleted when `AppShell` lands.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (kept for logout), react-router v6, TypeScript strict.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 AppShell, §7 aesthetic, §8 Phase 1, §9 animation).
|
||||||
|
|
||||||
|
**Authoritative gates:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file), `npm run build:client`, `npx vitest run`. Live visual verification (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||||
|
|
||||||
|
**Behavior parity checklist (must all be preserved from `AdminLayout.tsx` + `Sidebar.tsx`):**
|
||||||
|
|
||||||
|
- Auth: `loading` → spinner; `!isAuthenticated` → `<Navigate to="/login" replace/>`; 2FA-setup redirect (`user.require2FA && !user.totpEnabled && pathname !== "/"` → `/`).
|
||||||
|
- Logout: `handleLogout` sets a logout alert (`setLogoutAlert()`), closes the mobile drawer, and calls `logout()` after 400ms; the layout plays the framer-motion `{scale:1.5, opacity:0, filter:"blur(12px)"}` exit over 0.4s.
|
||||||
|
- Sidebar: permission-filtered sections/items (empty sections hidden); custom active matching (`matchPrefix`/`matchAlso`/`matchExclude`/`end`); company logo with light/dark variant + `onError` fallback to `/images/logo-{dark,light}.png`; user chip (avatar initial + name + `roleDisplay`); logout button; mobile open/close.
|
||||||
|
- `ShortcutsHelp` still rendered; `<Outlet/>` for page content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Action |
|
||||||
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
|
||||||
|
| `src/admin/ui/navData.tsx` | `MenuItem`/`MenuSection` types, the `menuSections` array (moved verbatim from Sidebar), pure helpers `isItemActive(item, pathname)` + `hasItemPermission(item, hasPermission)` | Create (move) |
|
||||||
|
| `src/admin/ui/SidebarNav.tsx` | Renders `navData` with MUI `List`: logo header, sections, white-pill active items, user+logout footer | Create |
|
||||||
|
| `src/admin/ui/AppShell.tsx` | Responsive `Drawer` + topbar + auth guards + logout animation + `<Outlet/>` + `ShortcutsHelp` | Create |
|
||||||
|
| `src/admin/components/Sidebar.tsx` | (interim) import `menuSections`/helpers from `navData` instead of defining inline | Modify, then Delete |
|
||||||
|
| `src/admin/components/AdminLayout.tsx` | Replaced by `AppShell` | Delete |
|
||||||
|
| `src/admin/AdminApp.tsx` | Route uses `<AppShell/>` instead of `<AdminLayout/>`; remove `import "./layout.css"` | Modify |
|
||||||
|
| `src/admin/layout.css` | Chrome styling now in MUI | Delete |
|
||||||
|
|
||||||
|
Import paths from `src/admin/ui/` (same depth as `components/`): `../context/AuthContext`, `../../context/ThemeContext`, `../utils/api`, `../hooks/...`, `../theme`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extract nav data into `navData.tsx`
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/navData.tsx`; Modify `src/admin/components/Sidebar.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `src/admin/ui/navData.tsx`.** MOVE (cut) the following from `src/admin/components/Sidebar.tsx` **verbatim**: the `MenuItem` and `MenuSection` interfaces and the entire `menuSections` array (all 6 sections with their inline SVG icons — do not retype, move them unchanged). Then add the two pure helpers (extracted from Sidebar's inline closures), and export everything:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
end?: boolean;
|
||||||
|
permission?: string | string[];
|
||||||
|
matchPrefix?: string;
|
||||||
|
matchAlso?: string[];
|
||||||
|
matchExclude?: string[];
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuSection {
|
||||||
|
label: string;
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuSections: MenuSection[] = [
|
||||||
|
/* ... the 6 sections, MOVED verbatim from Sidebar.tsx ... */
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
|
||||||
|
export function isItemActive(item: MenuItem, pathname: string): boolean {
|
||||||
|
if (item.matchPrefix) {
|
||||||
|
let active = pathname.startsWith(item.matchPrefix);
|
||||||
|
if (active && item.matchExclude) {
|
||||||
|
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
|
||||||
|
}
|
||||||
|
if (!active && item.matchAlso) {
|
||||||
|
active = item.matchAlso.some((p) => pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (item.end) return pathname === item.path;
|
||||||
|
return pathname.startsWith(item.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permission check: true if no permission, or any listed permission is held. */
|
||||||
|
export function hasItemPermission(
|
||||||
|
item: MenuItem,
|
||||||
|
hasPermission: (p: string) => boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!item.permission) return true;
|
||||||
|
if (Array.isArray(item.permission)) {
|
||||||
|
return item.permission.some((p) => hasPermission(p));
|
||||||
|
}
|
||||||
|
return hasPermission(item.permission);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: the original Sidebar computed `matchAlso` separately in the `NavLink` className callback. The consolidated `isItemActive` above folds `matchAlso` in so a single function is the source of truth. Preserve every section/item exactly.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `Sidebar.tsx` to import from `navData`** (interim — Sidebar is deleted in Task 4). Remove the inline `MenuItem`/`MenuSection`/`menuSections` definitions and the inline `isItemActive`/`hasItemPermission`; import them from `../ui/navData`. In the `NavLink` `className` callback, replace the inline active+matchAlso logic with `isItemActive(item, location.pathname)`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (147 pass — no behavior change).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** `src/admin/ui/navData.tsx` + `src/admin/components/Sidebar.tsx` (msg: `refactor(mui): extract sidebar nav data + active/permission helpers to navData`; end with the `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `SidebarNav.tsx` (MUI drawer content)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/SidebarNav.tsx`.
|
||||||
|
|
||||||
|
Renders the permission-filtered nav with MUI. Props: `{ onNavigate?: () => void; onLogout: () => void }` (`onNavigate` closes the mobile drawer; `onLogout` is owned by `AppShell`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the component.** Requirements (write complete MUI code):
|
||||||
|
- `const { user, hasPermission } = useAuth();` (`../context/AuthContext`), `const { theme } = useTheme();` aliased from `../../context/ThemeContext`, `const location = useLocation();`.
|
||||||
|
- `visibleSections` = `menuSections` mapped to filter items by `hasItemPermission(item, hasPermission)`, dropping empty sections.
|
||||||
|
- Root: a flex column `Box`, `height: "100%"`, padding ~`1.5`.
|
||||||
|
- **Logo header:** `Box component="img"` with `src` = `/api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"}` and `onError` falling back to `/images/logo-${theme}.png`; `sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}`.
|
||||||
|
- **Nav:** scrollable `Box` (`flex: 1, overflowY: "auto"`). For each section: a `Typography variant="caption"` subheader (uppercase, `letterSpacing: ".06em"`, `fontWeight: 700`, `color: "text.secondary"`, `fontSize: ".64rem"`, `px: 1.5`), then a `List disablePadding` of items. Each item:
|
||||||
|
```tsx
|
||||||
|
<ListItemButton
|
||||||
|
component={NavLink}
|
||||||
|
to={item.path}
|
||||||
|
end={item.end}
|
||||||
|
onClick={onNavigate}
|
||||||
|
selected={isItemActive(item, location.pathname)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
mb: 0.25,
|
||||||
|
py: 0.6,
|
||||||
|
color: "text.secondary",
|
||||||
|
"& svg": { width: 18, height: 18 },
|
||||||
|
"&.Mui-selected, &.Mui-selected:hover": {
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
color: "primary.main",
|
||||||
|
fontWeight: 700,
|
||||||
|
boxShadow: "0 2px 8px rgba(20,20,40,0.07)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
|
||||||
|
{item.icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.label}
|
||||||
|
primaryTypographyProps={{ fontSize: ".82rem", fontWeight: "inherit" }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
```
|
||||||
|
(Use MUI's `selected` prop for the active "pill" — do NOT rely on NavLink's default active class, because the active rule is custom.)
|
||||||
|
- **Footer:** top-bordered `Box` (`borderColor: "divider"`) with a user chip (MUI `Avatar` `bgcolor: "primary.main"` showing `user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"`, plus name + `roleDisplay` `Typography` with `noWrap`), and a logout `Button` (`color="inherit"`, `fullWidth`, the existing logout SVG as `startIcon`, label "Odhlásit se", `onClick={onLogout}`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds. (Component not yet mounted; visual check is later.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit** `src/admin/ui/SidebarNav.tsx` (msg: `feat(mui): SidebarNav — MUI List nav with white-pill active state`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `AppShell.tsx` (responsive layout + guards + animation)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/AppShell.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the component** with this exact structure (fill the guard bodies from the parity checklist):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||||
|
import { setLogoutAlert } from "../utils/api";
|
||||||
|
import SidebarNav from "./SidebarNav";
|
||||||
|
import ShortcutsHelp from "../components/ShortcutsHelp";
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 248;
|
||||||
|
|
||||||
|
export default function AppShell() {
|
||||||
|
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useAppTheme();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
setMobileOpen(false);
|
||||||
|
setLogoutAlert();
|
||||||
|
setTimeout(() => logout(), 400);
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100vh",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</Box>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={
|
||||||
|
loggingOut
|
||||||
|
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||||
|
: { scale: 1, opacity: 1, filter: "none" }
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: loggingOut ? 0.4 : 0.25,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
}}
|
||||||
|
style={{ minHeight: "100vh" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100vh",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Desktop permanent drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: "none", md: "block" },
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
border: 0,
|
||||||
|
bgcolor: "background.default",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarNav onLogout={handleLogout} />
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Mobile temporary drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{
|
||||||
|
display: { xs: "block", md: "none" },
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarNav
|
||||||
|
onNavigate={() => setMobileOpen(false)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Main column */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
minHeight: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
aria-label="Otevřít menu"
|
||||||
|
sx={{ display: { md: "none" } }}
|
||||||
|
>
|
||||||
|
{/* hamburger SVG (3 lines), 24x24, stroke currentColor */}
|
||||||
|
</IconButton>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||||
|
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||||
|
>
|
||||||
|
{/* sun SVG when theme==='dark' is off / moon — keep the two SVGs from AdminLayout, render the one for the current theme */}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<ShortcutsHelp />
|
||||||
|
</motion.div>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill the two `IconButton` SVGs from the existing `AdminLayout.tsx` (the hamburger 3-line icon; the sun/moon — render the moon when `theme === "dark"`, the sun when light, matching the current toggle's meaning). Keep the `admin-spinner` class for the loading state (it's defined in a CSS file that still exists in Phase 1).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit** `src/admin/ui/AppShell.tsx` (msg: `feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Wire `AppShell`, delete legacy chrome
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/AdminApp.tsx`; Delete `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, `src/admin/layout.css`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Swap the route.** In `src/admin/AdminApp.tsx`: change the import `import AdminLayout from "./components/AdminLayout";` → `import AppShell from "./ui/AppShell";`, and the route `<Route element={<AdminLayout />}>` → `<Route element={<AppShell />}>`. Remove the line `import "./layout.css";`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Delete** `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, and `src/admin/layout.css`. Then grep the repo for any remaining import of those modules and fix/remove (there should be none beyond AdminApp).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify (full gates)** — `npx tsc -b --noEmit` clean; `npm run build` (server+client) succeeds and the prod bundle has no broken refs; `npx vitest run` (147 pass). Confirm `layout.css` is no longer imported anywhere.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** (msg: `feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css`; + trailer).
|
||||||
|
|
||||||
|
- [ ] **Step 5: MANUAL verification (USER, dev server) — deferred:** sidebar shows permission-filtered sections; active item is the white pill; navigation works + closes the mobile drawer; theme toggle works; logout plays the scale+blur animation; logo light/dark + fallback; responsive (permanent drawer ≥ md, hamburger + temporary drawer < md); no console errors; dark + light both correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (for the controller)
|
||||||
|
|
||||||
|
- **Spec coverage:** Phase 1 (spec §8) — AppShell chrome via MUI Drawer + topbar (Tasks 2–4), reusing nav data/permissions (Task 1), preserving the logout animation + guards (Task 3), deleting `layout.css` (Task 4). Login stays OUT of this (it's a sibling route — Phase 3), untouched.
|
||||||
|
- **Type consistency:** `navData` exports `MenuItem`/`MenuSection`/`menuSections`/`isItemActive`/`hasItemPermission`; `SidebarNav` and (interim) `Sidebar` both consume them. `AppShell` props to `SidebarNav`: `onLogout` (required), `onNavigate` (optional).
|
||||||
|
- **Parity risks to watch in review:** the consolidated `isItemActive` must reproduce the original `matchAlso` behavior (the original applied `matchAlso` only in the NavLink callback) — the spec reviewer must diff active states for `/attendance/admin` (matchAlso `/attendance/create`,`/attendance/location`) and `/offers` (matchExclude). The `selected` prop must not double-apply with NavLink's own active class. `ScopedCssBaseline` wraps the shell so MUI reset applies; unmigrated page content inside `<Outlet/>` still uses its own CSS.
|
||||||
|
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# MUI Migration — Phase 2: Data/Form Kit + Vozidla Pilot — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Build the data + form foundation components (`DataTable`, `Modal`, `ConfirmDialog`, field controls) and migrate the **Vozidla (Vehicles)** page fully onto them — the pilot that battle-tests the kit. Behavior stays identical; styling becomes MUI (Soft-SaaS shell + dense table).
|
||||||
|
|
||||||
|
**Architecture:** New wrappers in `src/admin/ui/` expose stable, app-friendly APIs (Czech labels) over MUI `Dialog`/`Table`/form controls, so pages import from `ui/` not `@mui`. `Modal`/`ConfirmDialog` preserve the existing `FormModal`/`ConfirmModal` prop shapes so page churn is minimal. `DataTable` is a small dense table that takes a `columns` config + `rows` + optional row actions. Vozidla is then rewritten to use them; its data hooks (`useQuery(vehicleListOptions)`, `useApiMutation`) are unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (page-entry animations kept), TanStack Query v5, TypeScript strict.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 kit, §5.1 DataTable, §7 dense-table aesthetic, §8 Phase 2).
|
||||||
|
|
||||||
|
**Authoritative gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (expect 148). Live visual check (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||||
|
|
||||||
|
**Reference (current Vozidla behavior to preserve):** `src/admin/pages/Vehicles.tsx` — list table (SPZ, Název, Značka/Model, Počáteční km, Aktuální km, Počet jízd, Stav, Akce) with `formatKm`; inactive rows dimmed; status badge toggles active/inactive via `toggleVehicle`; row actions edit/delete; add/edit `FormModal` with SPZ (uppercased)/Název (required) + Značka/Model/Počáteční km/aktivní checkbox + validation (`Zadejte SPZ`/`Zadejte název`); delete `ConfirmModal`; permission gate `vehicles.manage` → `<Forbidden/>`; empty state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Action |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
|
||||||
|
| `src/admin/ui/Modal.tsx` | MUI `Dialog` form modal; preserves FormModal API (`isOpen`/`onClose`/`onSubmit`/`title`/`subtitle`/`loading`/`submitDisabled`/`submitText`/`children`) | Create |
|
||||||
|
| `src/admin/ui/ConfirmDialog.tsx` | MUI `Dialog` confirm; preserves ConfirmModal API (`isOpen`/`onClose`/`onConfirm`/`title`/`message`/`confirmText`/`confirmVariant`/`loading`) | Create |
|
||||||
|
| `src/admin/ui/DataTable.tsx` | Dense MUI `Table` from a `columns` config + `rows`; right-aligned/mono numeric cells; row hover; optional `rowInactive` + actions column | Create |
|
||||||
|
| `src/admin/ui/Field.tsx` | Labeled field wrapper (label + required + error helper) for composing inputs; `SwitchField` for booleans | Create |
|
||||||
|
| `src/admin/ui/index.ts` | Export the new components | Modify |
|
||||||
|
| `src/admin/pages/Vehicles.tsx` | Rewrite to use the kit | Modify |
|
||||||
|
|
||||||
|
Import paths from `src/admin/ui/`: `../context/AlertContext`, `../theme`, etc. MUI via deep imports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `Modal` + `ConfirmDialog` (MUI Dialog wrappers)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/Modal.tsx`, `src/admin/ui/ConfirmDialog.tsx`; Modify `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Modal.tsx`** — MUI `Dialog` preserving the existing `FormModal` API:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import MuiButton from "@mui/material/Button";
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
submitDisabled?: boolean;
|
||||||
|
submitText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
maxWidth?: "xs" | "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
loading = false,
|
||||||
|
submitDisabled = false,
|
||||||
|
submitText = "Uložit",
|
||||||
|
cancelText = "Zrušit",
|
||||||
|
maxWidth = "sm",
|
||||||
|
}: ModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={loading ? undefined : onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth={maxWidth}
|
||||||
|
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: subtitle ? 0.5 : 1.5 }}>
|
||||||
|
{title}
|
||||||
|
{subtitle && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>{children}</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||||
|
{cancelText}
|
||||||
|
</MuiButton>
|
||||||
|
<MuiButton
|
||||||
|
onClick={onSubmit}
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || submitDisabled}
|
||||||
|
>
|
||||||
|
{loading ? "Ukládám…" : submitText}
|
||||||
|
</MuiButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `ConfirmDialog.tsx`** — MUI `Dialog` preserving the `ConfirmModal` API:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import MuiButton from "@mui/material/Button";
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
confirmVariant?: "primary" | "danger";
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = "Potvrdit",
|
||||||
|
cancelText = "Zrušit",
|
||||||
|
confirmVariant = "primary",
|
||||||
|
loading = false,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={loading ? undefined : onClose}
|
||||||
|
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||||
|
>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{message}</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||||
|
{cancelText}
|
||||||
|
</MuiButton>
|
||||||
|
<MuiButton
|
||||||
|
onClick={onConfirm}
|
||||||
|
variant="contained"
|
||||||
|
color={confirmVariant === "danger" ? "error" : "primary"}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Pracuji…" : confirmText}
|
||||||
|
</MuiButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** add `export { default as Modal } from "./Modal";` and `export { default as ConfirmDialog } from "./ConfirmDialog";` to `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
- [ ] **Step 5: Commit** (msg `feat(mui): Modal + ConfirmDialog (MUI Dialog wrappers)`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `DataTable` (dense MUI Table)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/DataTable.tsx`; Modify `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `DataTable.tsx`** — a generic dense table:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
|
export interface DataColumn<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
align?: "left" | "right";
|
||||||
|
mono?: boolean; // DM Mono numeric cell
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<T> {
|
||||||
|
columns: DataColumn<T>[];
|
||||||
|
rows: T[];
|
||||||
|
rowKey: (row: T) => string | number;
|
||||||
|
rowInactive?: (row: T) => boolean;
|
||||||
|
empty?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
rowInactive,
|
||||||
|
empty,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
if (rows.length === 0 && empty) return <>{empty}</>;
|
||||||
|
return (
|
||||||
|
<TableContainer sx={{ borderRadius: 2 }}>
|
||||||
|
<Table size="small" sx={{ "& td, & th": { borderColor: "divider" } }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<TableCell
|
||||||
|
key={c.key}
|
||||||
|
align={c.align}
|
||||||
|
sx={{
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: ".05em",
|
||||||
|
fontSize: ".64rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={rowKey(row)}
|
||||||
|
hover
|
||||||
|
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
|
||||||
|
>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<TableCell
|
||||||
|
key={c.key}
|
||||||
|
align={c.align}
|
||||||
|
sx={{
|
||||||
|
fontSize: ".8rem",
|
||||||
|
...(c.mono ? { fontFamily: "'DM Mono', monospace" } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.render(row)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Numeric cells: pass `align="right" mono` and render via the existing `formatKm`/`formatCurrency` — the table only styles, never re-formats.)
|
||||||
|
|
||||||
|
- [ ] **Step 2:** add `export { default as DataTable } from "./DataTable"; export type { DataColumn } from "./DataTable";` to `index.ts`.
|
||||||
|
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
- [ ] **Step 4: Commit** (msg `feat(mui): DataTable — dense MUI table with columns config`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Field controls (`Field` + `SwitchField`)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/Field.tsx`; Modify `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `Field.tsx`** with a labeled wrapper and a boolean switch:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
mb: 0.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<Box component="span" sx={{ color: "error.main", ml: 0.25 }}>
|
||||||
|
*
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{children}
|
||||||
|
{error ? (
|
||||||
|
<Typography variant="caption" color="error">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
) : hint ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{hint}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchField({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** add `export { Field, SwitchField } from "./Field";` to `index.ts`. (`Button`, `Card`, `TextField` are already exported.)
|
||||||
|
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
- [ ] **Step 4: Commit** (msg `feat(mui): Field + SwitchField form controls`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Migrate `Vehicles.tsx` onto the kit
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/Vehicles.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the page** keeping ALL existing data logic (the `useQuery(vehicleListOptions)`, `saveVehicle`/`deleteVehicle`/`toggleVehicle` `useApiMutation`s, validation, handlers, permission gate) UNCHANGED, and swapping only the presentation:
|
||||||
|
- Permission gate + loading + empty state: keep behavior; render loading with the existing `admin-spinner` (still in base.css), or a MUI `CircularProgress` — keep `admin-spinner` for now.
|
||||||
|
- Page header: an MUI `Box` (flex, title `Typography variant="h4"` "Správa vozidel" + the kit `Button` "Přidat vozidlo" with the plus SVG). Keep the framer-motion entry animation if desired (optional).
|
||||||
|
- List: kit `Card` wrapping a `DataTable<Vehicle>` with columns: SPZ (`mono`), Název, Značka/Model (the existing `brand/model` join with `—` fallback), Počáteční km (`align right`, `mono`, `formatKm(...) + " km"`), Aktuální km (right/mono), Počet jízd (right/mono), Stav (a clickable MUI `Chip` colored `success`/`default` toggling `toggleActive`), Akce (edit + delete `IconButton`s with the existing SVGs). `rowInactive: (v) => !v.is_active`. `empty` = the existing empty-state block (can keep markup or render a simple MUI message + Button).
|
||||||
|
- Add/Edit modal: kit `Modal` (`isOpen={showModal}`, `onClose`, `onSubmit={handleSubmit}`, `title`, `loading={saving}`) containing `Field`-wrapped kit `TextField`s for SPZ (uppercase onChange)/Název/Značka/Model, a number `TextField` (type="number") for Počáteční km with the hint, and a `SwitchField` for `is_active`. Wire `errors`/`setErrors` exactly as today.
|
||||||
|
- Delete: kit `ConfirmDialog` (`isOpen={deleteConfirm.show}`, `onConfirm={handleDelete}`, `confirmVariant="danger"`, the existing message).
|
||||||
|
- Remove imports of the old `FormModal`/`ConfirmModal`/`FormField`; import from `../ui`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (148 pass — no test touches this page, but confirm nothing broke).
|
||||||
|
- [ ] **Step 3: Commit** (msg `feat(mui): migrate Vozidla (Vehicles) page onto MUI kit`; + trailer).
|
||||||
|
- [ ] **Step 4: MANUAL (USER, dev server) — deferred:** list renders (dense, mono numerics, inactive dimmed); status chip toggles active/inactive; add/edit modal validates (SPZ/Název required) + saves; SPZ uppercases; delete confirm works; empty state; permission gate; dark + light; no console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Phase-2 verification + final review
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `npx tsc -b --noEmit` clean; `npm run build` (server+client); `npx vitest run` (148).
|
||||||
|
- [ ] **Step 2:** Final whole-Phase-2 code review (kit components + Vozidla migration; behavior parity vs the original Vehicles.tsx).
|
||||||
|
- [ ] **Step 3:** finishing-a-development-branch (merge to master after the user's visual check).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (for the controller)
|
||||||
|
|
||||||
|
- **Spec coverage:** §5 kit (Modal, ConfirmDialog, DataTable, field controls — Tasks 1–3), §5.1 dense DataTable (Task 2), §8 Phase 2 Vozidla pilot (Task 4). Pages still import only from `ui/`.
|
||||||
|
- **Parity risks:** Vozidla validation (`Zadejte SPZ`/`Zadejte název`) + SPZ uppercasing + the three mutations' invalidate keys (`["vehicles","trips"]`) must be preserved verbatim. The status-toggle was a button styled as a badge → now a clickable `Chip`; behavior (toggle active) identical. `Modal`'s `onClose` is disabled while `loading` (matches FormModal's guard).
|
||||||
|
- **No CSS file to delete:** Vozidla uses shared classes (forms/tables/buttons/components.css), not a `vehicles.css`; those shared files remain for not-yet-migrated pages. This page simply stops using them.
|
||||||
|
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# MUI Migration — Phase 3: Kit Expansion — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (or direct execution with gates). Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Build the reusable `src/admin/ui/` components the page migrations keep needing, so later phases are fast and consistent. No page is migrated in this phase; the dev-only `/ui-kit` route is extended to showcase + visually verify each new component.
|
||||||
|
|
||||||
|
**Why these (scout reuse counts):** `Select` ~34 pages, `Tabs` ~41 files, `StatusChip` ~34, plus paginated/sortable lists, dates, checkboxes, inline alerts.
|
||||||
|
|
||||||
|
**Tech Stack:** MUI v7 (`@mui/material`) + `@mui/x-date-pickers` v8 + `date-fns` (cs locale) + Emotion. TypeScript strict.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (`/ui-kit`) is the user's. No dev server started by implementers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Component(s) | API |
|
||||||
|
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------- | --------- | ---------------------------------------- |
|
||||||
|
| `src/admin/ui/Select.tsx` | `Select` | `{ value, onChange(v), options?: {value,label}[], children?, ...TextFieldProps }` over `<TextField select>` — composes inside `Field` |
|
||||||
|
| `src/admin/ui/StatusChip.tsx` | `StatusChip` | `{ label, color?: "default" | "success" | "error" | "warning" | "info", onClick?, size?, ...ChipProps }` |
|
||||||
|
| `src/admin/ui/Tabs.tsx` | `Tabs`, `TabPanel` | `Tabs: { value, onChange(v), tabs: {value,label}[] }`; `TabPanel: { value, current, children }` |
|
||||||
|
| `src/admin/ui/Pagination.tsx` | `Pagination` | `{ page, pageCount, onChange(p) }` (renders nothing if `pageCount<=1`) |
|
||||||
|
| `src/admin/ui/Checkbox.tsx` | `CheckboxField` | `{ label, checked, onChange(b) }` (FormControlLabel + Checkbox) |
|
||||||
|
| `src/admin/ui/Alert.tsx` | `Alert` | `{ severity, children, onClose? }` (inline MUI Alert) |
|
||||||
|
| `src/admin/ui/DataTable.tsx` | extend | add `DataColumn.sortKey?`; `DataTableProps.{ sortBy?, sortDir?, onSort?(key) }`; render `TableSortLabel` in sortable headers |
|
||||||
|
| `src/admin/ui/DateField.tsx` | `DateField` | `{ value: string("yyyy-MM-dd" | ""), onChange(v: string), ...}`over MUI X`DatePicker`, format `dd.MM.yyyy` |
|
||||||
|
| `src/admin/ui/MuiProvider.tsx` | extend | wrap children in `LocalizationProvider` (`AdapterDateFns`, cs locale) so date pickers work app-wide |
|
||||||
|
| `src/admin/ui/index.ts` | extend | export all new components |
|
||||||
|
| `src/admin/pages/UiKit.tsx` | extend | showcase each new component in both themes |
|
||||||
|
|
||||||
|
Deferred (build when their page is migrated): `Autocomplete` (warehouse pickers), `FilterBar` layout helper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `Select`, `StatusChip`, `CheckboxField`, `Alert` (simple wrappers)
|
||||||
|
|
||||||
|
**Files:** Create the four files; modify `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Select.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
type Props = Omit<TextFieldProps, "onChange" | "select" | "value"> & {
|
||||||
|
value: string | number;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options?: SelectOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dropdown styled like the kit TextField; composes inside <Field>. */
|
||||||
|
export default function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options
|
||||||
|
? options.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
: children}
|
||||||
|
</MuiTextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `StatusChip.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Chip, { type ChipProps } from "@mui/material/Chip";
|
||||||
|
|
||||||
|
type Props = Omit<ChipProps, "color"> & {
|
||||||
|
color?: "default" | "success" | "error" | "warning" | "info";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Semantic status chip. Pass onClick to make it a toggle. */
|
||||||
|
export default function StatusChip({
|
||||||
|
color = "default",
|
||||||
|
size = "small",
|
||||||
|
onClick,
|
||||||
|
sx,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{ fontWeight: 600, ...(onClick ? { cursor: "pointer" } : {}), ...sx }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `Checkbox.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import MuiCheckbox from "@mui/material/Checkbox";
|
||||||
|
|
||||||
|
export function CheckboxField({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<MuiCheckbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Import `React` type or use `import type { ReactNode } from "react"` and type `label: ReactNode`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: `Alert.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import MuiAlert from "@mui/material/Alert";
|
||||||
|
|
||||||
|
export default function Alert({
|
||||||
|
severity = "info",
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
severity?: "success" | "error" | "warning" | "info";
|
||||||
|
children: ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
|
||||||
|
{children}
|
||||||
|
</MuiAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:** export from `index.ts`: `Select` (+ `type SelectOption`), `StatusChip`, `{ CheckboxField }`, `Alert`.
|
||||||
|
- [ ] **Step 6: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Select, StatusChip, CheckboxField, Alert`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `Tabs` + `TabPanel`, `Pagination`
|
||||||
|
|
||||||
|
**Files:** Create `Tabs.tsx`, `Pagination.tsx`; modify `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Tabs.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import MuiTabs from "@mui/material/Tabs";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
|
export interface TabDef {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tabs,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tabs: TabDef[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MuiTabs
|
||||||
|
value={value}
|
||||||
|
onChange={(_, v) => onChange(v)}
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
minHeight: 44,
|
||||||
|
"& .MuiTab-root": { textTransform: "none", fontWeight: 600 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<Tab key={t.value} value={t.value} label={t.label} />
|
||||||
|
))}
|
||||||
|
</MuiTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPanel({
|
||||||
|
value,
|
||||||
|
current,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
current: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
if (value !== current) return null;
|
||||||
|
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `Pagination.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import MuiPagination from "@mui/material/Pagination";
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
if (pageCount <= 1) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||||
|
<MuiPagination
|
||||||
|
page={page}
|
||||||
|
count={pageCount}
|
||||||
|
onChange={(_, p) => onChange(p)}
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** export `{ Tabs, TabPanel }` (+ `type TabDef`) and `Pagination` from `index.ts`.
|
||||||
|
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Tabs/TabPanel + Pagination`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Sortable `DataTable`
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/ui/DataTable.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Extend the types:
|
||||||
|
- `DataColumn<T>`: add `sortKey?: string;`
|
||||||
|
- `DataTableProps<T>`: add `sortBy?: string; sortDir?: "asc" | "desc"; onSort?: (key: string) => void;`
|
||||||
|
- [ ] **Step 2:** In the header cell render, if `c.sortKey && onSort` is set, wrap the header in `TableSortLabel`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||||
|
// ...header cell:
|
||||||
|
{
|
||||||
|
c.sortKey && onSort ? (
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === c.sortKey}
|
||||||
|
direction={sortBy === c.sortKey ? (sortDir ?? "asc") : "asc"}
|
||||||
|
onClick={() => onSort(c.sortKey!)}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</TableSortLabel>
|
||||||
|
) : (
|
||||||
|
c.header
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-sortable columns (no `sortKey`) render the plain header (unchanged). The existing `bold`/`mono`/`align`/`rowInactive`/`empty` behavior is untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify** `tsc -b` clean, `build:client` OK, `vitest run` (148). **Commit** (`feat(mui): kit — sortable DataTable headers (TableSortLabel)`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `DateField` + LocalizationProvider
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/DateField.tsx`; modify `MuiProvider.tsx`, `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `LocalizationProvider` to `MuiProvider.tsx`** wrapping its children, so date pickers work everywhere:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
|
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
|
||||||
|
import { cs } from "date-fns/locale";
|
||||||
|
// inside MuiProvider, wrap {children} (and the sync) :
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
||||||
|
<MuiColorSchemeSync />
|
||||||
|
{children}
|
||||||
|
</LocalizationProvider>;
|
||||||
|
```
|
||||||
|
|
||||||
|
> Verify the exact adapter import path for the installed `@mui/x-date-pickers@8` against its docs (it may be `@mui/x-date-pickers/AdapterDateFns` rather than `...V3`). Pick the one that resolves; report which.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `DateField.tsx`** — string-in / string-out (`yyyy-MM-dd`) to match existing form state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
import { parseISO, format, isValid } from "date-fns";
|
||||||
|
|
||||||
|
export default function DateField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: { value: string; onChange: (v: string) => void } & Record<string, unknown>) {
|
||||||
|
const dateValue = value ? parseISO(value) : null;
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
value={dateValue && isValid(dateValue) ? dateValue : null}
|
||||||
|
onChange={(d) => onChange(d && isValid(d) ? format(d, "yyyy-MM-dd") : "")}
|
||||||
|
format="dd.MM.yyyy"
|
||||||
|
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** export `DateField` from `index.ts`.
|
||||||
|
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — DateField over MUI X + cs LocalizationProvider`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Extend `/ui-kit` showcase + final review
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/UiKit.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add a section rendering each new component with sample data, in the existing `ScopedCssBaseline` page: a `Select` (3 options), `StatusChip`s (each color), `Tabs`+`TabPanel` (2 tabs), `Pagination` (page 2 of 5), `CheckboxField`, `Alert` (one of each severity), a sortable `DataTable` (3 columns, one `sortKey`, local sort state), and a `DateField`.
|
||||||
|
- [ ] **Step 2: Verify** `tsc -b` clean, `npm run build` (server+client) OK, `npx vitest run` (148). **Commit** (`feat(mui): showcase new kit components in /ui-kit`; + trailer).
|
||||||
|
- [ ] **Step 3:** Final review of all new components (parallel/adversarial), then finishing-a-development-branch after the user's `/ui-kit` visual check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- All new components compose with the existing `Field` (Select/DateField are inputs; Field provides the label) and follow the size=small/outlined/fullWidth defaults.
|
||||||
|
- `DataTable` sort is **additive** — existing callers (Vehicles) are unaffected (no `sortKey`/`onSort` → plain headers).
|
||||||
|
- `DateField` is string-in/string-out so it drops into existing form state (`start_date: ""`), and uses cs locale + `dd.MM.yyyy` (matching the date convention in CLAUDE.md).
|
||||||
|
- Deferred: `Autocomplete`, `FilterBar` — built when warehouse/list pages are migrated.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# MUI Migration — Phase 4: Users (Uživatelé) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Migrate `src/admin/pages/Users.tsx` onto the MUI kit, mirroring the already-migrated `Vehicles.tsx`, preserving ALL data logic. Presentation only changes.
|
||||||
|
|
||||||
|
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button`, `Card`, `DataTable`, `Modal`, `ConfirmDialog`, `Field`, `TextField`, `SwitchField`, **`Select`** (new), **`StatusChip`** (new). MUI `Avatar`/`IconButton` used directly (as Vehicles uses `IconButton`).
|
||||||
|
|
||||||
|
**Reference:** `src/admin/pages/Vehicles.tsx` is the canonical migrated page — copy its structure (Box + motion.div header, `Button startIcon`, `Card` + `DataTable`, `Modal` with `Field`/`TextField`, `ConfirmDialog`). The Users data layer already exists; do not change it.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (dev server) is the user's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior to preserve VERBATIM (from the scout)
|
||||||
|
|
||||||
|
- **Three mutations + invalidation:** `USER_INVALIDATE = ["users","trips","attendance","leave-requests","leave","projects"]`. `saveUser` (PUT if `editingUser` else POST), `deleteUser` (DELETE), `toggleUser` (PUT `{id,is_active}`). Keep all `onSuccess` messages.
|
||||||
|
- **Self-edit AuthContext sync (critical):** in `saveUser.onSuccess`, when `editingUser && currentUser && Number(editingUser.id)===Number(currentUser.id)`, call `updateUser({ username, email, fullName: \`${first_name} ${last_name}\`.trim() })`. Then `setShowModal(false); setEditingUser(null);`then`await new Promise(r=>setTimeout(r,300))` **(intentional delay — keep it)** before the success toast.
|
||||||
|
- **Auth guard:** `const { user: currentUser, updateUser, hasPermission } = useAuth();` → `if (!hasPermission("users.view")) return <Forbidden/>;`
|
||||||
|
- **Self-protection:** the delete action is **hidden** and the status toggle is **disabled** when `user.id === currentUser?.id`. These reference the live `currentUser` — keep them in the column render closures.
|
||||||
|
- **Password:** required only on create (`!editingUser`); empty string on edit means "keep existing".
|
||||||
|
- **Role dropdown:** options from `roleListOptions()` query; default to `roles[0]?.id` on create. `role_id` is `number|string` in form state.
|
||||||
|
- **`FormData` shape:** `{ username, email, password, first_name, last_name, role_id, is_active }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Migrate `Users.tsx`
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/Users.tsx` (read it first; it's the source of truth for the logic above).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `src/admin/pages/Users.tsx` fully and `src/admin/pages/Vehicles.tsx` (the template). Keep ALL hooks, mutations, state, handlers, and the guard exactly; change only the rendered JSX.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, SwitchField, Select, StatusChip, type DataColumn`. From `@mui/material`: `Box, Typography, Avatar, IconButton`. Keep `motion` (framer-motion), `useQuery`, `roleListOptions`, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. **Remove** old imports: `FormModal`, `ConfirmModal`, `FormField`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Page shell** — mirror Vehicles: outer `Box`; `motion.div` header (`Typography variant="h4"` "Správa uživatelů" + `Button startIcon={PlusIcon}` "Přidat uživatele"); `motion.div` + `Card` wrapping the `DataTable`. Loading state keeps the `admin-spinner` markup.
|
||||||
|
|
||||||
|
- [ ] **Step 4: DataTable columns** (`DataColumn<User>[]`, defined inside the component so they close over `currentUser`/handlers):
|
||||||
|
- **Uživatel:** composite render — MUI `Avatar` (initial of `full_name`/`username`, `bgcolor:"primary.main"`) + name (`Typography`) + `@username` (`Typography variant="caption" color="text.secondary"`).
|
||||||
|
- **Email:** `render: (u) => u.email`.
|
||||||
|
- **Role:** `StatusChip` colored by role — map `"admin"` → `color="warning"`, else `"default"`; label = `roleDisplay`/role name.
|
||||||
|
- **Stav:** clickable `StatusChip` (`color={u.is_active?"success":"default"}`, `onClick` → `toggleActive(u)`), but **non-clickable / no onClick when `u.id===currentUser?.id`** (self-protection).
|
||||||
|
- **Akce:** `IconButton` edit (always); `IconButton` delete (`color="error"`) **only when `u.id !== currentUser?.id`**. Use the same edit/delete SVGs as Vehicles.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add/Edit `Modal`** — `Field` + kit `TextField` for username/email/first_name/last_name; password `TextField type="password"` (label notes optional on edit); **`Select`** (inside a `Field` label="Role") for `role_id` with `options` from the roles query (`value: String(r.id), label: r.display_name||r.name`) — note `Select` is string-based, so store/compare `role_id` as string or `String(...)` at the boundary; `SwitchField` for `is_active`. Add a local `errors` state + validation in `handleSubmit` (required: username, email, first_name, last_name; password required when `!editingUser`) mirroring Vehicles, before `saveUser.mutateAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Delete `ConfirmDialog`** — drop-in replacement for `ConfirmModal` (title "Smazat uživatele", message with the user's name, `confirmVariant="danger"`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify** `npx tsc -b --noEmit` clean; `npm run build` OK; `npx vitest run` (148). Drop all `admin-*` class names.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit** (`feat(mui): migrate Users (Uživatelé) page onto MUI kit`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||||
|
|
||||||
|
- [ ] **Step 9: MANUAL (USER, dev server) — deferred:** list renders (avatar/role/status); status toggle works + is disabled for self; delete hidden for self; add (password required) + edit (password optional, role Select) save; self-edit updates the sidebar name; validation; dark+light; no console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Review + finish
|
||||||
|
|
||||||
|
- [ ] Spec-compliance review (esp. the self-edit AuthContext sync, self-protection, password-on-create-only, role Select string boundary, USER_INVALIDATE) then code-quality review.
|
||||||
|
- [ ] finishing-a-development-branch after the user's visual check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- The role `Select` is string-based (per the Phase 3 review fix); convert `role_id` at the boundary (`String(r.id)` for options/value; the server accepts the string or coerce on submit if the API needs a number — check how the existing code sends it and match).
|
||||||
|
- Self-protection guards and the self-edit `updateUser` sync are the highest-risk items — the spec reviewer must confirm both survive the column-render refactor.
|
||||||
|
- No `users.css` exists; Users uses shared classes — nothing to delete, it just stops using them.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# MUI Migration — Phase 5: Projects (Projekty) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Migrate `src/admin/pages/Projects.tsx` onto the MUI kit. It's a **paginated, server-sorted list** with a create modal (customer/user `Select`s, `DateField`s, multiline notes) and a delete confirm that has an extra "delete files from disk" checkbox. Mirror `Vehicles.tsx`/`Users.tsx`; preserve ALL data logic.
|
||||||
|
|
||||||
|
**Prereq kit tweak (Task 1):** `ConfirmDialog` needs to accept optional extra content below the message (for the delete-files checkbox), since `message` is a string.
|
||||||
|
|
||||||
|
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button, Card, DataTable` (now sortable), `Pagination`, `Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box/Typography/IconButton` direct.
|
||||||
|
|
||||||
|
**Reference:** `Vehicles.tsx` + `Users.tsx` (migrated). The Projects data layer is authoritative — keep it.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check is the user's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior to preserve VERBATIM (from the scout)
|
||||||
|
|
||||||
|
- **List state:** `const { sort, order, handleSort, activeSort } = useTableSort("project_number")`; `search`/`page` state; **reset `setPage(1)` when search changes**. `usePaginatedQuery<Project>(projectListOptions({ search, sort, order, page }))` → `{ items, pagination, isPending, isFetching }`.
|
||||||
|
- **isFetching dimming:** the list Card gets `sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}`.
|
||||||
|
- **Create-only** (edit navigates to the `/projects/:id` detail route — NOT a modal). `createForm = { name, customer_id, responsible_user_id, status: "aktivni", start_date, end_date, notes }`, `createErrors`, `creating`. Dependent queries enabled only while the modal is open: `offerCustomersOptions()`, `userListOptions("projects.view")` (filter to `is_active`), `projectNextNumberOptions()`. `createMutation`: POST `/projects`, `invalidate: ["projects","orders","offers","invoices","attendance"]`, onSuccess close + "Projekt byl vytvořen". **Validation: only `name` required ("Zadejte název projektu").**
|
||||||
|
- **Delete:** `deleteTarget`/`deleteFiles` state. `deleteMutation`: DELETE `/projects/:id`, `invalidate: ["projects","warehouse","orders","offers","invoices","attendance"]`, called as `mutateAsync({ id, delete_files: deleteFiles })`, onSuccess toast `data?.message || "Projekt byl smazán"` + reset.
|
||||||
|
- **Permissions:** `projects.view` → `<Forbidden/>`; `projects.create` hides the add button; `projects.delete` hides the per-row delete; delete also hidden when `p.order_id` (order-linked projects can't be manually deleted).
|
||||||
|
- **Status constants:** `STATUS_LABELS = { aktivni:"Aktivní", dokonceny:"Dokončený", zruseny:"Zrušený" }`. Map to `StatusChip` colors: `aktivni`→`success`, `dokonceny`→`info`, `zruseny`→`default`.
|
||||||
|
- **Next number:** `nextNumberQuery.data?.number ?? nextNumberQuery.data?.next_number ?? "…"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend `ConfirmDialog` with optional extra content
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/ui/ConfirmDialog.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add `children?: ReactNode` to `ConfirmDialogProps` (import `type { ReactNode }`). Render it in `DialogContent` BELOW the message (live — not frozen, so the checkbox stays interactive):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{shown.message}</DialogContentText>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify `tsc -b` clean; `build:client` OK; existing callers (Vozidla, Users) unaffected (no `children` → unchanged). **Commit** (`feat(mui): ConfirmDialog optional children (extra content below message)`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Migrate `Projects.tsx`
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/Projects.tsx` (read it first — authoritative for the logic above).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `src/admin/pages/Projects.tsx` fully + `Users.tsx`/`Vehicles.tsx`. Keep ALL hooks/queries/mutations/handlers/permission-guards/state. Change only presentation.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box, Typography, IconButton` direct. Keep `motion`, `useNavigate`, the query options, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. Remove `FormModal`/`ConfirmModal`/`FormField`/`AdminDatePicker`/legacy `Pagination` imports.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Page shell** — Box + motion.div header (`Typography variant="h4"` "Projekty" + `Button startIcon={PlusIcon}` "Nový projekt", hidden without `projects.create`). A search `TextField` (no `Field` wrapper) that sets search + `setPage(1)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: List** — `Card` (with the `isFetching` opacity sx) wrapping a sortable `DataTable<Project>`:
|
||||||
|
- Wire sort: `sortBy={sort} sortDir={order} onSort={handleSort}`. Give sortable columns a `sortKey` (e.g. `project_number`, `name` — match the server's accepted sort keys from the original `SortIcon` usage).
|
||||||
|
- Columns: project number (`mono`, `sortKey`), name (`sortKey`), customer, responsible user, status (`StatusChip` per the color map), linked order number (if `order_id`), start/end dates (`mono`, via the existing date formatter), actions — a detail link/`IconButton` that `navigate(\`/projects/${p.id}\`)`for edit, and a delete`IconButton color="error"`shown only when`hasPermission("projects.delete") && !p.order_id`.
|
||||||
|
- Below the table: `<Pagination page={page} pageCount={pagination?.totalPages ?? 1} onChange={setPage} />` (use the actual page-count field from `pagination` — check the shape).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create `Modal`** (`isOpen={showCreate}`, `loading={creating}`, title "Nový projekt", subtitle/hint with the next number): `Field`+`TextField` for name (required, error from `createErrors`); `Select` (in a `Field`) for customer (`offerCustomersOptions`) and responsible user (active users) — options `{ value: String(id), label }`, convert at the boundary; `Select` for status (the STATUS_LABELS options); two `DateField`s for start/end (string in/out — matches `createForm.start_date`); `TextField multiline minRows={3}` for notes. Validation: only name required.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Delete `ConfirmDialog`** — title "Smazat projekt", message with the project name, `confirmVariant="danger"`, and as **children** a `CheckboxField` "Smazat i soubory z disku" bound to `deleteFiles`. `onConfirm` calls the existing delete handler (`mutateAsync({ id, delete_files: deleteFiles })`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify** `tsc -b` clean; `npm run build` OK; `npx vitest run` (148). Drop `admin-*` classes. (Projects has no own CSS file; it uses shared classes.)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit** (`feat(mui): migrate Projects (Projekty) page onto MUI kit`; + trailer).
|
||||||
|
|
||||||
|
- [ ] **Step 9: MANUAL (USER) — deferred:** list renders + sorts (click headers) + paginates + dims while fetching; create modal (customer/user/status selects, date pickers, notes; name-required validation; next number shown) saves; delete confirm with the files checkbox works; order-linked projects show no delete; permission gating; dark+light.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Review + finish
|
||||||
|
|
||||||
|
- [ ] Spec-compliance review (server sort/pagination wiring, the create `invalidate` arrays, name-only validation, delete `{id, delete_files}` body + the checkbox, permission/order-link gating, status color map, the Select string boundaries) → code-quality review.
|
||||||
|
- [ ] finishing-a-development-branch after the user's visual check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- This is the first page to exercise the **sortable DataTable** + **Pagination** + **DateField** + **Select** together — confirm the sort keys match what the server accepts and the pagination page-count field name is correct (read the query options / a working paginated page).
|
||||||
|
- `Select` is string-based; convert customer/user/status ids at the form boundary (`String(...)` for options/value; send what the server expects).
|
||||||
|
- The delete-files checkbox rides in `ConfirmDialog` `children` (Task 1) — it's live (not frozen), so it toggles while open; it resets via the caller on close.
|
||||||
|
- Edit is a route navigation (`/projects/:id`), NOT a modal — don't add an edit modal.
|
||||||
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.
|
||||||
914
package-lock.json
generated
914
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.9.1",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -32,11 +32,15 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@mui/material": "^7.3.11",
|
||||||
|
"@mui/x-date-pickers": "^8.29.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@tanstack/react-query": "^5.100.5",
|
"@tanstack/react-query": "^5.100.5",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
@@ -74,8 +78,8 @@
|
|||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^18.3.31",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
|
||||||
|
-- project creation was deleted). Idempotent: re-running is a no-op.
|
||||||
|
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).
|
||||||
|
|
||||||
|
-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
|
||||||
|
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||||
|
('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());
|
||||||
|
|
||||||
|
-- 2. Grant it to the admin role (idempotent via composite PK).
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM `roles` r
|
||||||
|
CROSS JOIN `permissions` p
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
AND p.name IN ('projects.create');
|
||||||
@@ -172,6 +172,12 @@ const PERMISSIONS: {
|
|||||||
module: "projects",
|
module: "projects",
|
||||||
description: "Prohlížet seznam projektů",
|
description: "Prohlížet seznam projektů",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "projects.create",
|
||||||
|
display_name: "Vytvořit projekt",
|
||||||
|
module: "projects",
|
||||||
|
description: "Ručně vytvářet projekty",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "projects.edit",
|
name: "projects.edit",
|
||||||
display_name: "Upravit projekt",
|
display_name: "Upravit projekt",
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -2,7 +2,13 @@ import { Suspense } from "react";
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import AdminApp from "./admin/AdminApp";
|
import AdminApp from "./admin/AdminApp";
|
||||||
|
|
||||||
|
// Bootstrap loader: renders before MuiProvider (and its global styles) mount,
|
||||||
|
// so it must be fully self-contained — no theme vars, no global CSS classes.
|
||||||
|
// Background follows the persisted theme attribute set by ThemeContext.
|
||||||
function AdminLoader() {
|
function AdminLoader() {
|
||||||
|
const light =
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
document.documentElement.getAttribute("data-theme") === "light";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -10,10 +16,20 @@ function AdminLoader() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
background: "var(--bg-primary)",
|
background: light ? "#f4f3f1" : "#0f0f0f",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-spinner" />
|
<style>{"@keyframes boha-boot-spin{to{transform:rotate(360deg)}}"}</style>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "2px solid #d63031",
|
||||||
|
borderTopColor: "transparent",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "boha-boot-spin 0.8s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/__tests__/manual-create.test.ts
Normal file
142
src/__tests__/manual-create.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { createProject } from "../services/projects.service";
|
||||||
|
import { createOrder } from "../services/orders.service";
|
||||||
|
|
||||||
|
const createdProjectIds: number[] = [];
|
||||||
|
const createdOrderIds: number[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of createdProjectIds)
|
||||||
|
await prisma.projects.deleteMany({ where: { id } });
|
||||||
|
for (const id of createdOrderIds)
|
||||||
|
await prisma.orders.deleteMany({ where: { id } });
|
||||||
|
createdProjectIds.length = 0;
|
||||||
|
createdOrderIds.length = 0;
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseOrder = {
|
||||||
|
status: "prijata" as const,
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
exchange_rate: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createOrder (manual, optional linked project)", () => {
|
||||||
|
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
scope_title: "Ruční zakázka",
|
||||||
|
create_project: true,
|
||||||
|
});
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
expect(res.project_id).toBeTruthy();
|
||||||
|
const project = await prisma.projects.findUnique({
|
||||||
|
where: { id: res.project_id! },
|
||||||
|
});
|
||||||
|
if (project) createdProjectIds.push(project.id);
|
||||||
|
expect(project?.project_number).toBe(res.order_number);
|
||||||
|
expect(project?.order_id).toBe(res.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates only the order when create_project=false", async () => {
|
||||||
|
const res = await createOrder({ ...baseOrder, create_project: false });
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
expect(res.project_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared pool coherence (tracking)", () => {
|
||||||
|
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
||||||
|
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||||
|
if ("id" in o1) {
|
||||||
|
createdOrderIds.push(o1.id);
|
||||||
|
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||||
|
}
|
||||||
|
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||||
|
if ("project_number" in p) createdProjectIds.push(p.id);
|
||||||
|
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||||
|
if ("id" in o2) createdOrderIds.push(o2.id);
|
||||||
|
|
||||||
|
const n1 = "id" in o1 ? o1.order_number : null;
|
||||||
|
const np = "project_number" in p ? p.project_number : null;
|
||||||
|
const n2 = "id" in o2 ? o2.order_number : null;
|
||||||
|
expect(new Set([n1, np, n2]).size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createProject (manual, standalone)", () => {
|
||||||
|
it("assigns a shared number and is not linked to an order", async () => {
|
||||||
|
const result = await createProject({
|
||||||
|
name: "Test projekt",
|
||||||
|
status: "aktivni",
|
||||||
|
});
|
||||||
|
expect("project_number" in result).toBe(true);
|
||||||
|
if (!("project_number" in result)) return;
|
||||||
|
createdProjectIds.push(result.id);
|
||||||
|
expect(typeof result.project_number).toBe("string");
|
||||||
|
expect(result.project_number!.length).toBeGreaterThan(0);
|
||||||
|
expect(result.order_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives consecutive standalone projects distinct numbers", async () => {
|
||||||
|
const a = await createProject({ name: "Projekt A", status: "aktivni" });
|
||||||
|
const b = await createProject({ name: "Projekt B", status: "aktivni" });
|
||||||
|
if ("project_number" in a) createdProjectIds.push(a.id);
|
||||||
|
if ("project_number" in b) createdProjectIds.push(b.id);
|
||||||
|
expect("project_number" in a && "project_number" in b).toBe(true);
|
||||||
|
if ("project_number" in a && "project_number" in b) {
|
||||||
|
expect(a.project_number).not.toBe(b.project_number);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createOrder with price line item + attachment", () => {
|
||||||
|
it("stores a single line item with the entered price", async () => {
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
create_project: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "Práce",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 12345,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
const items = await prisma.order_items.findMany({
|
||||||
|
where: { order_id: res.id },
|
||||||
|
});
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
expect(Number(items[0].unit_price)).toBe(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores an uploaded PO attachment", async () => {
|
||||||
|
const buf = Buffer.from("%PDF-1.4 fake");
|
||||||
|
const res = await createOrder(
|
||||||
|
{ ...baseOrder, create_project: false },
|
||||||
|
buf,
|
||||||
|
"po.pdf",
|
||||||
|
);
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
const order = await prisma.orders.findUnique({
|
||||||
|
where: { id: res.id },
|
||||||
|
select: { attachment_name: true, attachment_data: true },
|
||||||
|
});
|
||||||
|
expect(order?.attachment_name).toBe("po.pdf");
|
||||||
|
expect(order?.attachment_data).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import {
|
import {
|
||||||
generateSharedNumber,
|
generateSharedNumber,
|
||||||
generateOfferNumber,
|
generateOfferNumber,
|
||||||
|
previewSharedNumber,
|
||||||
|
isSharedNumberTaken,
|
||||||
|
previewOfferNumber,
|
||||||
|
isOfferNumberTaken,
|
||||||
} from "../services/numbering.service";
|
} from "../services/numbering.service";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
|
||||||
@@ -48,3 +52,83 @@ describe("generateOfferNumber", () => {
|
|||||||
expect(num1).not.toBe(num2);
|
expect(num1).not.toBe(num2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("previewSharedNumber", () => {
|
||||||
|
let createdProjectId: number | null = null;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (createdProjectId !== null) {
|
||||||
|
await prisma.projects
|
||||||
|
.delete({ where: { id: createdProjectId } })
|
||||||
|
.catch(() => {});
|
||||||
|
createdProjectId = null;
|
||||||
|
}
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a number not already used by an order or project", async () => {
|
||||||
|
const preview = await previewSharedNumber();
|
||||||
|
expect(typeof preview).toBe("string");
|
||||||
|
expect(await isSharedNumberTaken(preview)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||||
|
// Fresh counter → preview points at the first sequence number. With the
|
||||||
|
// skip-taken logic this is guaranteed free, so we can safely claim it.
|
||||||
|
const first = await previewSharedNumber();
|
||||||
|
const project = await prisma.projects.create({
|
||||||
|
data: { project_number: first, name: "test-preview-skip-taken" },
|
||||||
|
});
|
||||||
|
createdProjectId = project.id;
|
||||||
|
|
||||||
|
// The counter still lags behind (no row was consumed), but `first` is now
|
||||||
|
// taken — the preview must advance past it instead of re-offering it.
|
||||||
|
const second = await previewSharedNumber();
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(await isSharedNumberTaken(second)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previewOfferNumber", () => {
|
||||||
|
let createdQuotationId: number | null = null;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (createdQuotationId !== null) {
|
||||||
|
await prisma.quotations
|
||||||
|
.delete({ where: { id: createdQuotationId } })
|
||||||
|
.catch(() => {});
|
||||||
|
createdQuotationId = null;
|
||||||
|
}
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a number not already used by a quotation", async () => {
|
||||||
|
const preview = await previewOfferNumber();
|
||||||
|
expect(typeof preview).toBe("string");
|
||||||
|
expect(await isOfferNumberTaken(preview)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||||
|
// Fresh counter → preview points at the first sequence number; with the
|
||||||
|
// skip-taken logic it's guaranteed free, so we can claim it.
|
||||||
|
const first = await previewOfferNumber();
|
||||||
|
const quotation = await prisma.quotations.create({
|
||||||
|
data: { quotation_number: first },
|
||||||
|
});
|
||||||
|
createdQuotationId = quotation.id;
|
||||||
|
|
||||||
|
// The counter still lags, but `first` is now taken — the preview must
|
||||||
|
// advance past it instead of re-offering it.
|
||||||
|
const second = await previewOfferNumber();
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(await isOfferNumberTaken(second)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,29 +5,12 @@ import { AuthProvider } from "./context/AuthContext";
|
|||||||
import { AlertProvider } from "./context/AlertContext";
|
import { AlertProvider } from "./context/AlertContext";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import AdminLayout from "./components/AdminLayout";
|
import AppShell from "./ui/AppShell";
|
||||||
import AlertContainer from "./components/AlertContainer";
|
import AlertContainer from "./components/AlertContainer";
|
||||||
|
import MuiProvider from "./ui/MuiProvider";
|
||||||
|
import { LoadingState } from "./ui";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import "./variables.css";
|
|
||||||
import "./base.css";
|
|
||||||
import "./forms.css";
|
|
||||||
import "./buttons.css";
|
|
||||||
import "./layout.css";
|
|
||||||
import "./components.css";
|
|
||||||
import "./tables.css";
|
|
||||||
import "./datepicker.css";
|
|
||||||
import "./filemanager.css";
|
|
||||||
import "./pagination.css";
|
|
||||||
import "./responsive.css";
|
|
||||||
import "./login.css";
|
|
||||||
import "./dashboard.css";
|
|
||||||
import "./attendance.css";
|
|
||||||
import "./plan.css";
|
|
||||||
import "./settings.css";
|
|
||||||
import "./offers.css";
|
|
||||||
import "./invoices.css";
|
|
||||||
import "./warehouse.css";
|
|
||||||
|
|
||||||
const Users = lazy(() => import("./pages/Users"));
|
const Users = lazy(() => import("./pages/Users"));
|
||||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||||
@@ -81,157 +64,160 @@ const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
|||||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||||
|
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||||
|
|
||||||
export default function AdminApp() {
|
export default function AdminApp() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<MuiProvider>
|
||||||
<AlertProvider>
|
<AuthProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AlertProvider>
|
||||||
<AlertContainer />
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
<AlertContainer />
|
||||||
<Suspense
|
<ErrorBoundary>
|
||||||
fallback={
|
<Suspense fallback={<LoadingState />}>
|
||||||
<div className="admin-loading">
|
<Routes>
|
||||||
<div className="admin-spinner" />
|
<Route path="login" element={<Login />} />
|
||||||
</div>
|
{import.meta.env.DEV && UiKit && (
|
||||||
}
|
<Route path="ui-kit" element={<UiKit />} />
|
||||||
>
|
)}
|
||||||
<Routes>
|
<Route element={<AppShell />}>
|
||||||
<Route path="login" element={<Login />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route element={<AdminLayout />}>
|
<Route path="users" element={<Users />} />
|
||||||
<Route index element={<Dashboard />} />
|
<Route path="attendance" element={<Attendance />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route
|
||||||
<Route path="attendance" element={<Attendance />} />
|
path="attendance/history"
|
||||||
<Route
|
element={<AttendanceHistory />}
|
||||||
path="attendance/history"
|
/>
|
||||||
element={<AttendanceHistory />}
|
<Route
|
||||||
/>
|
path="attendance/admin"
|
||||||
<Route
|
element={<AttendanceAdmin />}
|
||||||
path="attendance/admin"
|
/>
|
||||||
element={<AttendanceAdmin />}
|
<Route
|
||||||
/>
|
path="attendance/balances"
|
||||||
<Route
|
element={<AttendanceBalances />}
|
||||||
path="attendance/balances"
|
/>
|
||||||
element={<AttendanceBalances />}
|
<Route
|
||||||
/>
|
path="attendance/requests"
|
||||||
<Route
|
element={<LeaveRequests />}
|
||||||
path="attendance/requests"
|
/>
|
||||||
element={<LeaveRequests />}
|
<Route
|
||||||
/>
|
path="attendance/approval"
|
||||||
<Route
|
element={<LeaveApproval />}
|
||||||
path="attendance/approval"
|
/>
|
||||||
element={<LeaveApproval />}
|
<Route
|
||||||
/>
|
path="attendance/create"
|
||||||
<Route
|
element={<AttendanceCreate />}
|
||||||
path="attendance/create"
|
/>
|
||||||
element={<AttendanceCreate />}
|
<Route
|
||||||
/>
|
path="attendance/location/:id"
|
||||||
<Route
|
element={<AttendanceLocation />}
|
||||||
path="attendance/location/:id"
|
/>
|
||||||
element={<AttendanceLocation />}
|
<Route path="plan-work" element={<PlanWork />} />
|
||||||
/>
|
<Route path="trips" element={<Trips />} />
|
||||||
<Route path="plan-work" element={<PlanWork />} />
|
<Route path="trips/history" element={<TripsHistory />} />
|
||||||
<Route path="trips" element={<Trips />} />
|
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||||
<Route path="trips/history" element={<TripsHistory />} />
|
<Route path="vehicles" element={<Vehicles />} />
|
||||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
<Route path="offers" element={<Offers />} />
|
||||||
<Route path="vehicles" element={<Vehicles />} />
|
<Route path="offers/new" element={<OfferDetail />} />
|
||||||
<Route path="offers" element={<Offers />} />
|
<Route path="offers/:id" element={<OfferDetail />} />
|
||||||
<Route path="offers/new" element={<OfferDetail />} />
|
<Route
|
||||||
<Route path="offers/:id" element={<OfferDetail />} />
|
path="offers/customers"
|
||||||
<Route
|
element={<OffersCustomers />}
|
||||||
path="offers/customers"
|
/>
|
||||||
element={<OffersCustomers />}
|
<Route
|
||||||
/>
|
path="offers/templates"
|
||||||
<Route
|
element={<OffersTemplates />}
|
||||||
path="offers/templates"
|
/>
|
||||||
element={<OffersTemplates />}
|
<Route path="orders" element={<Orders />} />
|
||||||
/>
|
<Route path="orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="orders" element={<Orders />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
<Route path="orders/:id" element={<OrderDetail />} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="projects" element={<Projects />} />
|
<Route path="invoices" element={<Invoices />} />
|
||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||||
<Route path="invoices" element={<Invoices />} />
|
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
<Route path="audit-log" element={<AuditLog />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="warehouse" element={<Warehouse />} />
|
||||||
<Route path="audit-log" element={<AuditLog />} />
|
<Route
|
||||||
<Route path="warehouse" element={<Warehouse />} />
|
path="warehouse/items"
|
||||||
<Route path="warehouse/items" element={<WarehouseItems />} />
|
element={<WarehouseItems />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/items/:id"
|
<Route
|
||||||
element={<WarehouseItemDetail />}
|
path="warehouse/items/:id"
|
||||||
/>
|
element={<WarehouseItemDetail />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/receipts"
|
<Route
|
||||||
element={<WarehouseReceipts />}
|
path="warehouse/receipts"
|
||||||
/>
|
element={<WarehouseReceipts />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/receipts/new"
|
<Route
|
||||||
element={<WarehouseReceiptForm />}
|
path="warehouse/receipts/new"
|
||||||
/>
|
element={<WarehouseReceiptForm />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/receipts/:id"
|
<Route
|
||||||
element={<WarehouseReceiptDetail />}
|
path="warehouse/receipts/:id"
|
||||||
/>
|
element={<WarehouseReceiptDetail />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/receipts/:id/edit"
|
<Route
|
||||||
element={<WarehouseReceiptForm />}
|
path="warehouse/receipts/:id/edit"
|
||||||
/>
|
element={<WarehouseReceiptForm />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/issues"
|
<Route
|
||||||
element={<WarehouseIssues />}
|
path="warehouse/issues"
|
||||||
/>
|
element={<WarehouseIssues />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/issues/new"
|
<Route
|
||||||
element={<WarehouseIssueForm />}
|
path="warehouse/issues/new"
|
||||||
/>
|
element={<WarehouseIssueForm />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/issues/:id"
|
<Route
|
||||||
element={<WarehouseIssueDetail />}
|
path="warehouse/issues/:id"
|
||||||
/>
|
element={<WarehouseIssueDetail />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/issues/:id/edit"
|
<Route
|
||||||
element={<WarehouseIssueForm />}
|
path="warehouse/issues/:id/edit"
|
||||||
/>
|
element={<WarehouseIssueForm />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/reservations"
|
<Route
|
||||||
element={<WarehouseReservations />}
|
path="warehouse/reservations"
|
||||||
/>
|
element={<WarehouseReservations />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/inventory"
|
<Route
|
||||||
element={<WarehouseInventory />}
|
path="warehouse/inventory"
|
||||||
/>
|
element={<WarehouseInventory />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/inventory/new"
|
<Route
|
||||||
element={<WarehouseInventoryForm />}
|
path="warehouse/inventory/new"
|
||||||
/>
|
element={<WarehouseInventoryForm />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/inventory/:id"
|
<Route
|
||||||
element={<WarehouseInventoryDetail />}
|
path="warehouse/inventory/:id"
|
||||||
/>
|
element={<WarehouseInventoryDetail />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/reports"
|
<Route
|
||||||
element={<WarehouseReports />}
|
path="warehouse/reports"
|
||||||
/>
|
element={<WarehouseReports />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/suppliers"
|
<Route
|
||||||
element={<WarehouseSuppliers />}
|
path="warehouse/suppliers"
|
||||||
/>
|
element={<WarehouseSuppliers />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/locations"
|
<Route
|
||||||
element={<WarehouseLocations />}
|
path="warehouse/locations"
|
||||||
/>
|
element={<WarehouseLocations />}
|
||||||
<Route
|
/>
|
||||||
path="warehouse/categories"
|
<Route
|
||||||
element={<WarehouseCategories />}
|
path="warehouse/categories"
|
||||||
/>
|
element={<WarehouseCategories />}
|
||||||
</Route>
|
/>
|
||||||
<Route path="*" element={<NotFound />} />
|
</Route>
|
||||||
</Routes>
|
<Route path="*" element={<NotFound />} />
|
||||||
</Suspense>
|
</Routes>
|
||||||
</ErrorBoundary>
|
</Suspense>
|
||||||
</QueryClientProvider>
|
</ErrorBoundary>
|
||||||
</AlertProvider>
|
</QueryClientProvider>
|
||||||
</AuthProvider>
|
</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 Snackbar from "@mui/material/Snackbar";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import MuiAlert from "@mui/material/Alert";
|
||||||
import { useAlertState } from "../context/AlertContext";
|
import { useAlertState } from "../context/AlertContext";
|
||||||
|
|
||||||
const icons: Record<string, React.ReactNode> = {
|
|
||||||
success: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
||||||
<polyline points="22 4 12 14.01 9 11.01" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
error: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="15" y1="9" x2="9" y2="15" />
|
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
warning: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
info: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AlertContainer() {
|
export default function AlertContainer() {
|
||||||
const { alerts, removeAlert } = useAlertState();
|
const { alerts, removeAlert } = useAlertState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-alert-container" role="status" aria-live="polite">
|
<>
|
||||||
<AnimatePresence>
|
{alerts.map((alert, index) => (
|
||||||
{alerts.map((alert) => (
|
<Snackbar
|
||||||
<motion.div
|
key={alert.id}
|
||||||
key={alert.id}
|
open
|
||||||
className={`admin-toast admin-toast-${alert.type}`}
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
sx={{ bottom: { xs: 16 + index * 72 } }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
>
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
<MuiAlert
|
||||||
transition={{ duration: 0.2 }}
|
severity={alert.type}
|
||||||
|
onClose={() => removeAlert(alert.id)}
|
||||||
|
variant="filled"
|
||||||
|
sx={{ width: "100%", minWidth: 280 }}
|
||||||
>
|
>
|
||||||
<span className="admin-toast-icon">{icons[alert.type]}</span>
|
{alert.message}
|
||||||
<span className="admin-toast-message">{alert.message}</span>
|
</MuiAlert>
|
||||||
<button
|
</Snackbar>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDatetime,
|
formatDatetime,
|
||||||
@@ -6,8 +8,8 @@ import {
|
|||||||
calculateWorkMinutes,
|
calculateWorkMinutes,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
getLeaveTypeName,
|
getLeaveTypeName,
|
||||||
getLeaveTypeBadgeClass,
|
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
|
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
|
||||||
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
||||||
|
|
||||||
interface AttendanceRecord extends HookAttendanceRecord {
|
interface AttendanceRecord extends HookAttendanceRecord {
|
||||||
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
|
|||||||
onDelete: (record: AttendanceRecord) => void;
|
onDelete: (record: AttendanceRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||||
|
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||||
|
* sick→error(red), unpaid→default(gray). */
|
||||||
|
function leaveTypeChipColor(
|
||||||
|
type: string,
|
||||||
|
): "default" | "success" | "error" | "warning" | "info" {
|
||||||
|
switch (type) {
|
||||||
|
case "vacation":
|
||||||
|
return "info";
|
||||||
|
case "sick":
|
||||||
|
return "error";
|
||||||
|
case "unpaid":
|
||||||
|
return "default";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatBreak(record: AttendanceRecord): string {
|
function formatBreak(record: AttendanceRecord): string {
|
||||||
if (record.break_start && record.break_end) {
|
if (record.break_start && record.break_end) {
|
||||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||||
@@ -36,9 +88,7 @@ function formatBreak(record: AttendanceRecord): string {
|
|||||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
|
||||||
>
|
|
||||||
{record.project_logs.map((log, i) => {
|
{record.project_logs.map((log, i) => {
|
||||||
let h: number,
|
let h: number,
|
||||||
m: number,
|
m: number,
|
||||||
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
key={log.id ?? i}
|
key={log.id ?? i}
|
||||||
className="admin-badge"
|
color={isActive ? "info" : "default"}
|
||||||
style={{
|
label={`${log.project_name || `#${log.project_id}`} ${
|
||||||
fontSize: "0.7rem",
|
durationValid
|
||||||
display: "inline-block",
|
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||||
background: isActive ? "var(--accent-light)" : undefined,
|
: "—"
|
||||||
}}
|
}`}
|
||||||
>
|
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
|
||||||
{log.project_name || `#${log.project_id}`}{" "}
|
/>
|
||||||
{durationValid
|
|
||||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
|
||||||
: "—"}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (record.project_name) {
|
if (record.project_name) {
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
className="admin-badge admin-badge-wrap"
|
color="default"
|
||||||
style={{ fontSize: "0.75rem" }}
|
label={record.project_name}
|
||||||
>
|
sx={{
|
||||||
{record.project_name}
|
fontSize: "0.75rem",
|
||||||
</span>
|
height: "auto",
|
||||||
|
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return "—";
|
return "—";
|
||||||
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AttendanceShiftTableProps) {
|
}: AttendanceShiftTableProps) {
|
||||||
if (records.length === 0) {
|
const columns: DataColumn<AttendanceRecord>[] = [
|
||||||
return (
|
{
|
||||||
<div className="admin-empty-state">
|
key: "date",
|
||||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
header: "Datum",
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="admin-table-responsive">
|
<DataTable<AttendanceRecord>
|
||||||
<table className="admin-table">
|
columns={columns}
|
||||||
<thead>
|
rows={records}
|
||||||
<tr>
|
rowKey={(record) => record.id}
|
||||||
<th>Datum</th>
|
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import Typography from "@mui/material/Typography";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
|
||||||
|
|
||||||
interface BulkAttendanceForm {
|
interface BulkAttendanceForm {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -39,182 +39,137 @@ export default function BulkAttendanceModal({
|
|||||||
toggleUser,
|
toggleUser,
|
||||||
toggleAllUsers,
|
toggleAllUsers,
|
||||||
}: BulkAttendanceModalProps) {
|
}: BulkAttendanceModalProps) {
|
||||||
useModalLock(isOpen);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<motion.div
|
onClose={onClose}
|
||||||
className="admin-modal-overlay"
|
title="Vyplnit docházku za měsíc"
|
||||||
initial={{ opacity: 0 }}
|
maxWidth="lg"
|
||||||
animate={{ opacity: 1 }}
|
loading={submitting}
|
||||||
exit={{ opacity: 0 }}
|
onSubmit={onSubmit}
|
||||||
transition={{ duration: 0.2 }}
|
submitText="Vyplnit měsíc"
|
||||||
|
submitDisabled={form.user_ids.length === 0}
|
||||||
|
>
|
||||||
|
<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
|
<Typography
|
||||||
className="admin-modal-backdrop"
|
component="span"
|
||||||
onClick={() => !submitting && onClose()}
|
variant="body2"
|
||||||
/>
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
<motion.div
|
|
||||||
className="admin-modal admin-modal-lg"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="bulk-attendance-modal-title"
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
>
|
||||||
<div className="admin-modal-header">
|
Zaměstnanci
|
||||||
<h2
|
</Typography>
|
||||||
id="bulk-attendance-modal-title"
|
<Typography
|
||||||
className="admin-modal-title"
|
component="button"
|
||||||
>
|
type="button"
|
||||||
Vyplnit docházku za měsíc
|
onClick={toggleAllUsers}
|
||||||
</h2>
|
variant="caption"
|
||||||
<p
|
sx={{
|
||||||
style={{
|
background: "none",
|
||||||
color: "var(--text-secondary)",
|
border: "none",
|
||||||
marginTop: "0.25rem",
|
p: 0,
|
||||||
fontSize: "0.875rem",
|
cursor: "pointer",
|
||||||
}}
|
fontWeight: 500,
|
||||||
>
|
color: "primary.main",
|
||||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
|
}}
|
||||||
označí. Existující záznamy se přeskočí.
|
>
|
||||||
</p>
|
{form.user_ids.length === users.length
|
||||||
</div>
|
? "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">
|
<Box
|
||||||
<div className="admin-form">
|
sx={{
|
||||||
<div className="admin-form-group">
|
display: "grid",
|
||||||
<label className="admin-form-label">Měsíc</label>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<AdminDatePicker
|
gap: 2,
|
||||||
mode="month"
|
}}
|
||||||
value={form.month}
|
>
|
||||||
onChange={(val) => setForm({ ...form, month: val })}
|
<Field label="Příchod">
|
||||||
/>
|
<TimeField
|
||||||
</div>
|
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">
|
<Box
|
||||||
<label className="admin-form-label">
|
sx={{
|
||||||
Zaměstnanci
|
display: "grid",
|
||||||
<button
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
type="button"
|
gap: 2,
|
||||||
onClick={toggleAllUsers}
|
}}
|
||||||
style={{
|
>
|
||||||
marginLeft: "0.75rem",
|
<Field label="Začátek pauzy">
|
||||||
background: "none",
|
<TimeField
|
||||||
border: "none",
|
value={form.break_start_time}
|
||||||
color: "var(--accent-color)",
|
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||||
cursor: "pointer",
|
/>
|
||||||
fontSize: "0.8125rem",
|
</Field>
|
||||||
fontWeight: 500,
|
<Field label="Konec pauzy">
|
||||||
padding: 0,
|
<TimeField
|
||||||
}}
|
value={form.break_end_time}
|
||||||
>
|
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||||
{form.user_ids.length === users.length
|
/>
|
||||||
? "Odznačit vše"
|
</Field>
|
||||||
: "Vybrat vše"}
|
</Box>
|
||||||
</button>
|
</Modal>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
import { useEffect, type ReactNode } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import useReducedMotion from "../hooks/useReducedMotion";
|
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title: string;
|
|
||||||
message: ReactNode;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
type?: "danger" | "warning" | "default" | "info";
|
|
||||||
confirmVariant?: "danger" | "primary";
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfirmIcon({ type }: { type: string }) {
|
|
||||||
switch (type) {
|
|
||||||
case "danger":
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="15" y1="9" x2="9" y2="15" />
|
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
case "info":
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
case "warning":
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfirmModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
confirmText = "Potvrdit",
|
|
||||||
cancelText = "Zrušit",
|
|
||||||
type = "default",
|
|
||||||
confirmVariant,
|
|
||||||
loading,
|
|
||||||
}: ConfirmModalProps) {
|
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape" && !loading) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [isOpen, loading, onClose]);
|
|
||||||
|
|
||||||
const duration = reducedMotion ? 0 : 0.22;
|
|
||||||
const ease = [0.16, 1, 0.3, 1] as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
className="admin-modal-overlay"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
|
||||||
>
|
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
|
||||||
<motion.div
|
|
||||||
className="admin-modal admin-confirm-modal"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="confirm-modal-title"
|
|
||||||
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
||||||
transition={{ duration, ease }}
|
|
||||||
>
|
|
||||||
<div className="admin-modal-body admin-confirm-content">
|
|
||||||
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
|
||||||
<ConfirmIcon type={type} />
|
|
||||||
</div>
|
|
||||||
<h2 id="confirm-modal-title" className="admin-confirm-title">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p className="admin-confirm-message">{message}</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-modal-footer">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{cancelText}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "Zpracování..." : confirmText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -22,19 +25,25 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
className="admin-empty-state"
|
sx={{
|
||||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
minHeight: "60vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h2>Něco se pokazilo</h2>
|
<Typography variant="h5">Něco se pokazilo</Typography>
|
||||||
<p>{this.state.error?.message}</p>
|
<Typography variant="body2" color="text.secondary">
|
||||||
<button
|
{this.state.error?.message}
|
||||||
className="admin-btn admin-btn-primary"
|
</Typography>
|
||||||
onClick={() => window.location.reload()}
|
<Button onClick={() => window.location.reload()}>
|
||||||
>
|
|
||||||
Obnovit stránku
|
Obnovit stránku
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { EmptyState, Button } from "../ui";
|
||||||
|
|
||||||
export default function Forbidden() {
|
export default function Forbidden() {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
className="admin-empty-state"
|
sx={{
|
||||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
minHeight: "60vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h2>403</h2>
|
<EmptyState
|
||||||
<p>Nemáte oprávnění pro přístup k této stránce.</p>
|
title="403"
|
||||||
<Link to="/" className="admin-btn admin-btn-primary">
|
description="Nemáte oprávnění pro přístup k této stránce."
|
||||||
Zpět na Dashboard
|
action={
|
||||||
</Link>
|
<Button component={Link} to="/">
|
||||||
</div>
|
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 { useState, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { Modal, Button, TextField, Field } from "../ui";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
|
|
||||||
interface ConfirmationItem {
|
interface ConfirmationItem {
|
||||||
@@ -35,6 +40,8 @@ export default function OrderConfirmationModal({
|
|||||||
applyVat,
|
applyVat,
|
||||||
}: OrderConfirmationModalProps) {
|
}: OrderConfirmationModalProps) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||||
const [lang, setLang] = useState<string>("cs");
|
const [lang, setLang] = useState<string>("cs");
|
||||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
const [applyVatState, setApplyVatState] = useState(applyVat);
|
||||||
@@ -103,294 +110,320 @@ export default function OrderConfirmationModal({
|
|||||||
}, [defaultVatRate]);
|
}, [defaultVatRate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<motion.div
|
onClose={onClose}
|
||||||
className="admin-modal-overlay"
|
title={`Potvrzení objednávky ${orderNumber}`}
|
||||||
initial={{ opacity: 0 }}
|
maxWidth={step === "edit" ? "lg" : "md"}
|
||||||
animate={{ opacity: 1 }}
|
loading={loading}
|
||||||
exit={{ opacity: 0 }}
|
submitText={
|
||||||
transition={{ duration: 0.2 }}
|
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
|
||||||
>
|
}
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
submitDisabled={step === "edit" && items.length === 0}
|
||||||
<motion.div
|
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
|
||||||
className={
|
>
|
||||||
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
|
{step === "choose" ? (
|
||||||
}
|
<Box>
|
||||||
role="dialog"
|
<Field label="Jazyk dokumentu">
|
||||||
aria-modal="true"
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
aria-labelledby="order-confirmation-modal-title"
|
<Button
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
size="small"
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
variant={lang === "cs" ? "contained" : "outlined"}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
color={lang === "cs" ? "primary" : "inherit"}
|
||||||
transition={{ duration: 0.2 }}
|
onClick={() => setLang("cs")}
|
||||||
>
|
|
||||||
<div className="admin-modal-header">
|
|
||||||
<h2
|
|
||||||
id="order-confirmation-modal-title"
|
|
||||||
className="admin-modal-title"
|
|
||||||
>
|
>
|
||||||
Potvrzení objednávky {orderNumber}
|
Čeština
|
||||||
</h2>
|
</Button>
|
||||||
</div>
|
<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">
|
<Field label="DPH">
|
||||||
{step === "choose" ? (
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
<div className="admin-form">
|
<Button
|
||||||
<div className="admin-form-group">
|
size="small"
|
||||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
variant={applyVatState ? "contained" : "outlined"}
|
||||||
<div className="flex-row gap-2">
|
color={applyVatState ? "primary" : "inherit"}
|
||||||
<button
|
onClick={() => setApplyVatState(true)}
|
||||||
type="button"
|
>
|
||||||
onClick={() => setLang("cs")}
|
S DPH
|
||||||
className={
|
</Button>
|
||||||
lang === "cs"
|
<Button
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
variant={!applyVatState ? "contained" : "outlined"}
|
||||||
}
|
color={!applyVatState ? "primary" : "inherit"}
|
||||||
>
|
onClick={() => setApplyVatState(false)}
|
||||||
Čeština
|
>
|
||||||
</button>
|
Bez DPH
|
||||||
<button
|
</Button>
|
||||||
type="button"
|
</Box>
|
||||||
onClick={() => setLang("en")}
|
</Field>
|
||||||
className={
|
|
||||||
lang === "en"
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Obsah potvrzení">
|
||||||
<label className="admin-form-label">DPH</label>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
<div className="flex-row gap-2">
|
Jak chcete připravit potvrzení objednávky?
|
||||||
<button
|
</Typography>
|
||||||
type="button"
|
<Button
|
||||||
onClick={() => setApplyVatState(true)}
|
fullWidth
|
||||||
className={
|
onClick={() => {
|
||||||
applyVatState
|
setItems(initialItems.length > 0 ? initialItems : []);
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
setStep("edit");
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
}}
|
||||||
}
|
disabled={loading}
|
||||||
>
|
variant="outlined"
|
||||||
S DPH
|
color="inherit"
|
||||||
</button>
|
>
|
||||||
<button
|
Upravit položky
|
||||||
type="button"
|
</Button>
|
||||||
onClick={() => setApplyVatState(false)}
|
</Field>
|
||||||
className={
|
</Box>
|
||||||
!applyVatState
|
) : (
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
<Box>
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
<Box sx={{ mb: 1.5 }}>
|
||||||
}
|
<Button
|
||||||
>
|
size="small"
|
||||||
Bez DPH
|
variant="outlined"
|
||||||
</button>
|
color="inherit"
|
||||||
</div>
|
onClick={() => setStep("choose")}
|
||||||
</div>
|
disabled={loading}
|
||||||
|
>
|
||||||
<div className="admin-form-group">
|
Zpět
|
||||||
<label className="admin-form-label">Obsah potvrzení</label>
|
</Button>
|
||||||
<p
|
</Box>
|
||||||
className="text-secondary"
|
{isMobile ? (
|
||||||
style={{ marginBottom: "0.75rem" }}
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
>
|
{items.map((item, i) => (
|
||||||
Jak chcete připravit potvrzení objednávky?
|
<Box
|
||||||
</p>
|
key={i}
|
||||||
<button
|
sx={{
|
||||||
onClick={handleUseExisting}
|
border: 1,
|
||||||
disabled={loading}
|
borderColor: "divider",
|
||||||
className="admin-btn admin-btn-primary w-full"
|
borderRadius: 2,
|
||||||
style={{ marginBottom: "0.5rem" }}
|
p: 1.5,
|
||||||
>
|
display: "flex",
|
||||||
{loading ? (
|
flexDirection: "column",
|
||||||
<>
|
gap: 1,
|
||||||
<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}
|
|
||||||
>
|
>
|
||||||
Zrušit
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
</button>
|
<Typography
|
||||||
)}
|
variant="caption"
|
||||||
</div>
|
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||||
</motion.div>
|
>
|
||||||
</motion.div>
|
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 { useState, type ChangeEvent, type FocusEvent } from "react";
|
||||||
import FormModal from "./FormModal";
|
import Box from "@mui/material/Box";
|
||||||
|
import { Modal, TextField, Button } from "../ui";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { PlanCategory } from "../lib/queries/plan";
|
import { PlanCategory } from "../lib/queries/plan";
|
||||||
@@ -91,23 +92,44 @@ export default function PlanCategoriesModal({
|
|||||||
const busy =
|
const busy =
|
||||||
createCat.isPending || updateCat.isPending || deleteCat.isPending;
|
createCat.isPending || updateCat.isPending || deleteCat.isPending;
|
||||||
|
|
||||||
|
// Shared layout for a category row: color swatch | text input | actions.
|
||||||
|
const rowSx = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
py: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Native color picker, styled to match the row (was the .plan-cat-color rule).
|
||||||
|
const colorInputSx = {
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
p: 0,
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: "6px",
|
||||||
|
background: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
} as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onSubmit={onClose}
|
||||||
title="Správa kategorií"
|
title="Správa kategorií"
|
||||||
hideFooter
|
submitText="Zavřít"
|
||||||
size="md"
|
hideCancel
|
||||||
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<div className="plan-cat-manager">
|
<Box>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<div
|
<Box key={c.id} sx={{ ...rowSx, opacity: c.is_active ? 1 : 0.55 }}>
|
||||||
key={c.id}
|
<Box
|
||||||
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
|
component="input"
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
type="color"
|
||||||
className="plan-cat-color"
|
sx={colorInputSx}
|
||||||
// Uncontrolled + patch on blur (like the rename input): the
|
// Uncontrolled + patch on blur (like the rename input): the
|
||||||
// native color picker fires onChange on every drag tick, which
|
// native color picker fires onChange on every drag tick, which
|
||||||
// would spam PATCH requests and the audit log. onBlur commits
|
// would spam PATCH requests and the audit log. onBlur commits
|
||||||
@@ -115,96 +137,102 @@ export default function PlanCategoriesModal({
|
|||||||
defaultValue={c.color}
|
defaultValue={c.color}
|
||||||
key={c.color}
|
key={c.color}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onBlur={(e) => {
|
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||||
if (e.target.value !== c.color) {
|
if (e.target.value !== c.color) {
|
||||||
patch(c.id, { color: e.target.value });
|
patch(c.id, { color: e.target.value });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={`Barva – ${c.label}`}
|
aria-label={`Barva – ${c.label}`}
|
||||||
/>
|
/>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
className="admin-form-input"
|
|
||||||
defaultValue={c.label}
|
defaultValue={c.label}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const v = e.target.value.trim();
|
const v = e.target.value.trim();
|
||||||
if (v && v !== c.label) patch(c.id, { label: v });
|
if (v && v !== c.label) patch(c.id, { label: v });
|
||||||
}}
|
}}
|
||||||
aria-label={`Název – ${c.label}`}
|
inputProps={{ "aria-label": `Název – ${c.label}` }}
|
||||||
/>
|
/>
|
||||||
{confirmDeleteId === c.id ? (
|
{confirmDeleteId === c.id ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="contained"
|
||||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
color="error"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => handleDelete(c.id)}
|
onClick={() => handleDelete(c.id)}
|
||||||
>
|
>
|
||||||
Opravdu smazat
|
Opravdu smazat
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
color="inherit"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setConfirmDeleteId(null)}
|
onClick={() => setConfirmDeleteId(null)}
|
||||||
>
|
>
|
||||||
Zrušit
|
Zrušit
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
color="inherit"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
||||||
>
|
>
|
||||||
{c.is_active ? "Skrýt" : "Obnovit"}
|
{c.is_active ? "Skrýt" : "Obnovit"}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="contained"
|
||||||
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
|
color="error"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setConfirmDeleteId(c.id)}
|
onClick={() => setConfirmDeleteId(c.id)}
|
||||||
aria-label={`Smazat – ${c.label}`}
|
aria-label={`Smazat – ${c.label}`}
|
||||||
title="Smazat kategorii"
|
title="Smazat kategorii"
|
||||||
>
|
>
|
||||||
Smazat
|
Smazat
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="plan-cat-row plan-cat-row--new">
|
<Box
|
||||||
<input
|
sx={{ ...rowSx, mt: 1, pt: 2, borderTop: 1, borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="input"
|
||||||
type="color"
|
type="color"
|
||||||
className="plan-cat-color"
|
sx={colorInputSx}
|
||||||
value={newColor}
|
value={newColor}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setNewColor(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setNewColor(e.target.value)
|
||||||
|
}
|
||||||
aria-label="Barva nové kategorie"
|
aria-label="Barva nové kategorie"
|
||||||
/>
|
/>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Nová kategorie…"
|
placeholder="Nová kategorie…"
|
||||||
value={newLabel}
|
value={newLabel}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setNewLabel(e.target.value)}
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="contained"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
color="primary"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
>
|
>
|
||||||
Přidat
|
Přidat
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</FormModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import FormModal from "./FormModal";
|
import Box from "@mui/material/Box";
|
||||||
import FormField from "./FormField";
|
import Typography from "@mui/material/Typography";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import {
|
||||||
import ConfirmModal from "./ConfirmModal";
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
CheckboxField,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
} from "../ui";
|
||||||
import useReducedMotion from "../hooks/useReducedMotion";
|
import useReducedMotion from "../hooks/useReducedMotion";
|
||||||
import {
|
import {
|
||||||
ResolvedCell,
|
ResolvedCell,
|
||||||
@@ -77,6 +86,26 @@ interface Props {
|
|||||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TrashIcon = (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6" />
|
||||||
|
<path d="M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function PlanCellModal(props: Props) {
|
export default function PlanCellModal(props: Props) {
|
||||||
const { open, mode, onClose } = props;
|
const { open, mode, onClose } = props;
|
||||||
if (!open || mode.kind === "closed") return null;
|
if (!open || mode.kind === "closed") return null;
|
||||||
@@ -213,128 +242,111 @@ function EditForm(props: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If the row's current category was retired/hidden, still show it (marked)
|
||||||
|
// so the select reflects reality instead of silently snapping to the first
|
||||||
|
// active option. The server only re-checks the category if it changes, so
|
||||||
|
// keeping it saves fine.
|
||||||
|
const showRetiredCategory =
|
||||||
|
category && !activeCategories.some((c) => c.key === category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
title={title}
|
title={title}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
footerLeft={
|
>
|
||||||
mode.kind !== "create" ? (
|
{mode.kind !== "create" && (
|
||||||
<button
|
<Box sx={{ mb: 2 }}>
|
||||||
type="button"
|
<Button
|
||||||
className="admin-btn admin-btn-secondary"
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={TrashIcon}
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6" />
|
|
||||||
<path d="M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
Smazat
|
Smazat
|
||||||
</button>
|
</Button>
|
||||||
) : null
|
</Box>
|
||||||
}
|
)}
|
||||||
>
|
<Field label="Datum od">
|
||||||
<FormField label="Datum od">
|
<DateField
|
||||||
<AdminDatePicker
|
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
onChange={setDateFrom}
|
onChange={setDateFrom}
|
||||||
disabled={isOverride}
|
disabled={isOverride}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
{!isOverride && (
|
{!isOverride && (
|
||||||
<FormField label="Rozsah dnů">
|
<Field label="Rozsah dnů">
|
||||||
<label className="admin-form-checkbox">
|
<CheckboxField
|
||||||
<input
|
label="Více dní"
|
||||||
type="checkbox"
|
checked={isRange}
|
||||||
checked={isRange}
|
onChange={setIsRange}
|
||||||
onChange={(e) => setIsRange(e.target.checked)}
|
/>
|
||||||
/>
|
</Field>
|
||||||
<span>Více dní</span>
|
|
||||||
</label>
|
|
||||||
</FormField>
|
|
||||||
)}
|
)}
|
||||||
{!isOverride && isRange && (
|
{!isOverride && isRange && (
|
||||||
<FormField label="Datum do">
|
<Field label="Datum do">
|
||||||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
<DateField value={dateTo} onChange={setDateTo} />
|
||||||
</FormField>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<FormField label="Projekt">
|
<Field label="Projekt">
|
||||||
<select
|
<Select
|
||||||
className="admin-form-select"
|
value={projectId === null ? "" : String(projectId)}
|
||||||
value={projectId ?? ""}
|
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||||
onChange={(e) =>
|
options={[
|
||||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
{ value: "", label: "— bez projektu —" },
|
||||||
}
|
...projects.map((p) => ({
|
||||||
>
|
value: String(p.id),
|
||||||
<option value="">— bez projektu —</option>
|
label: p.name,
|
||||||
{projects.map((p) => (
|
})),
|
||||||
<option key={p.id} value={p.id}>
|
]}
|
||||||
{p.name}
|
/>
|
||||||
</option>
|
</Field>
|
||||||
))}
|
<Field label="Kategorie">
|
||||||
</select>
|
<Select
|
||||||
</FormField>
|
|
||||||
<FormField label="Kategorie">
|
|
||||||
<select
|
|
||||||
className="admin-form-select"
|
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(val) => setCategory(val)}
|
||||||
>
|
options={[
|
||||||
{/* If the row's current category was retired/hidden, still show it
|
...(showRetiredCategory
|
||||||
(marked) so the select reflects reality instead of silently
|
? [
|
||||||
snapping to the first active option. The server only re-checks
|
{
|
||||||
the category if it changes, so keeping it saves fine. */}
|
value: category,
|
||||||
{category && !activeCategories.some((c) => c.key === category) && (
|
label: `${planCategoryLabel(
|
||||||
<option value={category}>
|
category,
|
||||||
{planCategoryLabel(
|
Object.fromEntries(
|
||||||
category,
|
props.categories.map((x) => [x.key, x]),
|
||||||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
),
|
||||||
)}{" "}
|
)} (skrytá)`,
|
||||||
(skrytá)
|
},
|
||||||
</option>
|
]
|
||||||
)}
|
: []),
|
||||||
{activeCategories.map((c) => (
|
...activeCategories.map((c) => ({
|
||||||
<option key={c.key} value={c.key}>
|
value: c.key,
|
||||||
{c.label}
|
label: c.label,
|
||||||
</option>
|
})),
|
||||||
))}
|
]}
|
||||||
</select>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Text poznámky (volitelný)">
|
<Field label="Text poznámky (volitelný)">
|
||||||
<textarea
|
<TextField
|
||||||
className="admin-form-textarea"
|
multiline
|
||||||
|
minRows={3}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
maxLength={500}
|
inputProps={{ maxLength: 500 }}
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</FormModal>
|
</Modal>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={confirmDelete}
|
isOpen={confirmDelete}
|
||||||
title="Smazat záznam plánu?"
|
title="Smazat záznam plánu?"
|
||||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
type="danger"
|
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
onClose={() => setConfirmDelete(false)}
|
onClose={() => setConfirmDelete(false)}
|
||||||
@@ -376,26 +388,26 @@ function DayInRangeModal(
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
title="Den je součástí rozsahu"
|
title="Den je součástí rozsahu"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
hideFooter
|
onSubmit={onClose}
|
||||||
size="md"
|
submitText="Zavřít — ponechat rozsah beze změny"
|
||||||
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<p className="plan-modal-intro">
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{mode.range.date_from} – {mode.range.date_to}
|
{mode.range.date_from} – {mode.range.date_to}
|
||||||
</strong>
|
</strong>
|
||||||
. Vyberte, co chcete udělat:
|
. Vyberte, co chcete udělat:
|
||||||
</p>
|
</Typography>
|
||||||
<ul className="plan-choices">
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{choices.map((c, i) => (
|
{choices.map((c, i) => (
|
||||||
<motion.li
|
<motion.div
|
||||||
key={c.key}
|
key={c.key}
|
||||||
className={`plan-choice plan-choice-${c.tone}`}
|
|
||||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||||
@@ -405,35 +417,42 @@ function DayInRangeModal(
|
|||||||
ease: [0.16, 1, 0.3, 1],
|
ease: [0.16, 1, 0.3, 1],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="plan-choice-body">
|
<Card
|
||||||
<div className="plan-choice-title">{c.title}</div>
|
variant="outlined"
|
||||||
<div className="plan-choice-desc">{c.desc}</div>
|
sx={{
|
||||||
</div>
|
borderColor:
|
||||||
<button
|
c.tone === "primary" ? "primary.main" : "divider",
|
||||||
type="button"
|
}}
|
||||||
className={
|
|
||||||
c.tone === "primary"
|
|
||||||
? "admin-btn admin-btn-primary"
|
|
||||||
: "admin-btn admin-btn-secondary"
|
|
||||||
}
|
|
||||||
onClick={c.onClick}
|
|
||||||
>
|
>
|
||||||
{c.cta}
|
<Box
|
||||||
</button>
|
sx={{
|
||||||
</motion.li>
|
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>
|
</AnimatePresence>
|
||||||
</ul>
|
</Box>
|
||||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
</Modal>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Zavřít — ponechat rozsah beze změny
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,29 +473,35 @@ function ViewModal(props: Props) {
|
|||||||
: fallback.name
|
: fallback.name
|
||||||
: null);
|
: null);
|
||||||
return (
|
return (
|
||||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
<Modal
|
||||||
<p>
|
isOpen
|
||||||
|
title="Detail plánu"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onClose}
|
||||||
|
submitText="Zavřít"
|
||||||
|
>
|
||||||
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||||
</p>
|
</Typography>
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Kategorie:</strong>{" "}
|
<strong>Kategorie:</strong>{" "}
|
||||||
{planCategoryLabel(
|
{planCategoryLabel(
|
||||||
c.category,
|
c.category,
|
||||||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||||||
)}
|
)}
|
||||||
</p>
|
</Typography>
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||||
</p>
|
</Typography>
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Text:</strong> {c.note || "—"}
|
<strong>Text:</strong> {c.note || "—"}
|
||||||
</p>
|
</Typography>
|
||||||
{c.rangeFrom && c.rangeTo && (
|
{c.rangeFrom && c.rangeTo && (
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||||
{formatDate(c.rangeTo)}
|
{formatDate(c.rangeTo)}
|
||||||
</p>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</FormModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import {
|
import {
|
||||||
GridData,
|
GridData,
|
||||||
ResolvedCell,
|
ResolvedCell,
|
||||||
@@ -9,6 +11,357 @@ import {
|
|||||||
} from "../lib/queries/plan";
|
} from "../lib/queries/plan";
|
||||||
import type { Project } from "../lib/queries/projects";
|
import type { Project } from "../lib/queries/projects";
|
||||||
import PlanRangeChips from "./PlanRangeChips";
|
import PlanRangeChips from "./PlanRangeChips";
|
||||||
|
import { LoadingState } from "../ui";
|
||||||
|
import { fonts } from "../theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The planner surface, fully MUI/theme-driven (replaces the bespoke plan.css
|
||||||
|
* grid styling). All colors come from the theme palette via `theme.vars`, so it
|
||||||
|
* adapts to light/dark automatically; category colors come from the DB and are
|
||||||
|
* injected per cell as the `--cat-color` CSS variable. Class names from the JSX
|
||||||
|
* below are styled here as scoped nested selectors.
|
||||||
|
*/
|
||||||
|
const PlanGridRoot = styled(Box)(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
overflow: "auto",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
overscrollBehavior: "contain",
|
||||||
|
background: theme.vars!.palette.background.default,
|
||||||
|
border: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
boxShadow: theme.shadows[2],
|
||||||
|
maxHeight: "calc(100dvh - 240px)",
|
||||||
|
isolation: "isolate",
|
||||||
|
|
||||||
|
"& .plan-grid": {
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
borderCollapse: "separate",
|
||||||
|
borderSpacing: 0,
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "12.5px",
|
||||||
|
color: theme.vars!.palette.text.primary,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
"& col.plan-grid-date-col": { width: 88 },
|
||||||
|
"& tbody tr td + td": {
|
||||||
|
borderLeft: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sticky header
|
||||||
|
"& thead th": {
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 3,
|
||||||
|
padding: "14px 12px 12px",
|
||||||
|
background: theme.vars!.palette.background.paper,
|
||||||
|
color: theme.vars!.palette.text.primary,
|
||||||
|
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
|
textAlign: "left",
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
"& thead th.plan-grid-date-col": {
|
||||||
|
left: 0,
|
||||||
|
zIndex: 4,
|
||||||
|
minWidth: 88,
|
||||||
|
padding: "12px 10px 10px 14px",
|
||||||
|
fontSize: "10.5px",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: theme.vars!.palette.text.secondary,
|
||||||
|
},
|
||||||
|
"& .plan-person-head": {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 9,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
"& .plan-person-dot": {
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: theme.vars!.palette.action.disabled,
|
||||||
|
},
|
||||||
|
"& .plan-person-name": {
|
||||||
|
display: "inline-flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minWidth: 0,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
},
|
||||||
|
"& .plan-person-name strong": {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.vars!.palette.text.primary,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
maxWidth: 180,
|
||||||
|
},
|
||||||
|
"& .plan-person-name small": {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme.vars!.palette.text.disabled,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sticky date column
|
||||||
|
"& tbody td.plan-grid-date-col": {
|
||||||
|
position: "sticky",
|
||||||
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
background: theme.vars!.palette.background.paper,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme.vars!.palette.text.secondary,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minWidth: 88,
|
||||||
|
padding: "9px 10px 9px 14px",
|
||||||
|
borderRight: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
|
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
|
},
|
||||||
|
"& .plan-date-stamp": {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
gap: 1,
|
||||||
|
},
|
||||||
|
"& .plan-date-daynum": {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme.vars!.palette.text.primary,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
lineHeight: 1.05,
|
||||||
|
},
|
||||||
|
"& .plan-date-dow": {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.14em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: theme.vars!.palette.text.disabled,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Today
|
||||||
|
"& tbody tr.is-today td.plan-grid-date-col": {
|
||||||
|
background: theme.vars!.palette.background.paper,
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 6,
|
||||||
|
bottom: 6,
|
||||||
|
width: 3,
|
||||||
|
borderRadius: "0 2px 2px 0",
|
||||||
|
background: theme.vars!.palette.primary.main,
|
||||||
|
boxShadow: `0 0 14px rgba(${theme.vars!.palette.primary.mainChannel} / 0.3)`,
|
||||||
|
},
|
||||||
|
"& .plan-date-daynum": { color: theme.vars!.palette.primary.main },
|
||||||
|
},
|
||||||
|
"& tbody tr.is-today td:not(.plan-grid-date-col)": {
|
||||||
|
background: `rgba(${theme.vars!.palette.primary.mainChannel} / 0.06)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Body cells
|
||||||
|
"& tbody td": {
|
||||||
|
padding: 0,
|
||||||
|
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
|
verticalAlign: "stretch",
|
||||||
|
minWidth: 168,
|
||||||
|
background: theme.vars!.palette.background.paper,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Weekend
|
||||||
|
"& tr.plan-grid-weekend td": {
|
||||||
|
background: `rgba(${theme.vars!.palette.text.primaryChannel} / 0.03)`,
|
||||||
|
},
|
||||||
|
"& tr.plan-grid-weekend td.plan-grid-date-col": {
|
||||||
|
background: theme.vars!.palette.background.paper,
|
||||||
|
color: theme.vars!.palette.text.disabled,
|
||||||
|
"& .plan-date-dow": { color: theme.vars!.palette.warning.main },
|
||||||
|
"& .plan-date-daynum": { color: theme.vars!.palette.text.secondary },
|
||||||
|
},
|
||||||
|
|
||||||
|
// The cell button + marker tape
|
||||||
|
"& .plan-cell": {
|
||||||
|
position: "relative",
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 60,
|
||||||
|
textAlign: "left",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
borderRadius: 0,
|
||||||
|
padding: "9px 11px 10px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit",
|
||||||
|
transition: "background 150ms ease, transform 150ms ease",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
left: 4,
|
||||||
|
top: 8,
|
||||||
|
bottom: 8,
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: `var(--cat-color, ${theme.vars!.palette.divider})`,
|
||||||
|
transition:
|
||||||
|
"background 150ms ease, box-shadow 150ms ease, transform 150ms ease",
|
||||||
|
},
|
||||||
|
"&:hover": { background: theme.vars!.palette.action.hover },
|
||||||
|
"&:hover::before": {
|
||||||
|
transform: "scaleX(1.4)",
|
||||||
|
boxShadow: "0 0 12px currentColor",
|
||||||
|
},
|
||||||
|
"&:active": { transform: "translateY(0.5px)" },
|
||||||
|
"&:focus-visible": {
|
||||||
|
outline: "none",
|
||||||
|
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, inset 0 0 0 4px ${theme.vars!.palette.background.paper}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .plan-cell--readonly": {
|
||||||
|
cursor: "default",
|
||||||
|
"&:hover": { background: "transparent" },
|
||||||
|
"&:hover::before": { transform: "none", boxShadow: "none" },
|
||||||
|
},
|
||||||
|
// A read-only cell that HAS data is still clickable (view mode).
|
||||||
|
"& .plan-cell--readonly:not(.plan-cell--empty):not(.plan-cell--past)": {
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": { background: theme.vars!.palette.action.hover },
|
||||||
|
"&:hover::before": {
|
||||||
|
transform: "scaleX(1.4)",
|
||||||
|
boxShadow: "0 0 12px currentColor",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .plan-cell--past": {
|
||||||
|
cursor: "default",
|
||||||
|
opacity: 0.55,
|
||||||
|
"&:hover": { background: "transparent" },
|
||||||
|
"&:hover::before": { transform: "none", boxShadow: "none" },
|
||||||
|
},
|
||||||
|
// Empty cells: dashed tape + a "+" hover hint.
|
||||||
|
"& .plan-cell--empty::before": {
|
||||||
|
background: "transparent",
|
||||||
|
border: `1px dashed ${theme.vars!.palette.divider}`,
|
||||||
|
width: 2,
|
||||||
|
left: 5,
|
||||||
|
},
|
||||||
|
"& .plan-cell--empty:hover::before": {
|
||||||
|
background: theme.vars!.palette.primary.main,
|
||||||
|
borderColor: theme.vars!.palette.primary.main,
|
||||||
|
transform: "scaleX(1.6)",
|
||||||
|
},
|
||||||
|
"& .plan-cell--empty::after": {
|
||||||
|
content: '"+"',
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
color: theme.vars!.palette.text.disabled,
|
||||||
|
opacity: 0,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 300,
|
||||||
|
pointerEvents: "none",
|
||||||
|
transition: "opacity 150ms ease",
|
||||||
|
},
|
||||||
|
"& .plan-cell--empty:hover::after": {
|
||||||
|
opacity: 0.7,
|
||||||
|
color: theme.vars!.palette.primary.main,
|
||||||
|
},
|
||||||
|
"& .plan-cell--empty.plan-cell--readonly::after": { content: "none" },
|
||||||
|
|
||||||
|
// One-shot mutation pulse
|
||||||
|
"@keyframes plan-cell-pulse": {
|
||||||
|
"0%": {
|
||||||
|
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 0 rgba(${theme.vars!.palette.primary.mainChannel} / 0.4)`,
|
||||||
|
},
|
||||||
|
"60%": {
|
||||||
|
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 6px transparent`,
|
||||||
|
},
|
||||||
|
"100%": { boxShadow: "inset 0 0 0 2px transparent, 0 0 0 0 transparent" },
|
||||||
|
},
|
||||||
|
"& .plan-cell--pulse": { animation: "plan-cell-pulse 600ms ease-out" },
|
||||||
|
|
||||||
|
// Chips inside a cell
|
||||||
|
"& .plan-chip-block": {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
textAlign: "left",
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
"& .plan-chip-line": {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
"& .plan-chip": {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "2px 7px",
|
||||||
|
fontSize: "10.5px",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: `var(--cat-color, ${theme.vars!.palette.text.primary})`,
|
||||||
|
background: `color-mix(in srgb, var(--cat-color, transparent) 12%, ${theme.vars!.palette.background.paper})`,
|
||||||
|
border: `1px solid color-mix(in srgb, var(--cat-color, ${theme.vars!.palette.divider}) 35%, transparent)`,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
"& .plan-chip-project": {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: "12.5px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme.vars!.palette.text.primary,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
"& .plan-cell-note": {
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginTop: 1,
|
||||||
|
fontSize: "11.5px",
|
||||||
|
color: theme.vars!.palette.text.secondary,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
|
||||||
|
"@media (max-width: 480px)": {
|
||||||
|
"& col.plan-grid-date-col": { width: 72 },
|
||||||
|
"& tbody td": { minWidth: 132 },
|
||||||
|
"& .plan-cell": { minHeight: 56 },
|
||||||
|
},
|
||||||
|
// Touch: show a faint persistent "+" on empty editable cells.
|
||||||
|
"@media (hover: none)": {
|
||||||
|
"& .plan-cell--empty:not(.plan-cell--readonly)::after": {
|
||||||
|
opacity: 0.26,
|
||||||
|
color: theme.vars!.palette.text.disabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"@media (prefers-reduced-motion: reduce)": {
|
||||||
|
"& .plan-cell, & .plan-cell::before, & .plan-cell::after": {
|
||||||
|
transition: "none",
|
||||||
|
},
|
||||||
|
"& .plan-cell--pulse": { animation: "none" },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: GridData | undefined;
|
data: GridData | undefined;
|
||||||
@@ -107,12 +460,7 @@ export default function PlanGrid({
|
|||||||
const today = useMemo(() => todayIso(), []);
|
const today = useMemo(() => todayIso(), []);
|
||||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||||
|
|
||||||
if (!data)
|
if (!data) return <LoadingState />;
|
||||||
return (
|
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const days = eachDay(data.date_from, data.date_to);
|
const days = eachDay(data.date_from, data.date_to);
|
||||||
const users = data.users;
|
const users = data.users;
|
||||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||||
@@ -124,7 +472,7 @@ export default function PlanGrid({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
|
<PlanGridRoot data-pulse={pulseAttr}>
|
||||||
<table className="plan-grid">
|
<table className="plan-grid">
|
||||||
{/* The colgroup is what actually controls column width in a
|
{/* The colgroup is what actually controls column width in a
|
||||||
table — `min-width` on `<th>`/`<td>` is just a floor that
|
table — `min-width` on `<th>`/`<td>` is just a floor that
|
||||||
@@ -255,6 +603,6 @@ export default function PlanGrid({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</PlanGridRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { projectFilesOptions } from "../lib/queries/projects";
|
import { projectFilesOptions } from "../lib/queries/projects";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import ConfirmModal from "./ConfirmModal";
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
ConfirmDialog,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
DataTable,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
@@ -122,7 +135,7 @@ function getFileIcon(type: string, extension?: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cfg = iconMap[ext] || {
|
const cfg = iconMap[ext] || {
|
||||||
color: "var(--text-muted)",
|
color: "var(--mui-palette-text-secondary)",
|
||||||
path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z",
|
path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +156,16 @@ function getFileIcon(type: string, extension?: string) {
|
|||||||
|
|
||||||
function SymlinkBadge({ target }: { target?: string }) {
|
function SymlinkBadge({ target }: { target?: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="fm-symlink-badge" title={target || "Odkaz"}>
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={target || "Odkaz"}
|
||||||
|
sx={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
ml: 0.5,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
@@ -155,7 +177,7 @@ function SymlinkBadge({ target }: { target?: string }) {
|
|||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,26 +190,53 @@ function FileNameCell({
|
|||||||
}) {
|
}) {
|
||||||
if (item.type === "folder") {
|
if (item.type === "folder") {
|
||||||
return (
|
return (
|
||||||
<span className="fm-name-cell">
|
<Box
|
||||||
<button
|
component="span"
|
||||||
|
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
className="fm-folder-link"
|
|
||||||
onClick={() => onFolderClick(item.name)}
|
onClick={() => onFolderClick(item.name)}
|
||||||
|
sx={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
p: 0,
|
||||||
|
font: "inherit",
|
||||||
|
color: "primary.main",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
"&:hover": { textDecoration: "underline" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</button>
|
</Box>
|
||||||
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||||
{item.item_count !== undefined && (
|
{item.item_count !== undefined && (
|
||||||
<span className="fm-item-count">{item.item_count}</span>
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: ".7rem",
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.item_count}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</span>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="fm-name-cell">
|
<Box
|
||||||
<span className="fm-file-name">{item.name}</span>
|
component="span"
|
||||||
|
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||||
|
>
|
||||||
|
<Box component="span">{item.name}</Box>
|
||||||
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||||
</span>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,76 +518,271 @@ export default function ProjectFileManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
if (filesLoading && items.length === 0 && !errorMessage) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-card">
|
<Card>
|
||||||
<div className="admin-card-body">
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
<h3 className="admin-card-title">Soubory</h3>
|
Soubory
|
||||||
<div className="fm-empty">
|
</Typography>
|
||||||
|
<EmptyState
|
||||||
|
icon={
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--text-tertiary)"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{errorMessage}</span>
|
}
|
||||||
</div>
|
title={errorMessage}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<FileItem>[] = [
|
||||||
<div className="admin-card">
|
{
|
||||||
<div className="admin-card-body">
|
key: "icon",
|
||||||
<h3 className="admin-card-title">Soubory</h3>
|
header: "",
|
||||||
|
width: 30,
|
||||||
{/* Toolbar */}
|
align: "left",
|
||||||
<div className="fm-toolbar">
|
render: (item) => (
|
||||||
<div className="fm-breadcrumb">
|
<Box sx={{ textAlign: "center" }}>
|
||||||
{breadcrumb.map((segment, i) => (
|
{getFileIcon(item.type, item.extension)}
|
||||||
<span key={i} className="fm-breadcrumb-segment">
|
</Box>
|
||||||
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
|
),
|
||||||
<button
|
},
|
||||||
type="button"
|
{
|
||||||
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? "active" : ""}`}
|
key: "name",
|
||||||
onClick={() => handleBreadcrumbClick(i)}
|
header: "Název",
|
||||||
>
|
render: (item) =>
|
||||||
{i === 0 ? projectNumber : segment}
|
renamingItem === item.name ? (
|
||||||
</button>
|
<TextField
|
||||||
</span>
|
value={renameValue}
|
||||||
))}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
</div>
|
autoFocus
|
||||||
|
sx={{ maxWidth: 300 }}
|
||||||
{fullPath && (
|
onKeyDown={(e) => {
|
||||||
<span className="fm-full-path" title={fullPath}>
|
if (e.key === "Enter") {
|
||||||
{fullPath}
|
e.preventDefault();
|
||||||
</span>
|
handleRename(item);
|
||||||
)}
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
{canManage && (
|
e.preventDefault();
|
||||||
<div className="fm-toolbar-actions">
|
isCancelling.current = true;
|
||||||
<button
|
setRenamingItem(null);
|
||||||
type="button"
|
setRenameValue(item.name);
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
setTimeout(() => {
|
||||||
onClick={() => {
|
isCancelling.current = false;
|
||||||
setNewFolderMode(!newFolderMode);
|
}, 0);
|
||||||
setNewFolderName("");
|
}
|
||||||
|
}}
|
||||||
|
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
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
height="14"
|
height="14"
|
||||||
@@ -551,101 +795,148 @@ export default function ProjectFileManager({
|
|||||||
<line x1="12" y1="11" x2="12" y2="17" />
|
<line x1="12" y1="11" x2="12" y2="17" />
|
||||||
<line x1="9" y1="14" x2="15" y2="14" />
|
<line x1="9" y1="14" x2="15" y2="14" />
|
||||||
</svg>
|
</svg>
|
||||||
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 ? (
|
Složka
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
</Button>
|
||||||
) : (
|
<Button
|
||||||
"Vytvořit"
|
size="small"
|
||||||
)}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
</button>
|
disabled={uploading}
|
||||||
<button
|
startIcon={
|
||||||
type="button"
|
uploading ? (
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
<CircularProgress size={14} color="inherit" />
|
||||||
onClick={() => {
|
) : (
|
||||||
|
<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);
|
setNewFolderMode(false);
|
||||||
setNewFolderName("");
|
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
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
</button>
|
<polyline points="17 8 12 3 7 8" />
|
||||||
</div>
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<span>Přetáhněte soubory sem</span>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Drop zone + table */}
|
{items.length === 0 && !filesLoading ? (
|
||||||
<div
|
<EmptyState
|
||||||
className={`fm-content ${dragOver ? "fm-drag-over" : ""}`}
|
icon={
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
>
|
|
||||||
{dragOver && (
|
|
||||||
<div className="fm-dropzone-overlay">
|
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
@@ -653,180 +944,31 @@ export default function ProjectFileManager({
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
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" />
|
<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>
|
</svg>
|
||||||
<span>
|
}
|
||||||
{hasNasFolder
|
title={
|
||||||
? "Složka je prázdná"
|
hasNasFolder
|
||||||
: "Složka projektu zatím neexistuje"}
|
? "Složka je prázdná"
|
||||||
</span>
|
: "Složka projektu zatím neexistuje"
|
||||||
{canManage && !hasNasFolder && (
|
}
|
||||||
<span style={{ fontSize: "11px" }}>
|
description={
|
||||||
Nahrání souboru ji automaticky vytvoří
|
canManage && !hasNasFolder
|
||||||
</span>
|
? "Nahrání souboru ji automaticky vytvoří"
|
||||||
)}
|
: undefined
|
||||||
</div>
|
}
|
||||||
) : (
|
/>
|
||||||
<div className="admin-table-responsive">
|
) : (
|
||||||
<table className="admin-table">
|
<DataTable
|
||||||
<thead>
|
columns={columns}
|
||||||
<tr>
|
rows={items}
|
||||||
<th style={{ width: "30px" }}></th>
|
rowKey={(item) => item.name}
|
||||||
<th>Název</th>
|
/>
|
||||||
<th style={{ width: "90px" }}>Velikost</th>
|
)}
|
||||||
<th style={{ width: "120px" }}>Změněno</th>
|
</Box>
|
||||||
{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>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteTarget !== null}
|
isOpen={deleteTarget !== null}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -836,9 +978,9 @@ export default function ProjectFileManager({
|
|||||||
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`}
|
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
||||||
import ReactQuill from "react-quill-new";
|
import ReactQuill from "react-quill-new";
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
import { RichEditorRoot } from "../ui/RichText";
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
"#000000",
|
"#000000",
|
||||||
@@ -104,8 +105,7 @@ export default function RichEditor({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<RichEditorRoot
|
||||||
className="admin-rich-editor"
|
|
||||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<ReactQuill
|
<ReactQuill
|
||||||
@@ -118,6 +118,6 @@ export default function RichEditor({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</RichEditorRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import Typography from "@mui/material/Typography";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
DateField,
|
||||||
|
TimeField,
|
||||||
|
Field,
|
||||||
|
Alert,
|
||||||
|
} from "../ui";
|
||||||
import {
|
import {
|
||||||
calcFormWorkMinutes,
|
calcFormWorkMinutes,
|
||||||
calcProjectMinutesTotal,
|
calcProjectMinutesTotal,
|
||||||
@@ -81,6 +91,19 @@ export interface ShiftFormModalProps {
|
|||||||
editingRecord: EditingRecord | null;
|
editingRecord: EditingRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RemoveIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
// ---------- ProjectTimeStatus ----------
|
// ---------- ProjectTimeStatus ----------
|
||||||
|
|
||||||
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||||
@@ -101,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
|||||||
|
|
||||||
const isMatch = remaining === 0;
|
const isMatch = remaining === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ mb: 1 }}>
|
||||||
style={{
|
<Alert severity={isMatch ? "success" : "error"}>
|
||||||
padding: "0.5rem 0.75rem",
|
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
|
||||||
marginBottom: "0.5rem",
|
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
|
||||||
borderRadius: "6px",
|
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
|
||||||
fontSize: "0.8rem",
|
{Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
|
||||||
background: isMatch
|
</Alert>
|
||||||
? "var(--success-bg, rgba(34,197,94,0.1))"
|
</Box>
|
||||||
: "var(--danger-bg, rgba(239,68,68,0.1))",
|
|
||||||
color: isMatch
|
|
||||||
? "var(--success-color, #16a34a)"
|
|
||||||
: "var(--danger-color, #dc2626)",
|
|
||||||
border: `1px solid ${
|
|
||||||
isMatch
|
|
||||||
? "var(--success-border, rgba(34,197,94,0.3))"
|
|
||||||
: "var(--danger-border, rgba(239,68,68,0.3))"
|
|
||||||
}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
|
|
||||||
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
|
|
||||||
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
|
|
||||||
{remaining < 0 ? "(překročeno)" : ""}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,65 +145,65 @@ function ProjectLogRow({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: ProjectLogRowProps) {
|
}: ProjectLogRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-row gap-2 mb-2">
|
<Box
|
||||||
<select
|
sx={{
|
||||||
value={log.project_id}
|
display: "flex",
|
||||||
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
|
alignItems: "center",
|
||||||
className="admin-form-select"
|
gap: 1,
|
||||||
style={{ flex: 3, marginBottom: 0 }}
|
mb: 1,
|
||||||
>
|
}}
|
||||||
<option value="">— Projekt —</option>
|
>
|
||||||
{projectList.map((p) => (
|
<Box sx={{ flex: 3, minWidth: 0 }}>
|
||||||
<option key={p.id} value={p.id}>
|
<Select
|
||||||
{p.project_number} – {p.name}
|
value={String(log.project_id)}
|
||||||
</option>
|
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||||
))}
|
options={[
|
||||||
</select>
|
{ value: "", label: "— Projekt —" },
|
||||||
<input
|
...projectList.map((p) => ({
|
||||||
type="number"
|
value: String(p.id),
|
||||||
min="0"
|
label: `${p.project_number} – ${p.name}`,
|
||||||
max="24"
|
})),
|
||||||
value={log.hours}
|
]}
|
||||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
/>
|
||||||
className="admin-form-input"
|
</Box>
|
||||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||||
placeholder="h"
|
<TextField
|
||||||
/>
|
type="number"
|
||||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
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
|
h
|
||||||
</span>
|
</Typography>
|
||||||
<input
|
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||||
type="number"
|
<TextField
|
||||||
min="0"
|
type="number"
|
||||||
max="59"
|
value={log.minutes}
|
||||||
value={log.minutes}
|
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
placeholder="m"
|
||||||
className="admin-form-input"
|
slotProps={{
|
||||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
|
||||||
placeholder="m"
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
m
|
m
|
||||||
</span>
|
</Typography>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
aria-label="Odebrat"
|
||||||
style={{ padding: "0.375rem", flexShrink: 0 }}
|
|
||||||
title="Odebrat"
|
title="Odebrat"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<svg
|
{RemoveIcon}
|
||||||
width="16"
|
</IconButton>
|
||||||
height="16"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +223,6 @@ export default function ShiftFormModal({
|
|||||||
onShiftDateChange,
|
onShiftDateChange,
|
||||||
editingRecord,
|
editingRecord,
|
||||||
}: ShiftFormModalProps) {
|
}: ShiftFormModalProps) {
|
||||||
useModalLock(isOpen);
|
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
const isWorkType = form.leave_type === "work";
|
const isWorkType = form.leave_type === "work";
|
||||||
|
|
||||||
@@ -247,276 +253,211 @@ export default function ShiftFormModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<motion.div
|
onClose={onClose}
|
||||||
className="admin-modal-overlay"
|
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||||
initial={{ opacity: 0 }}
|
subtitle={
|
||||||
animate={{ opacity: 1 }}
|
!isCreate && editingRecord
|
||||||
exit={{ opacity: 0 }}
|
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
||||||
transition={{ duration: 0.2 }}
|
: undefined
|
||||||
|
}
|
||||||
|
maxWidth="lg"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitText="Uložit"
|
||||||
|
cancelText="Zrušit"
|
||||||
|
>
|
||||||
|
{isCreate ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
<Field label="Zaměstnanec" required>
|
||||||
<motion.div
|
<Select
|
||||||
className="admin-modal admin-modal-lg"
|
value={form.user_id}
|
||||||
role="dialog"
|
onChange={(val) => updateField("user_id", val)}
|
||||||
aria-modal="true"
|
options={[
|
||||||
aria-labelledby="shift-form-modal-title"
|
{ value: "", label: "Vyberte zaměstnance" },
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
...users.map((user) => ({
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
value: String(user.id),
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
label: user.name,
|
||||||
transition={{ duration: 0.2 }}
|
})),
|
||||||
>
|
]}
|
||||||
<div className="admin-modal-header">
|
/>
|
||||||
<h2 id="shift-form-modal-title" className="admin-modal-title">
|
</Field>
|
||||||
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
<Field label="Datum směny" required>
|
||||||
</h2>
|
<DateField
|
||||||
{!isCreate && editingRecord && (
|
value={form.shift_date}
|
||||||
<p
|
onChange={(val) => onShiftDateChange(val)}
|
||||||
style={{
|
/>
|
||||||
color: "var(--text-secondary)",
|
</Field>
|
||||||
marginTop: "0.25rem",
|
</Box>
|
||||||
}}
|
) : (
|
||||||
>
|
<Field label="Datum směny">
|
||||||
{editingRecord.user_name} —{" "}
|
<DateField
|
||||||
{formatDate(editingRecord.shift_date)}
|
value={form.shift_date}
|
||||||
</p>
|
onChange={(val) => updateField("shift_date", val)}
|
||||||
)}
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</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,7 @@
|
|||||||
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 {
|
import {
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
getActivityIconClass,
|
getActivityIconClass,
|
||||||
@@ -18,6 +21,16 @@ interface DashActivityFeedProps {
|
|||||||
activities: Activity[] | null;
|
activities: Activity[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maps the legacy activity-icon class to the MUI palette key used to tint the
|
||||||
|
// circular icon badge (danger → error, accent → primary, muted → default).
|
||||||
|
const ACTIVITY_BADGE_COLOR: Record<string, string> = {
|
||||||
|
success: "success",
|
||||||
|
info: "info",
|
||||||
|
danger: "error",
|
||||||
|
accent: "primary",
|
||||||
|
muted: "default",
|
||||||
|
};
|
||||||
|
|
||||||
function getActivityIcon(action: string) {
|
function getActivityIcon(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "create":
|
case "create":
|
||||||
@@ -103,37 +116,92 @@ export default function DashActivityFeed({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-card dash-activity-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Audit log</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Audit log</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/audit-log"
|
to="/audit-log"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Detail →
|
Detail →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
|
||||||
{activities.map((act) => (
|
{activities.length === 0 ? (
|
||||||
<div key={act.id} className="dash-activity-row">
|
<EmptyState title="Žádná aktivita" />
|
||||||
<div
|
) : (
|
||||||
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
>
|
{activities.map((act) => {
|
||||||
{getActivityIcon(act.action)}
|
const badgeColor =
|
||||||
</div>
|
ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ??
|
||||||
<div className="dash-activity-main">
|
"default";
|
||||||
<div className="dash-activity-text">{act.description}</div>
|
const isDefault = badgeColor === "default";
|
||||||
<div className="dash-activity-sub">
|
return (
|
||||||
{act.username || "Systém"} ·{" "}
|
<Box
|
||||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
key={act.id}
|
||||||
</div>
|
sx={{
|
||||||
</div>
|
display: "flex",
|
||||||
<div className="dash-activity-time admin-mono">
|
alignItems: "center",
|
||||||
{formatActivityTime(act.created_at)}
|
gap: 1.5,
|
||||||
</div>
|
py: 1.25,
|
||||||
</div>
|
borderBottom: 1,
|
||||||
))}
|
borderColor: "divider",
|
||||||
</div>
|
"&:last-of-type": { borderBottom: 0 },
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
bgcolor: isDefault ? "grey.600" : `${badgeColor}.main`,
|
||||||
|
color: "common.white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 { Link as RouterLink } from "react-router-dom";
|
||||||
import {
|
import Box from "@mui/material/Box";
|
||||||
LEAVE_TYPE_LABELS,
|
import Typography from "@mui/material/Typography";
|
||||||
STATUS_DOT_CLASS,
|
import Avatar from "@mui/material/Avatar";
|
||||||
STATUS_LABELS,
|
import { Card, Button, StatusChip, EmptyState } from "../../ui";
|
||||||
} from "../../utils/dashboardHelpers";
|
import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
|
||||||
|
|
||||||
interface AttendanceUser {
|
interface AttendanceUser {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
|
|||||||
attendance: AttendanceData | null;
|
attendance: AttendanceData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maps the attendance status to the StatusChip / avatar tint color.
|
||||||
|
// Mirrors the legacy dash-status-* dot classes: in→success, away→warning,
|
||||||
|
// out→default, leave→error.
|
||||||
|
const STATUS_COLOR: Record<
|
||||||
|
string,
|
||||||
|
"default" | "success" | "warning" | "error"
|
||||||
|
> = {
|
||||||
|
in: "success",
|
||||||
|
away: "warning",
|
||||||
|
out: "default",
|
||||||
|
leave: "error",
|
||||||
|
};
|
||||||
|
|
||||||
export default function DashAttendanceToday({
|
export default function DashAttendanceToday({
|
||||||
attendance,
|
attendance,
|
||||||
}: DashAttendanceTodayProps) {
|
}: DashAttendanceTodayProps) {
|
||||||
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-card dash-attendance-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Docházka dnes</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Docházka dnes</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/attendance/admin"
|
to="/attendance/admin"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Detail →
|
Detail →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
|
||||||
{attendance.users.map((u, i) => (
|
{attendance.users.length === 0 ? (
|
||||||
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
|
<EmptyState title="Žádná docházka" />
|
||||||
<div
|
) : (
|
||||||
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
>
|
{attendance.users.map((u, i) => {
|
||||||
{u.initials || "?"}
|
const color = STATUS_COLOR[u.status] ?? "default";
|
||||||
</div>
|
const isDefault = color === "default";
|
||||||
<div className="dash-presence-name">{u.name}</div>
|
return (
|
||||||
<div className="dash-presence-end">
|
<Box
|
||||||
<span
|
key={`${u.user_id}-${i}`}
|
||||||
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
"&:last-of-type": { borderBottom: 0 },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{u.status === "leave"
|
<Avatar
|
||||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
sx={{
|
||||||
: STATUS_LABELS[u.status]}
|
width: 36,
|
||||||
</span>
|
height: 36,
|
||||||
{u.arrived_at && (
|
fontSize: "0.8rem",
|
||||||
<span className="admin-mono dash-presence-time">
|
fontWeight: 700,
|
||||||
{u.arrived_at}
|
bgcolor: isDefault ? "grey.600" : `${color}.main`,
|
||||||
</span>
|
color: "common.white",
|
||||||
)}
|
}}
|
||||||
</div>
|
>
|
||||||
</div>
|
{u.initials || "?"}
|
||||||
))}
|
</Avatar>
|
||||||
</div>
|
<Typography
|
||||||
</div>
|
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 { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { StatCard, type StatCardColor } from "../../ui";
|
||||||
import { formatCurrency } from "../../utils/formatters";
|
import { formatCurrency } from "../../utils/formatters";
|
||||||
|
|
||||||
interface KpiCard {
|
interface KpiCard {
|
||||||
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const KPI_CLASS_MAP: Record<number, string> = {
|
// Maps the legacy KPI color tokens to the StatCard color palette
|
||||||
4: "admin-kpi-4",
|
// (danger → error; the rest are 1:1).
|
||||||
3: "admin-kpi-3",
|
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||||
2: "admin-kpi-2",
|
success: "success",
|
||||||
1: "admin-kpi-1",
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
danger: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||||
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Box
|
||||||
className={`admin-kpi-grid ${kpiClass}`}
|
component={motion.div}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
sm: "repeat(2, 1fr)",
|
||||||
|
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{kpiCards.map((kpi) => (
|
{kpiCards.map((kpi) => (
|
||||||
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
|
<StatCard
|
||||||
<div className="admin-stat-label">{kpi.label}</div>
|
key={kpi.label}
|
||||||
<div className="admin-stat-value admin-mono">
|
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
|
||||||
{kpi.value}
|
label={kpi.label}
|
||||||
{kpi.sub && (
|
value={
|
||||||
<small
|
<>
|
||||||
className="text-muted"
|
{kpi.value}
|
||||||
style={{
|
{kpi.sub && (
|
||||||
fontSize: "0.75em",
|
<Typography
|
||||||
fontWeight: 500,
|
component="span"
|
||||||
marginLeft: "0.25rem",
|
color="text.secondary"
|
||||||
}}
|
sx={{
|
||||||
>
|
fontSize: "0.75em",
|
||||||
{kpi.sub}
|
fontWeight: 500,
|
||||||
</small>
|
ml: 0.5,
|
||||||
)}
|
fontFamily: "inherit",
|
||||||
</div>
|
}}
|
||||||
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
>
|
||||||
</div>
|
{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 { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
||||||
import AdminDatePicker from "../AdminDatePicker";
|
import { Button, Modal, Field, Select, DateField, TextField } from "../../ui";
|
||||||
import apiFetch from "../../utils/api";
|
import apiFetch from "../../utils/api";
|
||||||
import useModalLock from "../../hooks/useModalLock";
|
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -46,6 +46,14 @@ interface DashQuickActionsProps {
|
|||||||
onPunch: () => void;
|
onPunch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maps the legacy quick-action color tokens to the MUI Button palette color.
|
||||||
|
const ACTION_COLOR: Record<string, "success" | "error" | "info" | "warning"> = {
|
||||||
|
success: "success",
|
||||||
|
danger: "error",
|
||||||
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
export default function DashQuickActions({
|
export default function DashQuickActions({
|
||||||
dashData,
|
dashData,
|
||||||
punching,
|
punching,
|
||||||
@@ -69,8 +77,6 @@ export default function DashQuickActions({
|
|||||||
});
|
});
|
||||||
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
||||||
|
|
||||||
useModalLock(showTripModal);
|
|
||||||
|
|
||||||
const openTripModal = async () => {
|
const openTripModal = async () => {
|
||||||
setTripForm({
|
setTripForm({
|
||||||
vehicle_id: "",
|
vehicle_id: "",
|
||||||
@@ -286,303 +292,192 @@ export default function DashQuickActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<Box
|
||||||
className="dash-quick-actions"
|
component={motion.div}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
transition={{ duration: 0.25, delay: 0.08 }}
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
sm: "repeat(2, 1fr)",
|
||||||
|
md: "repeat(4, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{quickActions.map((action) =>
|
{quickActions.map((action) => {
|
||||||
action.onClick ? (
|
const color = ACTION_COLOR[action.color] ?? "primary";
|
||||||
<button
|
const common = {
|
||||||
|
variant: "contained" as const,
|
||||||
|
color,
|
||||||
|
startIcon: action.icon,
|
||||||
|
sx: { py: 1.5, justifyContent: "flex-start" },
|
||||||
|
};
|
||||||
|
return action.onClick ? (
|
||||||
|
<Button
|
||||||
key={action.label}
|
key={action.label}
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
{...common}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.label}
|
||||||
<span>{action.label}</span>
|
</Button>
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Button
|
||||||
key={action.label}
|
key={action.label}
|
||||||
|
component={RouterLink}
|
||||||
to={action.path!}
|
to={action.path!}
|
||||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
{...common}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.label}
|
||||||
<span>{action.label}</span>
|
</Button>
|
||||||
</Link>
|
);
|
||||||
),
|
})}
|
||||||
)}
|
</Box>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{showTripModal && (
|
isOpen={showTripModal}
|
||||||
<motion.div
|
onClose={() => setShowTripModal(false)}
|
||||||
className="admin-modal-overlay"
|
title="Přidat jízdu"
|
||||||
initial={{ opacity: 0 }}
|
maxWidth="md"
|
||||||
animate={{ opacity: 1 }}
|
onSubmit={handleTripSubmit}
|
||||||
exit={{ opacity: 0 }}
|
submitText="Uložit"
|
||||||
transition={{ duration: 0.2 }}
|
loading={tripSubmitting}
|
||||||
>
|
>
|
||||||
<div
|
<Box
|
||||||
className="admin-modal-backdrop"
|
sx={{
|
||||||
onClick={() => setShowTripModal(false)}
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
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
|
</Field>
|
||||||
className="admin-modal admin-modal-lg"
|
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
<DateField
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
value={tripForm.trip_date}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
onChange={(val) => {
|
||||||
transition={{ duration: 0.2 }}
|
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||||
>
|
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
|
||||||
<div className="admin-modal-header">
|
}}
|
||||||
<h2 className="admin-modal-title">Přidat jízdu</h2>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-modal-body">
|
</Box>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="admin-form-row admin-form-row-3">
|
<Box
|
||||||
<div
|
sx={{
|
||||||
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
|
display: "grid",
|
||||||
>
|
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
|
||||||
<label className="admin-form-label required">
|
gap: 2,
|
||||||
Počáteční stav km
|
}}
|
||||||
</label>
|
>
|
||||||
<input
|
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
|
||||||
type="number"
|
<TextField
|
||||||
inputMode="numeric"
|
type="number"
|
||||||
value={tripForm.start_km}
|
inputMode="numeric"
|
||||||
onChange={(e) => {
|
value={tripForm.start_km}
|
||||||
setTripForm((prev) => ({
|
onChange={(e) => {
|
||||||
...prev,
|
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
|
||||||
start_km: e.target.value,
|
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
|
||||||
}));
|
}}
|
||||||
setTripErrors((prev) => ({
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
...prev,
|
/>
|
||||||
start_km: undefined,
|
</Field>
|
||||||
}));
|
<Field label="Konečný stav km" required error={tripErrors.end_km}>
|
||||||
}}
|
<TextField
|
||||||
className="admin-form-input"
|
type="number"
|
||||||
min="0"
|
inputMode="numeric"
|
||||||
/>
|
value={tripForm.end_km}
|
||||||
{tripErrors.start_km && (
|
onChange={(e) => {
|
||||||
<span className="admin-form-error">
|
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
|
||||||
{tripErrors.start_km}
|
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
|
||||||
</span>
|
}}
|
||||||
)}
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
</div>
|
/>
|
||||||
<div
|
</Field>
|
||||||
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
|
<Field label="Vzdálenost">
|
||||||
>
|
<TextField
|
||||||
<label className="admin-form-label required">
|
value={`${formatKm(tripDistance())} km`}
|
||||||
Konečný stav km
|
InputProps={{ readOnly: true }}
|
||||||
</label>
|
disabled
|
||||||
<input
|
/>
|
||||||
type="number"
|
</Field>
|
||||||
inputMode="numeric"
|
</Box>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<div
|
sx={{
|
||||||
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
|
display: "grid",
|
||||||
>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<label className="admin-form-label required">
|
gap: 2,
|
||||||
Místo odjezdu
|
}}
|
||||||
</label>
|
>
|
||||||
<input
|
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
|
||||||
type="text"
|
<TextField
|
||||||
value={tripForm.route_from}
|
value={tripForm.route_from}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTripForm((prev) => ({
|
setTripForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
route_from: e.target.value,
|
route_from: e.target.value,
|
||||||
}));
|
}));
|
||||||
setTripErrors((prev) => ({
|
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
|
||||||
...prev,
|
}}
|
||||||
route_from: undefined,
|
placeholder="Např. Praha"
|
||||||
}));
|
/>
|
||||||
}}
|
</Field>
|
||||||
className="admin-form-input"
|
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
|
||||||
placeholder="Např. Praha"
|
<TextField
|
||||||
/>
|
value={tripForm.route_to}
|
||||||
{tripErrors.route_from && (
|
onChange={(e) => {
|
||||||
<span className="admin-form-error">
|
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
|
||||||
{tripErrors.route_from}
|
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
|
||||||
</span>
|
}}
|
||||||
)}
|
placeholder="Např. Brno"
|
||||||
</div>
|
/>
|
||||||
<div
|
</Field>
|
||||||
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
|
</Box>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Typ jízdy">
|
||||||
<label className="admin-form-label">Typ jízdy</label>
|
<Select
|
||||||
<select
|
value={String(tripForm.is_business)}
|
||||||
value={tripForm.is_business}
|
onChange={(val) =>
|
||||||
onChange={(e) =>
|
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
|
||||||
setTripForm((prev) => ({
|
}
|
||||||
...prev,
|
options={[
|
||||||
is_business: parseInt(e.target.value),
|
{ value: "1", label: "Služební" },
|
||||||
}))
|
{ value: "0", label: "Soukromá" },
|
||||||
}
|
]}
|
||||||
className="admin-form-select"
|
/>
|
||||||
>
|
</Field>
|
||||||
<option value={1}>Služební</option>
|
|
||||||
<option value={0}>Soukromá</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Poznámky">
|
||||||
<label className="admin-form-label">Poznámky</label>
|
<TextField
|
||||||
<textarea
|
multiline
|
||||||
value={tripForm.notes}
|
minRows={2}
|
||||||
onChange={(e) =>
|
value={tripForm.notes}
|
||||||
setTripForm((prev) => ({
|
onChange={(e) =>
|
||||||
...prev,
|
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||||
notes: e.target.value,
|
}
|
||||||
}))
|
placeholder="Volitelné poznámky..."
|
||||||
}
|
/>
|
||||||
className="admin-form-textarea"
|
</Field>
|
||||||
rows={2}
|
</Modal>
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import ConfirmModal from "../ConfirmModal";
|
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
|
||||||
import useModalLock from "../../hooks/useModalLock";
|
|
||||||
import apiFetch from "../../utils/api";
|
import apiFetch from "../../utils/api";
|
||||||
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
||||||
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
||||||
@@ -76,8 +79,6 @@ export default function DashSessions() {
|
|||||||
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
useModalLock(deleteAllModal);
|
|
||||||
|
|
||||||
const handleDeleteSession = async () => {
|
const handleDeleteSession = async () => {
|
||||||
if (!deleteModal.session) {
|
if (!deleteModal.session) {
|
||||||
return;
|
return;
|
||||||
@@ -127,113 +128,140 @@ export default function DashSessions() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
>
|
>
|
||||||
<div
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
className="admin-card-header"
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: "0.75rem",
|
gap: 1.5,
|
||||||
}}
|
mb: 2,
|
||||||
>
|
}}
|
||||||
<h2 className="admin-card-title">Přihlášená zařízení</h2>
|
>
|
||||||
{sessions.filter((s) => !s.is_current).length > 0 && (
|
<Typography variant="h6">Přihlášená zařízení</Typography>
|
||||||
<button
|
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||||
onClick={() => setDeleteAllModal(true)}
|
<Button
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
onClick={() => setDeleteAllModal(true)}
|
||||||
>
|
variant="outlined"
|
||||||
Odhlásit ostatní
|
color="inherit"
|
||||||
</button>
|
size="small"
|
||||||
)}
|
sx={{ flexShrink: 0 }}
|
||||||
</div>
|
>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
Odhlásit ostatní
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{sessionsLoading ? (
|
{sessionsLoading ? (
|
||||||
<div className="admin-loading">
|
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
|
||||||
<div className="admin-spinner" />
|
<CircularProgress size={28} />
|
||||||
</div>
|
</Box>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ textAlign: "center", py: 3 }}
|
||||||
|
>
|
||||||
|
Žádné aktivní relace
|
||||||
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
{sessions.length === 0 && (
|
{sessions.map((session) => (
|
||||||
<div
|
<Box
|
||||||
className="text-secondary"
|
key={session.id}
|
||||||
style={{
|
sx={{
|
||||||
padding: "1.5rem",
|
display: "flex",
|
||||||
textAlign: "center",
|
alignItems: "center",
|
||||||
fontSize: "0.875rem",
|
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
|
<Box
|
||||||
</div>
|
sx={{
|
||||||
)}
|
display: "flex",
|
||||||
{sessions.length > 0 && (
|
alignItems: "center",
|
||||||
<div className="dash-sessions-list">
|
justifyContent: "center",
|
||||||
{sessions.map((session) => (
|
color: "text.secondary",
|
||||||
<div
|
flexShrink: 0,
|
||||||
key={session.id}
|
}}
|
||||||
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
>
|
||||||
|
{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">
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
{getDeviceIcon(session.device_info?.icon)}
|
{session.device_info?.browser} na{" "}
|
||||||
</div>
|
{session.device_info?.os}
|
||||||
<div className="dash-session-info">
|
</Typography>
|
||||||
<div className="dash-session-device">
|
{session.is_current && (
|
||||||
{session.device_info?.browser} na{" "}
|
<StatusChip color="success" label="Aktuální" />
|
||||||
{session.device_info?.os}
|
)}
|
||||||
{session.is_current && (
|
</Box>
|
||||||
<span
|
<Box
|
||||||
className="admin-badge admin-badge-success"
|
sx={{
|
||||||
style={{ marginLeft: "0.5rem" }}
|
display: "flex",
|
||||||
>
|
alignItems: "center",
|
||||||
Aktuální
|
gap: 0.75,
|
||||||
</span>
|
color: "text.secondary",
|
||||||
)}
|
fontSize: "0.75rem",
|
||||||
</div>
|
}}
|
||||||
<div className="dash-session-meta">
|
>
|
||||||
<span>{session.ip_address}</span>
|
<span>{session.ip_address}</span>
|
||||||
<span className="dash-session-meta-separator">|</span>
|
<span>|</span>
|
||||||
<span>{formatSessionDate(session.created_at)}</span>
|
<span>{formatSessionDate(session.created_at)}</span>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-session-actions">
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
{!session.is_current && (
|
{!session.is_current && (
|
||||||
<button
|
<IconButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDeleteModal({ isOpen: true, session })
|
setDeleteModal({ isOpen: true, session })
|
||||||
}
|
}
|
||||||
className="admin-btn-icon danger"
|
color="error"
|
||||||
title="Ukončit relaci"
|
size="small"
|
||||||
aria-label="Ukončit relaci"
|
title="Ukončit relaci"
|
||||||
>
|
aria-label="Ukončit relaci"
|
||||||
<svg
|
>
|
||||||
width="16"
|
<svg
|
||||||
height="16"
|
width="16"
|
||||||
viewBox="0 0 24 24"
|
height="16"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
strokeWidth="2"
|
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" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
<polyline points="16 17 21 12 16 7" />
|
||||||
</svg>
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
</button>
|
</svg>
|
||||||
)}
|
</IconButton>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</Box>
|
||||||
))}
|
</Box>
|
||||||
</div>
|
))}
|
||||||
)}
|
</Box>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
||||||
onConfirm={handleDeleteSession}
|
onConfirm={handleDeleteSession}
|
||||||
@@ -241,10 +269,10 @@ export default function DashSessions() {
|
|||||||
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
|
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
|
||||||
confirmText="Ukončit"
|
confirmText="Ukončit"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteAllModal}
|
isOpen={deleteAllModal}
|
||||||
onClose={() => setDeleteAllModal(false)}
|
onClose={() => setDeleteAllModal(false)}
|
||||||
onConfirm={handleDeleteAllSessions}
|
onConfirm={handleDeleteAllSessions}
|
||||||
@@ -252,7 +280,7 @@ export default function DashSessions() {
|
|||||||
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
||||||
confirmText="Odhlásit vše"
|
confirmText="Odhlásit vše"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="warning"
|
confirmVariant="primary"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { jsonQuery } from "../../lib/apiAdapter";
|
|
||||||
|
|
||||||
interface Batch {
|
|
||||||
id: number;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
received_at: string;
|
|
||||||
is_consumed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BatchesResponse {
|
|
||||||
batches: Batch[];
|
|
||||||
total_stock: number;
|
|
||||||
reserved_quantity: number;
|
|
||||||
available_quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BatchPickerProps {
|
|
||||||
itemId: number | null;
|
|
||||||
value: number | null;
|
|
||||||
onChange: (batchId: number, unitPrice: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BatchPicker({
|
|
||||||
itemId,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: BatchPickerProps) {
|
|
||||||
const { data: response } = useQuery({
|
|
||||||
queryKey: ["warehouse", "batches", itemId],
|
|
||||||
queryFn: () =>
|
|
||||||
jsonQuery<BatchesResponse>(
|
|
||||||
`/api/admin/warehouse/items/${itemId}/batches`,
|
|
||||||
),
|
|
||||||
enabled: !!itemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const batches = response?.batches ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{response && response.reserved_quantity > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
marginBottom: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
|
|
||||||
{response.reserved_quantity} ks)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<select
|
|
||||||
className="admin-form-select"
|
|
||||||
value={value ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const batchId = Number(e.target.value);
|
|
||||||
const batch = batches.find((b) => b.id === batchId);
|
|
||||||
if (batch) onChange(batch.id, Number(batch.unit_price));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">-- auto FIFO --</option>
|
|
||||||
{batches.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>
|
|
||||||
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
|
|
||||||
ks | {Number(b.unit_price).toFixed(2)} Kč
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
import { useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {
|
||||||
|
warehouseItemListOptions,
|
||||||
|
type WarehouseItem,
|
||||||
|
} from "../../lib/queries/warehouse";
|
||||||
|
|
||||||
interface ItemPickerProps {
|
interface ItemPickerProps {
|
||||||
value: number | null;
|
value: number | null;
|
||||||
@@ -9,220 +16,88 @@ interface ItemPickerProps {
|
|||||||
itemName?: string;
|
itemName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal option shape used to display a selected value that isn't in the
|
||||||
|
* current search results (edit/prefill via `itemName`). */
|
||||||
|
type ItemOption = Pick<WarehouseItem, "id" | "name"> & Partial<WarehouseItem>;
|
||||||
|
|
||||||
export default function ItemPicker({
|
export default function ItemPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
itemName,
|
itemName,
|
||||||
}: ItemPickerProps) {
|
}: ItemPickerProps) {
|
||||||
const [search, setSearch] = useState(itemName ?? "");
|
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
|
|
||||||
const items = data?.data ?? [];
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const { data, isFetching } = useQuery(
|
||||||
const activeIndexRef = useRef<number>(-1);
|
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||||
const listboxId = useId();
|
);
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const options: ItemOption[] = data?.data ?? [];
|
||||||
|
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
// Show the selected value even when it isn't in the current search results
|
||||||
position: "fixed";
|
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
|
||||||
top: number;
|
const selectedOption: ItemOption | null =
|
||||||
left: number;
|
value == null
|
||||||
width: number;
|
? null
|
||||||
zIndex: number;
|
: (options.find((o) => o.id === value) ?? {
|
||||||
} | null>(null);
|
id: value,
|
||||||
|
name: itemName ?? "",
|
||||||
const updatePosition = useCallback(() => {
|
});
|
||||||
if (!containerRef.current) return;
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
setDropdownStyle({
|
|
||||||
position: "fixed",
|
|
||||||
top: rect.bottom + 2,
|
|
||||||
left: rect.left,
|
|
||||||
width: rect.width,
|
|
||||||
zIndex: 100,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset active index when items change
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeIndex >= items.length) {
|
|
||||||
activeIndexRef.current = -1;
|
|
||||||
setActiveIndex(-1);
|
|
||||||
}
|
|
||||||
}, [items, activeIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
updatePosition();
|
|
||||||
const onScroll = () => updatePosition();
|
|
||||||
const onClose = () => setOpen(false);
|
|
||||||
window.addEventListener("scroll", onScroll, true);
|
|
||||||
window.addEventListener("resize", onClose);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", onScroll, true);
|
|
||||||
window.removeEventListener("resize", onClose);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
setDropdownStyle(null);
|
|
||||||
activeIndexRef.current = -1;
|
|
||||||
setActiveIndex(-1);
|
|
||||||
}
|
|
||||||
}, [open, updatePosition]);
|
|
||||||
|
|
||||||
// Close on click-outside via mousedown on document
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onDocMouseDown = (e: MouseEvent) => {
|
|
||||||
const target = e.target;
|
|
||||||
if (containerRef.current && target instanceof Node) {
|
|
||||||
if (!containerRef.current.contains(target)) {
|
|
||||||
// Don't close if click landed on an option in the portal
|
|
||||||
const listEl = document.getElementById(listboxId);
|
|
||||||
if (listEl && listEl.contains(target)) return;
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", onDocMouseDown);
|
|
||||||
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
|
||||||
}, [open, listboxId]);
|
|
||||||
|
|
||||||
const handleSelect = (itemId: number, name: string) => {
|
|
||||||
onChange(itemId);
|
|
||||||
setOpen(false);
|
|
||||||
setSearch(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setActive = (index: number) => {
|
|
||||||
activeIndexRef.current = index;
|
|
||||||
setActiveIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!open) {
|
|
||||||
setOpen(true);
|
|
||||||
if (items.length > 0) setActive(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next =
|
|
||||||
activeIndexRef.current < items.length - 1
|
|
||||||
? activeIndexRef.current + 1
|
|
||||||
: 0;
|
|
||||||
setActive(next);
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!open) {
|
|
||||||
setOpen(true);
|
|
||||||
if (items.length > 0) setActive(items.length - 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prev =
|
|
||||||
activeIndexRef.current > 0
|
|
||||||
? activeIndexRef.current - 1
|
|
||||||
: items.length - 1;
|
|
||||||
setActive(prev);
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
if (
|
|
||||||
open &&
|
|
||||||
activeIndexRef.current >= 0 &&
|
|
||||||
activeIndexRef.current < items.length
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
const item = items[activeIndexRef.current];
|
|
||||||
handleSelect(item.id, item.name);
|
|
||||||
}
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
if (open) {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
} else if (e.key === "Home") {
|
|
||||||
if (open && items.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
setActive(0);
|
|
||||||
}
|
|
||||||
} else if (e.key === "End") {
|
|
||||||
if (open && items.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
setActive(items.length - 1);
|
|
||||||
}
|
|
||||||
} else if (e.key === "Tab") {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeId =
|
|
||||||
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Autocomplete<ItemOption>
|
||||||
ref={containerRef}
|
value={selectedOption}
|
||||||
style={{ position: "relative" }}
|
inputValue={inputValue}
|
||||||
role="combobox"
|
onInputChange={(_, newInput) => setInputValue(newInput)}
|
||||||
aria-haspopup="listbox"
|
onChange={(_, opt) => {
|
||||||
aria-expanded={open}
|
if (opt) onChange(opt.id);
|
||||||
aria-owns={listboxId}
|
}}
|
||||||
>
|
options={options}
|
||||||
<input
|
loading={isFetching}
|
||||||
type="text"
|
filterOptions={(x) => x}
|
||||||
className="admin-form-input"
|
getOptionLabel={(o) => o.name}
|
||||||
placeholder="Hledat položku..."
|
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||||
value={search}
|
noOptionsText="Žádné položky"
|
||||||
role="searchbox"
|
renderOption={(props, option) => {
|
||||||
aria-autocomplete="list"
|
const { key, ...rest } = props;
|
||||||
aria-controls={listboxId}
|
return (
|
||||||
aria-activedescendant={activeId}
|
<Box component="li" key={key} {...rest}>
|
||||||
onChange={(e) => {
|
<Box>
|
||||||
setSearch(e.target.value);
|
<Typography variant="body2">{option.name}</Typography>
|
||||||
setOpen(true);
|
{(option.item_number ||
|
||||||
}}
|
option.available_quantity !== undefined) && (
|
||||||
onFocus={() => {
|
<Typography variant="caption" color="text.secondary">
|
||||||
setOpen(true);
|
{option.item_number}
|
||||||
updatePosition();
|
{option.item_number && option.available_quantity !== undefined
|
||||||
}}
|
? " · "
|
||||||
onKeyDown={handleKeyDown}
|
: ""}
|
||||||
/>
|
{option.available_quantity !== undefined
|
||||||
{open &&
|
? `${option.available_quantity} ${option.unit ?? ""}`
|
||||||
items.length > 0 &&
|
: ""}
|
||||||
dropdownStyle &&
|
</Typography>
|
||||||
createPortal(
|
)}
|
||||||
<ul
|
</Box>
|
||||||
id={listboxId}
|
</Box>
|
||||||
role="listbox"
|
);
|
||||||
className="admin-item-picker-list"
|
}}
|
||||||
style={dropdownStyle}
|
renderInput={(params) => (
|
||||||
>
|
<TextField
|
||||||
{items.map((item, index) => (
|
{...params}
|
||||||
<li
|
size="small"
|
||||||
key={item.id}
|
placeholder="Hledat položku..."
|
||||||
id={`${listboxId}-option-${index}`}
|
slotProps={{
|
||||||
role="option"
|
input: {
|
||||||
aria-selected={value === item.id}
|
...params.InputProps,
|
||||||
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
|
endAdornment: (
|
||||||
onMouseDown={(e) => {
|
<>
|
||||||
e.preventDefault();
|
{isFetching ? (
|
||||||
handleSelect(item.id, item.name);
|
<CircularProgress color="inherit" size={18} />
|
||||||
}}
|
) : null}
|
||||||
>
|
{params.InputProps.endAdornment}
|
||||||
<span className="admin-item-picker-name">{item.name}</span>
|
</>
|
||||||
{item.item_number && (
|
),
|
||||||
<span className="admin-item-picker-number">
|
},
|
||||||
{item.item_number}
|
}}
|
||||||
</span>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.available_quantity !== undefined && (
|
/>
|
||||||
<span className="admin-item-picker-qty">
|
|
||||||
{item.available_quantity} {item.unit}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
warehouseLocationListOptions,
|
|
||||||
type WarehouseLocation,
|
|
||||||
} from "../../lib/queries/warehouse";
|
|
||||||
|
|
||||||
interface LocationSelectProps {
|
|
||||||
value: number | null;
|
|
||||||
onChange: (locationId: number | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LocationSelect({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: LocationSelectProps) {
|
|
||||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
className="admin-form-select"
|
|
||||||
value={value ?? ""}
|
|
||||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
>
|
|
||||||
<option value="">-- bez lokace --</option>
|
|
||||||
{locations?.map((loc: WarehouseLocation) => (
|
|
||||||
<option key={loc.id} value={loc.id}>
|
|
||||||
{loc.code} - {loc.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { Select } from "../../ui";
|
||||||
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
||||||
|
|
||||||
interface ReservationPickerProps {
|
interface ReservationPickerProps {
|
||||||
@@ -26,11 +28,9 @@ export default function ReservationPicker({
|
|||||||
const reservations = result?.data ?? [];
|
const reservations = result?.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<Select
|
||||||
className="admin-form-select"
|
value={value == null ? "" : String(value)}
|
||||||
value={value ?? ""}
|
onChange={(val) => {
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (!val) {
|
if (!val) {
|
||||||
onChange(null, 0);
|
onChange(null, 0);
|
||||||
return;
|
return;
|
||||||
@@ -42,13 +42,13 @@ export default function ReservationPicker({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">— žádná rezervace —</option>
|
<MenuItem value="">— žádná rezervace —</MenuItem>
|
||||||
{reservations.map((r) => (
|
{reservations.map((r) => (
|
||||||
<option key={r.id} value={r.id}>
|
<MenuItem key={r.id} value={String(r.id)}>
|
||||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||||
</option>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
warehouseSupplierListOptions,
|
|
||||||
type WarehouseSupplier,
|
|
||||||
} from "../../lib/queries/warehouse";
|
|
||||||
|
|
||||||
interface SupplierSelectProps {
|
|
||||||
value: number | null;
|
|
||||||
onChange: (supplierId: number | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SupplierSelect({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: SupplierSelectProps) {
|
|
||||||
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
|
|
||||||
const suppliers = data?.data ?? [];
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
className="admin-form-select"
|
|
||||||
value={value ?? ""}
|
|
||||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
>
|
|
||||||
<option value="">-- bez dodavatele --</option>
|
|
||||||
{suppliers.map((s: WarehouseSupplier) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
{s.ico ? ` (${s.ico})` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import { ReactNode, useId, useRef } from "react";
|
|
||||||
import ItemPicker from "./ItemPicker";
|
|
||||||
import LocationSelect from "./LocationSelect";
|
|
||||||
import BatchPicker from "./BatchPicker";
|
|
||||||
|
|
||||||
export interface MovementItem {
|
|
||||||
key: string;
|
|
||||||
item_id: number | null;
|
|
||||||
item_name?: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
location_id: number | null;
|
|
||||||
batch_id: number | null;
|
|
||||||
reservation_id: number | null;
|
|
||||||
notes: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MovementRowRenderer<T> = (
|
|
||||||
item: T,
|
|
||||||
index: number,
|
|
||||||
updateItem: (field: string, value: unknown) => void,
|
|
||||||
removeItem: () => void,
|
|
||||||
) => ReactNode;
|
|
||||||
|
|
||||||
interface WarehouseMovementTableProps<T extends { key: string }> {
|
|
||||||
items: T[];
|
|
||||||
onChange: (items: T[]) => void;
|
|
||||||
mode: "receipt" | "issue" | "inventory";
|
|
||||||
defaultItem?: () => Omit<T, "key">;
|
|
||||||
renderRow?: MovementRowRenderer<T>;
|
|
||||||
renderHeader?: () => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDecimal(raw: string): number {
|
|
||||||
// Strip leading minus if you want negative support; here we want only positive
|
|
||||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
|
||||||
const n = Number(cleaned);
|
|
||||||
return Number.isFinite(n) ? n : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
|
|
||||||
item_id: null,
|
|
||||||
quantity: 0,
|
|
||||||
unit_price: 0,
|
|
||||||
location_id: null,
|
|
||||||
batch_id: null,
|
|
||||||
reservation_id: null,
|
|
||||||
notes: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function WarehouseMovementTable<T extends { key: string }>({
|
|
||||||
items,
|
|
||||||
onChange,
|
|
||||||
mode,
|
|
||||||
defaultItem,
|
|
||||||
renderRow,
|
|
||||||
renderHeader,
|
|
||||||
}: WarehouseMovementTableProps<T>) {
|
|
||||||
const baseId = useId();
|
|
||||||
const counterRef = useRef(0);
|
|
||||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
|
||||||
|
|
||||||
const addItem = () => {
|
|
||||||
const factory =
|
|
||||||
defaultItem ??
|
|
||||||
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
|
|
||||||
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
|
|
||||||
};
|
|
||||||
const removeItem = (index: number) => {
|
|
||||||
onChange(items.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
const updateItem = (index: number, field: string, value: unknown) => {
|
|
||||||
const updated = [...items];
|
|
||||||
updated[index] = { ...updated[index], [field]: value };
|
|
||||||
onChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
// If a custom row renderer is supplied, the caller is fully in charge of
|
|
||||||
// the row layout (and typically the column headers too — see inventory mode).
|
|
||||||
if (renderRow) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="admin-warehouse-movement-table">
|
|
||||||
<table className="admin-table">
|
|
||||||
{renderHeader && (
|
|
||||||
<thead>
|
|
||||||
<tr>{renderHeader()}</tr>
|
|
||||||
</thead>
|
|
||||||
)}
|
|
||||||
<tbody>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<tr key={item.key}>
|
|
||||||
{renderRow(
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
(field, value) => updateItem(index, field, value),
|
|
||||||
() => removeItem(index),
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={addItem}
|
|
||||||
>
|
|
||||||
+ Přidat řádek
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
|
|
||||||
const movementItems = items as unknown as MovementItem[];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="admin-warehouse-movement-table">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="admin-warehouse-col-item">Položka</th>
|
|
||||||
{mode === "issue" && (
|
|
||||||
<th className="admin-warehouse-col-batch">Šarže</th>
|
|
||||||
)}
|
|
||||||
<th className="admin-warehouse-col-qty">Množství</th>
|
|
||||||
<th className="admin-warehouse-col-price">Cena/ks</th>
|
|
||||||
<th className="admin-warehouse-col-location">Lokace</th>
|
|
||||||
<th className="admin-warehouse-col-notes">Poznámka</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{movementItems.map((item, index) => (
|
|
||||||
<tr key={item.key}>
|
|
||||||
<td className="admin-warehouse-col-item">
|
|
||||||
<ItemPicker
|
|
||||||
value={item.item_id}
|
|
||||||
itemName={item.item_name}
|
|
||||||
onChange={(id) => updateItem(index, "item_id", id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{mode === "issue" && (
|
|
||||||
<td className="admin-warehouse-col-batch">
|
|
||||||
<BatchPicker
|
|
||||||
itemId={item.item_id}
|
|
||||||
value={item.batch_id}
|
|
||||||
onChange={(batchId, unitPrice) => {
|
|
||||||
updateItem(index, "batch_id", batchId);
|
|
||||||
updateItem(index, "unit_price", unitPrice);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="admin-warehouse-col-qty">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="admin-form-input"
|
|
||||||
value={item.quantity || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(
|
|
||||||
index,
|
|
||||||
"quantity",
|
|
||||||
parseDecimal(e.target.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
min="0"
|
|
||||||
step="0.001"
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]+([\.,][0-9]+)?"
|
|
||||||
aria-label={`Množství, řádek ${index + 1}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="admin-warehouse-col-price">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="admin-form-input"
|
|
||||||
value={item.unit_price || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(
|
|
||||||
index,
|
|
||||||
"unit_price",
|
|
||||||
parseDecimal(e.target.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
disabled={mode === "issue"}
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]+([\.,][0-9]+)?"
|
|
||||||
aria-label={`Cena za kus, řádek ${index + 1}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="admin-warehouse-col-location">
|
|
||||||
<LocationSelect
|
|
||||||
value={item.location_id}
|
|
||||||
onChange={(id) => updateItem(index, "location_id", id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="admin-warehouse-col-notes">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="admin-form-input"
|
|
||||||
value={item.notes ?? ""}
|
|
||||||
onChange={(e) => updateItem(index, "notes", e.target.value)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
onClick={() => removeItem(index)}
|
|
||||||
title="Odebrat řádek"
|
|
||||||
aria-label="Odebrat řádek"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={addItem}
|
|
||||||
>
|
|
||||||
+ Přidat řádek
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Dashboard
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
/* Dashboard layout */
|
|
||||||
.dash {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash .admin-page-header {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quick actions */
|
|
||||||
.dash-quick-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-quick-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-quick-btn:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-quick-btn-success {
|
|
||||||
background: var(--success-soft);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
.dash-quick-btn-info {
|
|
||||||
background: var(--info-soft);
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
.dash-quick-btn-warning {
|
|
||||||
background: var(--warning-soft);
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
.dash-quick-btn-danger {
|
|
||||||
background: var(--danger-soft);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-quick-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
filter: brightness(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .dash-quick-btn-success {
|
|
||||||
background: var(--success);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
[data-theme="light"] .dash-quick-btn-info {
|
|
||||||
background: var(--info);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
[data-theme="light"] .dash-quick-btn-warning {
|
|
||||||
background: var(--warning);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
[data-theme="light"] .dash-quick-btn-danger {
|
|
||||||
background: var(--danger);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main content 3-col grid */
|
|
||||||
.dash-main-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.4fr 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card link */
|
|
||||||
.dash-card-link {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--accent-color);
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-card-link:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Activity rows */
|
|
||||||
.dash-activity-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-row:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-icon {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-icon.success {
|
|
||||||
background: var(--success-soft);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
.dash-activity-icon.info {
|
|
||||||
background: var(--info-soft);
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
.dash-activity-icon.warning {
|
|
||||||
background: var(--warning-soft);
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
.dash-activity-icon.danger {
|
|
||||||
background: var(--danger-soft);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
.dash-activity-icon.accent {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.dash-activity-icon.muted {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-text {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-sub {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-activity-time {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Presence rows */
|
|
||||||
.dash-presence-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-avatar {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-avatar.dash-status-in {
|
|
||||||
background: var(--success-soft);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
.dash-presence-avatar.dash-status-away {
|
|
||||||
background: var(--warning-soft);
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
.dash-presence-avatar.dash-status-out {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.dash-presence-avatar.dash-status-leave {
|
|
||||||
background: var(--info-soft);
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-status-dot.dash-status-in {
|
|
||||||
background: var(--success);
|
|
||||||
}
|
|
||||||
.dash-status-dot.dash-status-away {
|
|
||||||
background: var(--warning);
|
|
||||||
}
|
|
||||||
.dash-status-dot.dash-status-out {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
.dash-status-dot.dash-status-leave {
|
|
||||||
background: var(--info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-label.dash-status-in {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
.dash-presence-label.dash-status-away {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
.dash-presence-label.dash-status-out {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.dash-presence-label.dash-status-leave {
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-end {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-presence-time {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right column */
|
|
||||||
.dash-right-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Project rows */
|
|
||||||
.dash-project-row {
|
|
||||||
display: block;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-project-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-project-row:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-project-name {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-project-customer {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stat mini rows */
|
|
||||||
.dash-stat-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-stat-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty row */
|
|
||||||
.dash-empty-row {
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bottom stacked layout (profile + sessions) */
|
|
||||||
.dash-bottom {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash .admin-card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Profile grid inside account card */
|
|
||||||
.dash-profile-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-profile-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-profile-label {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-profile-value {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Responsive
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.dash-main-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-right-col {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.dash-quick-actions {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-main-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-right-col {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-bottom {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.dash-quick-actions {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
.dash-profile-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Sessions / Devices
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.dash-sessions-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-item:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-item-current {
|
|
||||||
background: var(--row-current);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-item-current:hover {
|
|
||||||
background: var(--row-current-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-item-current .dash-session-icon {
|
|
||||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-device {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-meta {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-meta-separator {
|
|
||||||
color: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-actions {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.dash-session-item {
|
|
||||||
padding: 1rem;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-device {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-session-meta {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
React DatePicker Overrides
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.react-datepicker-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-popper {
|
|
||||||
z-index: 100 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent flash at top-left before popper calculates position */
|
|
||||||
#datepicker-portal .react-datepicker-popper {
|
|
||||||
opacity: 0;
|
|
||||||
animation: dp-fade-in 0.01s forwards 0.02s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dp-fade-in {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker {
|
|
||||||
font-family: inherit !important;
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
border: 1px solid var(--border-color) !important;
|
|
||||||
border-radius: var(--border-radius-sm) !important;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
font-size: 0.875rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__triangle {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.react-datepicker__header {
|
|
||||||
background-color: var(--bg-tertiary) !important;
|
|
||||||
border-bottom: 1px solid var(--border-color) !important;
|
|
||||||
padding-top: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__current-month,
|
|
||||||
.react-datepicker-time__header {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-name {
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Days */
|
|
||||||
.react-datepicker__day {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition:
|
|
||||||
background 0.15s,
|
|
||||||
color 0.15s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day:hover {
|
|
||||||
background-color: var(--accent-light) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--selected,
|
|
||||||
.react-datepicker__day--keyboard-selected {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--today {
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--outside-month {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--disabled {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
opacity: 0.3 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation arrows */
|
|
||||||
.react-datepicker__navigation {
|
|
||||||
top: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation-icon::before {
|
|
||||||
border-color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation:hover *::before {
|
|
||||||
border-color: var(--accent-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Year dropdown */
|
|
||||||
.react-datepicker__year-dropdown,
|
|
||||||
.react-datepicker__month-dropdown,
|
|
||||||
.react-datepicker__year-read-view,
|
|
||||||
.react-datepicker__month-read-view {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time picker */
|
|
||||||
.react-datepicker__time-container {
|
|
||||||
border-left: 1px solid var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container .react-datepicker__time {
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box
|
|
||||||
ul.react-datepicker__time-list
|
|
||||||
li.react-datepicker__time-list-item {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
transition: background 0.15s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box
|
|
||||||
ul.react-datepicker__time-list
|
|
||||||
li.react-datepicker__time-list-item:hover {
|
|
||||||
background-color: var(--accent-light) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__time-container
|
|
||||||
.react-datepicker__time
|
|
||||||
.react-datepicker__time-box
|
|
||||||
ul.react-datepicker__time-list
|
|
||||||
li.react-datepicker__time-list-item--selected {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Month picker */
|
|
||||||
.react-datepicker__monthPicker {
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-year-header {
|
|
||||||
background-color: var(--bg-tertiary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
border-bottom: 1px solid var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-wrapper {
|
|
||||||
background-color: var(--bg-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
padding: 0.5rem !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: background 0.15s !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text:hover {
|
|
||||||
background-color: var(--accent-light) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text--keyboard-selected,
|
|
||||||
.react-datepicker__month-text--selected {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-text--today {
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input */
|
|
||||||
.react-datepicker__input-container input {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__close-icon::after {
|
|
||||||
background-color: var(--accent-color) !important;
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
File Manager
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.fm-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-full-path {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
user-select: all;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-segment {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-sep {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin: 0 4px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-btn:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-breadcrumb-btn.active {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-new-folder {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-new-folder .admin-form-input {
|
|
||||||
max-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-content {
|
|
||||||
position: relative;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-content.fm-drag-over {
|
|
||||||
border: 2px dashed var(--accent-color);
|
|
||||||
background: var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-dropzone-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
z-index: 5;
|
|
||||||
color: var(--accent-color);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 2.5rem 1rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-folder-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--accent-color);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-folder-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-item-count {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-file-name {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-meta {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-name-cell {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fm-symlink-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Forms
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition:
|
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input:focus {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"],
|
|
||||||
.admin-form-input[type="time"],
|
|
||||||
.admin-form-input[type="month"],
|
|
||||||
.admin-form-input[type="number"] {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
text-align: left;
|
|
||||||
height: 36px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="number"]::-webkit-inner-spin-button,
|
|
||||||
.admin-form-input[type="number"]::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"]::-webkit-date-and-time-value,
|
|
||||||
.admin-form-input[type="time"]::-webkit-date-and-time-value,
|
|
||||||
.admin-form-input[type="month"]::-webkit-date-and-time-value {
|
|
||||||
text-align: left;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"]::-webkit-datetime-edit,
|
|
||||||
.admin-form-input[type="time"]::-webkit-datetime-edit,
|
|
||||||
.admin-form-input[type="month"]::-webkit-datetime-edit {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-input[type="date"]::-webkit-calendar-picker-indicator,
|
|
||||||
.admin-form-input[type="time"]::-webkit-calendar-picker-indicator,
|
|
||||||
.admin-form-input[type="month"]::-webkit-calendar-picker-indicator {
|
|
||||||
filter: var(--calendar-icon-filter, none);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select */
|
|
||||||
.admin-form-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition:
|
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
min-height: 36px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
background-image: var(--select-arrow);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 0.75rem center;
|
|
||||||
padding-right: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-select:focus {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-select option {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Textarea */
|
|
||||||
.admin-form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition:
|
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
resize: vertical;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-textarea:focus {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox */
|
|
||||||
.admin-form-checkbox {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input + span::before {
|
|
||||||
content: "";
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin-right: 8px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
vertical-align: middle;
|
|
||||||
transition:
|
|
||||||
border-color var(--transition),
|
|
||||||
box-shadow var(--transition),
|
|
||||||
background var(--transition);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:checked + span::before {
|
|
||||||
background: var(--accent-color);
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
|
||||||
background-size: 12px;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:focus + span::before {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox:hover
|
|
||||||
input:not(:checked):not(:disabled):not(:indeterminate)
|
|
||||||
+ span::before {
|
|
||||||
border-color: var(--border-color-hover);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:indeterminate + span::before {
|
|
||||||
background: var(--accent-color);
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='12' x2='18' y2='12'%3E%3C/line%3E%3C/svg%3E");
|
|
||||||
background-size: 12px;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input:disabled + span::before {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox:has(input:disabled) {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reorderable List */
|
|
||||||
.admin-reorder-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-label.accent {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows .admin-btn-icon {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows .admin-btn-icon:hover:not(:disabled) {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-reorder-arrows .admin-btn-icon:disabled {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Rows (Grid Layouts) */
|
|
||||||
.admin-form-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-row-3 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-row-4 {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: 1.2fr 1fr 1fr 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-form-row-4 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.admin-form-row,
|
|
||||||
.admin-form-row-3 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-form-row-4,
|
|
||||||
.admin-form-row-5 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Utilities */
|
|
||||||
.admin-form-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Required field indicator */
|
|
||||||
.admin-form-label.required::after {
|
|
||||||
content: " *";
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline field errors */
|
|
||||||
.admin-form-group.has-error .admin-form-input,
|
|
||||||
.admin-form-group.has-error .admin-form-select,
|
|
||||||
.admin-form-group.has-error .admin-form-textarea {
|
|
||||||
border-color: var(--danger);
|
|
||||||
box-shadow: 0 0 0 3px var(--danger-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-group.has-error .admin-form-label {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-error {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--danger);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Touch targets - min 44px on mobile */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-form-input,
|
|
||||||
.admin-form-select,
|
|
||||||
.admin-form-textarea {
|
|
||||||
min-height: 44px;
|
|
||||||
font-size: 16px; /* prevent auto-zoom on iOS */
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-checkbox input + span::before {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-label {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Customer Selector
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-customer-select {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-selected {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
background: var(--input-bg);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-selected span {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-selected .admin-btn-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
margin-right: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
|
||||||
max-height: 260px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 99px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item {
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item:hover {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item div:first-child {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-item div:last-child {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-customer-dropdown-empty {
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Invoice Status Badges
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-badge-invoice-issued {
|
|
||||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-badge-invoice-paid {
|
|
||||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-badge-invoice-overdue {
|
|
||||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Invoice Month Navigation
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.invoice-month-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invoice-month-nav span {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-width: 120px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invoice-month-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invoice-month-btn:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invoice-month-btn:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Received Invoices - Upload Modal
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.received-upload-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-card {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-file-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-file-name {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-file-size {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-card-fields {
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.invoice-month-btn {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-row {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.received-upload-file-name {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Layout
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-layout {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100dvh;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-layout {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Sidebar
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
/* -- Theme variables for sidebar -- */
|
|
||||||
[data-theme="dark"] .admin-sidebar {
|
|
||||||
--sb-bg: #141414;
|
|
||||||
--sb-border: #2a2a2a;
|
|
||||||
--sb-text: #a0a0a0;
|
|
||||||
--sb-text-hover: #ddd;
|
|
||||||
--sb-hover-bg: #1f1f1f;
|
|
||||||
--sb-active-bg: #ffffff;
|
|
||||||
--sb-active-text: #141414;
|
|
||||||
--sb-label: #444;
|
|
||||||
--sb-muted: #555;
|
|
||||||
--sb-scrollbar: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .admin-sidebar {
|
|
||||||
--sb-bg: #ffffff;
|
|
||||||
--sb-border: #e8e6e1;
|
|
||||||
--sb-text: #7c7c84;
|
|
||||||
--sb-text-hover: #1a1a1a;
|
|
||||||
--sb-hover-bg: #f5f4f2;
|
|
||||||
--sb-active-bg: #141414;
|
|
||||||
--sb-active-text: #ffffff;
|
|
||||||
--sb-label: #a0a0a0;
|
|
||||||
--sb-muted: #a0a0a0;
|
|
||||||
--sb-scrollbar: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
height: 100dvh;
|
|
||||||
z-index: 50;
|
|
||||||
background: var(--sb-bg);
|
|
||||||
border-right: 1px solid var(--sb-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
padding-left: env(safe-area-inset-left, 0px);
|
|
||||||
padding-right: env(safe-area-inset-right, 0px);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
visibility: hidden;
|
|
||||||
transition:
|
|
||||||
transform 0.3s ease,
|
|
||||||
visibility 0.3s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
visibility: visible;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-sidebar {
|
|
||||||
right: auto;
|
|
||||||
width: 220px;
|
|
||||||
height: 100%;
|
|
||||||
transform: none;
|
|
||||||
visibility: visible;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .admin-sidebar {
|
|
||||||
box-shadow:
|
|
||||||
1px 0 0 0 var(--sb-border),
|
|
||||||
4px 0 16px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Overlay (mobile) */
|
|
||||||
.admin-sidebar-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 49;
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-overlay.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-sidebar-overlay {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Header */
|
|
||||||
.admin-sidebar-header {
|
|
||||||
padding: 0 18px;
|
|
||||||
height: 73px;
|
|
||||||
border-bottom: 1px solid var(--sb-border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-logo {
|
|
||||||
height: 28px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-close {
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--sb-text);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-close:hover {
|
|
||||||
background: var(--sb-hover-bg);
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-sidebar-close {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Navigation */
|
|
||||||
.admin-sidebar-nav {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-nav::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-nav::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar-nav::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--sb-scrollbar);
|
|
||||||
border-radius: 99px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nav Section */
|
|
||||||
.admin-nav-section {
|
|
||||||
padding: 14px 10px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--sb-label);
|
|
||||||
padding: 0 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nav Item */
|
|
||||||
.admin-nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-radius: 7px;
|
|
||||||
color: var(--sb-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 450;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
text-decoration: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item:hover {
|
|
||||||
background: var(--sb-hover-bg);
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item.active {
|
|
||||||
background: var(--sb-active-bg);
|
|
||||||
color: var(--sb-active-text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item.active svg {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.admin-nav-item {
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 15px;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-item svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Footer */
|
|
||||||
.admin-sidebar-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
border-top: 1px solid var(--sb-border);
|
|
||||||
padding: 14px 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-details {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-name {
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-role {
|
|
||||||
color: var(--sb-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 10px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--sb-text);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn:hover {
|
|
||||||
background: var(--sb-hover-bg);
|
|
||||||
color: var(--sb-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-sidebar-footer {
|
|
||||||
padding: 10px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-chip {
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-logout-btn {
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Main Content Area
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-main {
|
|
||||||
margin-left: 220px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.admin-header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 30;
|
|
||||||
height: calc(73px + env(safe-area-inset-top, 0px));
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 1rem;
|
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-header {
|
|
||||||
left: 220px;
|
|
||||||
padding: 0 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-menu-btn {
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-menu-btn:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header-theme-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: var(--transition);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header-theme-btn:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-color: var(--border-color-hover);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-theme-icon {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
opacity: 0;
|
|
||||||
transform: rotate(180deg) scale(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-theme-icon.visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(0) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.admin-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-top: calc(73px + 1rem + env(safe-area-inset-top, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-content {
|
|
||||||
padding: 28px 32px;
|
|
||||||
padding-top: calc(73px + 28px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Page Headers
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-page-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.admin-page-header {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-subtitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.admin-page-actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-actions .admin-btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Grid System
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid > .admin-card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid-3 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.admin-grid-3 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-grid-3 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid-4 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.admin-grid-4 {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page header on mobile */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-page-title {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-page-subtitle {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
padding: 12px !important;
|
|
||||||
padding-top: calc(73px + 12px + env(safe-area-inset-top, 0px)) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid - single column on small mobile */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-grid-4 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Settings Grid
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-settings-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-settings-grid > .admin-card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.admin-settings-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -82,3 +82,12 @@ export const orderDetailOptions = (id: string | undefined) =>
|
|||||||
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const orderNextNumberOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["orders", "next-number"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ next_number?: string; number?: string }>(
|
||||||
|
"/api/admin/orders/next-number",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -109,3 +109,12 @@ export const projectFilesOptions = (projectId: number, path: string) =>
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const projectNextNumberOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["projects", "next-number"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ next_number?: string; number?: string }>(
|
||||||
|
"/api/admin/projects/next-number",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Login Page
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.admin-login {
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100dvh;
|
|
||||||
max-height: 100vh;
|
|
||||||
max-height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login .bg-orb {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(80px);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login .bg-orb-1 {
|
|
||||||
width: 400px;
|
|
||||||
height: 400px;
|
|
||||||
background: var(--orb-color-1);
|
|
||||||
top: 10%;
|
|
||||||
left: 20%;
|
|
||||||
animation: float 20s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login .bg-orb-2 {
|
|
||||||
width: 320px;
|
|
||||||
height: 320px;
|
|
||||||
background: var(--orb-color-2);
|
|
||||||
bottom: 20%;
|
|
||||||
right: 15%;
|
|
||||||
animation: float 25s ease-in-out infinite reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-theme-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: var(--transition);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-theme-btn:hover {
|
|
||||||
background: var(--glass-bg-solid);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--border-color-hover);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
max-height: calc(100vh - 2rem);
|
|
||||||
max-height: calc(100dvh - 2rem);
|
|
||||||
padding: 2rem;
|
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
box-shadow: var(--glass-shadow);
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.admin-login-card {
|
|
||||||
padding: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-logo {
|
|
||||||
height: 48px;
|
|
||||||
width: auto;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-2fa-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent-color);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-login-subtitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-back-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: var(--transition);
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-back-link:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Offers Module
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Items table */
|
|
||||||
.offers-items-table {
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table {
|
|
||||||
min-width: 700px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table thead th {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
padding: 8px 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table td {
|
|
||||||
vertical-align: top;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table tbody tr {
|
|
||||||
transition: background var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table tbody tr:hover {
|
|
||||||
background: var(--table-row-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table td .admin-form-input {
|
|
||||||
display: block;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Language badges */
|
|
||||||
.offers-lang-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 1.5rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
padding: 0 0.375rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
|
||||||
color: var(--info);
|
|
||||||
margin-right: 0.375rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-lang-badge-cz {
|
|
||||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* RichEditor (Quill) */
|
|
||||||
.admin-rich-editor {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .quill {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar */
|
|
||||||
.admin-rich-editor .ql-toolbar.ql-snow {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
border-radius: 0.5rem 0.5rem 0 0;
|
|
||||||
padding: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-toolbar .ql-formats {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar buttons */
|
|
||||||
.admin-rich-editor .ql-snow .ql-stroke {
|
|
||||||
stroke: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow .ql-fill {
|
|
||||||
fill: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-color: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow button:hover .ql-stroke,
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
|
|
||||||
stroke: var(--text-primary);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow button:hover .ql-fill,
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
|
|
||||||
fill: var(--text-primary);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow button:hover,
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active state */
|
|
||||||
.admin-rich-editor .ql-snow button.ql-active {
|
|
||||||
color: var(--accent-color);
|
|
||||||
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow button.ql-active .ql-stroke {
|
|
||||||
stroke: var(--accent-color);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow button.ql-active .ql-fill,
|
|
||||||
.admin-rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
|
|
||||||
fill: var(--accent-color);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-item.ql-selected {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label.ql-active {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
|
|
||||||
stroke: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdowns (font, size, color, align) */
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-options {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-item {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow .ql-picker-item:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Font picker */
|
|
||||||
.admin-rich-editor .ql-snow .ql-font .ql-picker-options {
|
|
||||||
min-width: 11rem;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Font labels - vysoka specificita kvuli quill.snow.css */
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="arial"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="arial"]::before {
|
|
||||||
content: "Arial" !important;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="tahoma"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="tahoma"]::before {
|
|
||||||
content: "Tahoma" !important;
|
|
||||||
font-family: Tahoma, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="verdana"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="verdana"]::before {
|
|
||||||
content: "Verdana" !important;
|
|
||||||
font-family: Verdana, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="georgia"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="georgia"]::before {
|
|
||||||
content: "Georgia" !important;
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
}
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-label[data-value="times-new-roman"]::before,
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-item[data-value="times-new-roman"]::before {
|
|
||||||
content: "Times New Roman" !important;
|
|
||||||
font-family: "Times New Roman", serif;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="courier-new"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="courier-new"]::before {
|
|
||||||
content: "Courier New" !important;
|
|
||||||
font-family: "Courier New", monospace;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="trebuchet-ms"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="trebuchet-ms"]::before {
|
|
||||||
content: "Trebuchet MS" !important;
|
|
||||||
font-family: "Trebuchet MS", sans-serif;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="impact"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="impact"]::before {
|
|
||||||
content: "Impact" !important;
|
|
||||||
font-family: Impact, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-label[data-value="comic-sans-ms"]::before,
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-item[data-value="comic-sans-ms"]::before {
|
|
||||||
content: "Comic Sans MS" !important;
|
|
||||||
font-family: "Comic Sans MS", cursive;
|
|
||||||
}
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-label[data-value="lucida-console"]::before,
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-item[data-value="lucida-console"]::before {
|
|
||||||
content: "Lucida Console" !important;
|
|
||||||
font-family: "Lucida Console", monospace;
|
|
||||||
}
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-label[data-value="palatino-linotype"]::before,
|
|
||||||
.ql-snow
|
|
||||||
.ql-picker.ql-font
|
|
||||||
.ql-picker-item[data-value="palatino-linotype"]::before {
|
|
||||||
content: "Palatino Linotype" !important;
|
|
||||||
font-family: "Palatino Linotype", serif;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="garamond"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="garamond"]::before {
|
|
||||||
content: "Garamond" !important;
|
|
||||||
font-family: Garamond, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Font classes */
|
|
||||||
.ql-font-arial {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-font-tahoma {
|
|
||||||
font-family: Tahoma, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-font-verdana {
|
|
||||||
font-family: Verdana, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-font-georgia {
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
}
|
|
||||||
.ql-font-times-new-roman {
|
|
||||||
font-family: "Times New Roman", serif;
|
|
||||||
}
|
|
||||||
.ql-font-courier-new {
|
|
||||||
font-family: "Courier New", monospace;
|
|
||||||
}
|
|
||||||
.ql-font-trebuchet-ms {
|
|
||||||
font-family: "Trebuchet MS", sans-serif;
|
|
||||||
}
|
|
||||||
.ql-font-impact {
|
|
||||||
font-family: Impact, sans-serif;
|
|
||||||
}
|
|
||||||
.ql-font-comic-sans-ms {
|
|
||||||
font-family: "Comic Sans MS", cursive;
|
|
||||||
}
|
|
||||||
.ql-font-lucida-console {
|
|
||||||
font-family: "Lucida Console", monospace;
|
|
||||||
}
|
|
||||||
.ql-font-palatino-linotype {
|
|
||||||
font-family: "Palatino Linotype", serif;
|
|
||||||
}
|
|
||||||
.ql-font-garamond {
|
|
||||||
font-family: Garamond, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Size picker */
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="8px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="8px"]::before {
|
|
||||||
content: "8px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="9px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="9px"]::before {
|
|
||||||
content: "9px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before {
|
|
||||||
content: "10px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="11px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="11px"]::before {
|
|
||||||
content: "11px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before {
|
|
||||||
content: "12px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before {
|
|
||||||
content: "14px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
|
|
||||||
content: "16px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before {
|
|
||||||
content: "18px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before {
|
|
||||||
content: "20px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before {
|
|
||||||
content: "24px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
|
|
||||||
content: "28px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before {
|
|
||||||
content: "32px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="36px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="36px"]::before {
|
|
||||||
content: "36px" !important;
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="48px"]::before,
|
|
||||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="48px"]::before {
|
|
||||||
content: "48px" !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editor area */
|
|
||||||
.admin-rich-editor .ql-container.ql-snow {
|
|
||||||
border: none;
|
|
||||||
border-radius: 0 0 0.5rem 0.5rem;
|
|
||||||
font-family: Tahoma, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-editor {
|
|
||||||
min-height: var(--re-min-height, 120px);
|
|
||||||
padding: 0.75rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family: Tahoma, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-editor.ql-blank::before {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lists inside editor */
|
|
||||||
.admin-rich-editor .ql-editor ul,
|
|
||||||
.admin-rich-editor .ql-editor ol {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color picker */
|
|
||||||
.admin-rich-editor
|
|
||||||
.ql-snow
|
|
||||||
.ql-color-picker
|
|
||||||
.ql-picker-options[aria-hidden="false"] {
|
|
||||||
width: 176px;
|
|
||||||
padding: 0.375rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-color-picker .ql-picker-item {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip (link editor) */
|
|
||||||
.admin-rich-editor .ql-snow .ql-tooltip {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-tooltip input[type="text"] {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-editor .ql-snow .ql-tooltip a {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Read-only rendered rich text (Quill HTML output) */
|
|
||||||
.admin-rich-text-view {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-text-view ul,
|
|
||||||
.admin-rich-text-view ol {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
margin: 0.25rem 0 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-text-view li {
|
|
||||||
margin-bottom: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-text-view a {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-text-view strong,
|
|
||||||
.admin-rich-text-view b {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-text-view br + b,
|
|
||||||
.admin-rich-text-view br + strong {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-rich-text-view > br:first-child,
|
|
||||||
.admin-rich-text-view ul + br,
|
|
||||||
.admin-rich-text-view ol + br {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.offers-items-table .admin-table td .admin-form-input {
|
|
||||||
font-size: 16px;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Give the description column more room by shrinking numeric columns */
|
|
||||||
.offers-col-qty {
|
|
||||||
width: 4rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-unit {
|
|
||||||
width: 4rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-price {
|
|
||||||
width: 5.5rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-included {
|
|
||||||
width: 3.5rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-total {
|
|
||||||
width: 5.5rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-del {
|
|
||||||
width: 2.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.offers-items-table {
|
|
||||||
margin: 0 -1rem;
|
|
||||||
width: calc(100% + 2rem);
|
|
||||||
border-radius: 0;
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table {
|
|
||||||
min-width: 700px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table td {
|
|
||||||
padding: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-items-table .admin-table th {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Further reduce numeric columns on very small screens */
|
|
||||||
.offers-col-qty {
|
|
||||||
width: 3.5rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-unit {
|
|
||||||
width: 3.5rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-price {
|
|
||||||
width: 5rem !important;
|
|
||||||
}
|
|
||||||
.offers-col-total {
|
|
||||||
width: 5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Offer draft row in table */
|
|
||||||
.offers-draft-row {
|
|
||||||
background: var(--row-draft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-draft-row-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: var(--warning);
|
|
||||||
background: color-mix(in srgb, var(--warning) 14%, transparent);
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
border-radius: 99px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expired offer without order */
|
|
||||||
.offers-expired-row {
|
|
||||||
background: var(--row-expired);
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-expired-row td {
|
|
||||||
color: var(--danger) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-expired-row a {
|
|
||||||
color: var(--danger) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Invalidated offer */
|
|
||||||
.offers-invalidated-row {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-invalidated-row td {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offers-invalidated-row a {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Completed offer */
|
|
||||||
.offers-completed-row {
|
|
||||||
background: var(
|
|
||||||
--row-completed,
|
|
||||||
color-mix(in srgb, var(--success) 8%, transparent)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Read-only form (invalidated offer detail) */
|
|
||||||
.offers-readonly input[readonly],
|
|
||||||
.offers-readonly select:disabled {
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Offer draft indicator */
|
|
||||||
/* Filters */
|
|
||||||
.admin-filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-filter-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-filter-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-filter-group .admin-tabs {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-filter-group .admin-form-select {
|
|
||||||
min-width: 10rem;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,29 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import BulkAttendanceModal from "../components/BulkAttendanceModal";
|
import BulkAttendanceModal from "../components/BulkAttendanceModal";
|
||||||
import ShiftFormModal from "../components/ShiftFormModal";
|
import ShiftFormModal from "../components/ShiftFormModal";
|
||||||
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
FilterBar,
|
||||||
|
LoadingState,
|
||||||
|
MonthField,
|
||||||
|
PageEnter,
|
||||||
|
Select,
|
||||||
|
StatCard,
|
||||||
|
StatusChip,
|
||||||
|
ProgressBar,
|
||||||
|
type ProgressColor,
|
||||||
|
type StatCardColor,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
interface UserTotalData {
|
interface UserTotalData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,23 +48,57 @@ interface UserTotalData {
|
|||||||
worked_holiday_hours?: number;
|
worked_holiday_hours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFundBarBackground(data: UserTotalData) {
|
/** Fond "used" total mirrors the Fond footer line below:
|
||||||
// fondUsed mirrors the Fond "used" line in the IIFE below:
|
* real_worked + vacation + worked_holiday + free_holiday. */
|
||||||
// real_worked + vacation + worked_holiday + free_holiday
|
function getFondUsed(data: UserTotalData): number {
|
||||||
// (delta is fondUsed - fund; 0 means exactly at the fund).
|
|
||||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||||
const fondUsed =
|
return (
|
||||||
realWorked +
|
realWorked +
|
||||||
(data.vacation_hours ?? 0) +
|
(data.vacation_hours ?? 0) +
|
||||||
(data.worked_holiday_hours ?? 0) +
|
(data.worked_holiday_hours ?? 0) +
|
||||||
(data.svatek ?? 0);
|
(data.svatek ?? 0)
|
||||||
const fundVal = data.fund ?? 0;
|
);
|
||||||
const delta = fondUsed - fundVal;
|
|
||||||
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
|
|
||||||
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
|
|
||||||
return "var(--gradient)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
|
||||||
|
* the legacy getFundBarBackground conditional: over fund → warning, exactly
|
||||||
|
* at fund → success, under fund → primary (was the accent gradient). */
|
||||||
|
function getFundBarColor(data: UserTotalData): ProgressColor {
|
||||||
|
const delta = getFondUsed(data) - (data.fund ?? 0);
|
||||||
|
if (delta > 0) return "warning";
|
||||||
|
if (delta >= 0) return "success";
|
||||||
|
return "primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrintIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 6 2 18 2 18 9" />
|
||||||
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||||
|
<rect x="6" y="14" width="12" height="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AddIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AttendanceAdmin() {
|
export default function AttendanceAdmin() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -111,279 +158,238 @@ export default function AttendanceAdmin() {
|
|||||||
Object.keys(data.user_totals).length === 0;
|
Object.keys(data.user_totals).length === 0;
|
||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasTotals = Object.keys(data.user_totals).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-page-header"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "flex",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
alignItems: "flex-start",
|
||||||
transition={{ duration: 0.25 }}
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<Typography variant="h4">Správa docházky</Typography>
|
||||||
<h1 className="admin-page-title">Správa docházky</h1>
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={PrintIcon}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
title="Tisk docházky"
|
title="Tisk docházky"
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ marginRight: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<polyline points="6 9 6 2 18 2 18 9" />
|
|
||||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
|
||||||
<rect x="6" y="14" width="12" height="8" />
|
|
||||||
</svg>
|
|
||||||
Tisk
|
Tisk
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
|
||||||
onClick={openBulkModal}
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
>
|
|
||||||
Vyplnit měsíc
|
Vyplnit měsíc
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button startIcon={AddIcon} onClick={openCreateModal}>
|
||||||
onClick={openCreateModal}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Přidat záznam
|
Přidat záznam
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card mb-6"
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Box>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
>
|
<Select
|
||||||
<div className="admin-card-body">
|
value={filterUserId}
|
||||||
<div className="admin-form-row">
|
onChange={setFilterUserId}
|
||||||
<FormField label="Měsíc">
|
options={[
|
||||||
<AdminDatePicker
|
{ value: "", label: "Všichni zaměstnanci" },
|
||||||
mode="month"
|
...data.users.map((user) => ({
|
||||||
value={month}
|
value: String(user.id),
|
||||||
onChange={(val: string) => setMonth(val)}
|
label: user.name,
|
||||||
/>
|
})),
|
||||||
</FormField>
|
]}
|
||||||
<FormField label="Zaměstnanec">
|
/>
|
||||||
<select
|
</Box>
|
||||||
value={filterUserId}
|
</FilterBar>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* User Totals */}
|
{/* User Totals (KPI cards) */}
|
||||||
{Object.keys(data.user_totals).length > 0 && (
|
{hasTotals && (
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-grid admin-grid-3 mb-6"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: {
|
||||||
transition={{ duration: 0.25, delay: 0.09 }}
|
xs: "1fr",
|
||||||
|
sm: "repeat(2, 1fr)",
|
||||||
|
lg: "repeat(3, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||||
const ut = userData as UserTotalData;
|
const ut = userData as UserTotalData;
|
||||||
|
const balance = data.leave_balances[uid];
|
||||||
|
const statColor: StatCardColor = ut.working ? "success" : "default";
|
||||||
return (
|
return (
|
||||||
<div key={uid} className="admin-card" style={{ display: "flex" }}>
|
<StatCard
|
||||||
<div
|
key={uid}
|
||||||
className="admin-card-body"
|
label={ut.name}
|
||||||
style={{
|
value={
|
||||||
display: "flex",
|
<Box
|
||||||
flexDirection: "column",
|
component="span"
|
||||||
flex: 1,
|
sx={{
|
||||||
}}
|
display: "inline-flex",
|
||||||
>
|
alignItems: "center",
|
||||||
<div className="flex-row gap-2 mb-2">
|
gap: 1,
|
||||||
<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",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ut.vacation_hours > 0 && (
|
{formatMinutes(ut.minutes)}
|
||||||
<span className="attendance-leave-badge badge-vacation">
|
<StatusChip
|
||||||
Dov: {ut.vacation_hours}h
|
color={ut.working ? "success" : "default"}
|
||||||
</span>
|
label={ut.working ? "✓" : "✗"}
|
||||||
)}
|
/>
|
||||||
{ut.sick_hours > 0 && (
|
</Box>
|
||||||
<span className="attendance-leave-badge badge-sick">
|
}
|
||||||
Nem: {ut.sick_hours}h
|
color={statColor}
|
||||||
</span>
|
footer={
|
||||||
)}
|
<Box>
|
||||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
{/* Leave-type badges */}
|
||||||
<span className="attendance-leave-badge badge-holiday">
|
<Box
|
||||||
Sv: {Math.round(ut.svatek)}h
|
sx={{
|
||||||
</span>
|
display: "flex",
|
||||||
)}
|
flexWrap: "wrap",
|
||||||
{ut.unpaid_hours > 0 && (
|
gap: 0.5,
|
||||||
<span className="attendance-leave-badge badge-unpaid">
|
mb: 1,
|
||||||
Nep: {ut.unpaid_hours}h
|
minHeight: "1.75rem",
|
||||||
</span>
|
alignItems: "center",
|
||||||
)}
|
}}
|
||||||
</div>
|
>
|
||||||
{ut.fund !== null &&
|
{ut.vacation_hours > 0 && (
|
||||||
(() => {
|
<StatusChip
|
||||||
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
|
color="info"
|
||||||
// (everything the user actually got paid for this month:
|
label={`Dov: ${ut.vacation_hours}h`}
|
||||||
// raw worked time with sub-day remainders, plus
|
/>
|
||||||
// vacation days, plus hours worked on holidays, plus
|
)}
|
||||||
// the 8h per unworked holiday from the fund).
|
{ut.sick_hours > 0 && (
|
||||||
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
|
<StatusChip
|
||||||
const fondUsed =
|
color="error"
|
||||||
realWorked +
|
label={`Nem: ${ut.sick_hours}h`}
|
||||||
(ut.vacation_hours ?? 0) +
|
/>
|
||||||
(ut.worked_holiday_hours ?? 0) +
|
)}
|
||||||
(ut.svatek ?? 0);
|
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||||
const fundVal = ut.fund ?? 0;
|
<StatusChip
|
||||||
const delta = fondUsed - fundVal;
|
color="warning"
|
||||||
return (
|
label={`Sv: ${Math.round(ut.svatek)}h`}
|
||||||
<div
|
/>
|
||||||
className="kpi-card-footer"
|
)}
|
||||||
style={{
|
{ut.unpaid_hours > 0 && (
|
||||||
marginTop: "auto",
|
<StatusChip
|
||||||
paddingTop: "0.75rem",
|
color="default"
|
||||||
borderTop: "1px solid var(--border-color)",
|
label={`Nep: ${ut.unpaid_hours}h`}
|
||||||
}}
|
/>
|
||||||
>
|
)}
|
||||||
<div
|
</Box>
|
||||||
className="text-secondary"
|
|
||||||
style={{
|
{/* Fond usage */}
|
||||||
display: "flex",
|
{ut.fund !== null &&
|
||||||
justifyContent: "space-between",
|
(() => {
|
||||||
alignItems: "center",
|
// Fond "used" = real_worked + vacation +
|
||||||
fontSize: "0.8rem",
|
// worked_holiday + free_holiday (everything the user
|
||||||
}}
|
// actually got paid for this month).
|
||||||
>
|
const fondUsed = getFondUsed(ut);
|
||||||
<span>
|
const fundVal = ut.fund ?? 0;
|
||||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
const delta = fondUsed - fundVal;
|
||||||
{formatHoursDecimal(fundVal * 60)}h
|
const pct = Math.min(
|
||||||
</span>
|
100,
|
||||||
{delta > 0 && (
|
(fondUsed / (ut.fund || 1)) * 100,
|
||||||
<span className="text-warning fw-600">
|
);
|
||||||
+{formatHoursDecimal(delta * 60)}h
|
return (
|
||||||
</span>
|
<Box>
|
||||||
)}
|
<Box
|
||||||
{delta <= 0 && delta < 0 && (
|
sx={{
|
||||||
<span className="text-danger fw-600">
|
display: "flex",
|
||||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
justifyContent: "space-between",
|
||||||
</span>
|
alignItems: "center",
|
||||||
)}
|
mb: 0.5,
|
||||||
</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",
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</Box>
|
||||||
</div>
|
);
|
||||||
);
|
})()}
|
||||||
})()}
|
|
||||||
<div
|
{/* Remaining vacation */}
|
||||||
className="text-secondary"
|
<Box sx={{ mt: 1.5 }}>
|
||||||
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
|
{balance ? (
|
||||||
>
|
<Typography variant="caption" color="text.secondary">
|
||||||
{data.leave_balances[uid] ? (
|
Zbývá dovolené:{" "}
|
||||||
<>
|
{balance.vacation_remaining.toFixed(1)}h /{" "}
|
||||||
Zbývá dovolené:{" "}
|
{balance.vacation_total}h
|
||||||
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
|
</Typography>
|
||||||
h / {data.leave_balances[uid].vacation_total}h
|
) : (
|
||||||
</>
|
<Typography
|
||||||
) : (
|
variant="caption"
|
||||||
<span style={{ visibility: "hidden" }}>—</span>
|
sx={{ visibility: "hidden" }}
|
||||||
)}
|
>
|
||||||
</div>
|
—
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Records Table */}
|
{/* Records Table */}
|
||||||
<motion.div
|
<Card>
|
||||||
className="admin-card"
|
<AttendanceShiftTable
|
||||||
initial={{ opacity: 0, y: 12 }}
|
records={data.records}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
onEdit={openEditModal}
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||||
>
|
/>
|
||||||
<div className="admin-card-body">
|
</Card>
|
||||||
<AttendanceShiftTable
|
|
||||||
records={data.records}
|
|
||||||
onEdit={openEditModal}
|
|
||||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<BulkAttendanceModal
|
<BulkAttendanceModal
|
||||||
@@ -435,7 +441,7 @@ export default function AttendanceAdmin() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -444,6 +450,6 @@ export default function AttendanceAdmin() {
|
|||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
import { userListOptions } from "../lib/queries/users";
|
import { userListOptions } from "../lib/queries/users";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { todayLocalStr } from "../utils/formatters";
|
import { todayLocalStr } from "../utils/formatters";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DateField,
|
||||||
|
Field,
|
||||||
|
LoadingState,
|
||||||
|
PageEnter,
|
||||||
|
PageHeader,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
TimeField,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface CreateForm {
|
interface CreateForm {
|
||||||
@@ -102,223 +114,218 @@ export default function AttendanceCreate() {
|
|||||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Přidat záznam docházky"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
actions={
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.25 }}
|
variant="outlined"
|
||||||
>
|
color="inherit"
|
||||||
<div>
|
component={RouterLink}
|
||||||
<h1 className="admin-page-title">Přidat záznam docházky</h1>
|
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
<Link
|
|
||||||
to="/attendance/admin"
|
to="/attendance/admin"
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
>
|
>
|
||||||
← Zpět na správu
|
← Zpět na správu
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</motion.div>
|
/>
|
||||||
|
|
||||||
<motion.div
|
<Card sx={{ maxWidth: 640 }}>
|
||||||
className="admin-card"
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
style={{ maxWidth: "600px" }}
|
{/* Employee + Shift date */}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Box
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sx={{
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
display: "grid",
|
||||||
>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<div className="admin-card-body">
|
gap: 2,
|
||||||
<form onSubmit={handleSubmit} className="admin-form">
|
}}
|
||||||
<div className="admin-form-row">
|
>
|
||||||
<FormField label="Zaměstnanec" required>
|
<Field label="Zaměstnanec" required>
|
||||||
<select
|
<Select
|
||||||
value={form.user_id}
|
value={form.user_id}
|
||||||
onChange={(e) =>
|
onChange={(val) => setForm({ ...form, user_id: val })}
|
||||||
setForm({ ...form, user_id: e.target.value })
|
options={[
|
||||||
}
|
{ value: "", label: "Vyberte zaměstnance" },
|
||||||
className="admin-form-select"
|
...users.map((u) => ({
|
||||||
required
|
value: String(u.id),
|
||||||
>
|
label: u.name,
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
<Field label="Datum směny" required>
|
||||||
|
<DateField
|
||||||
|
value={form.shift_date}
|
||||||
|
onChange={(val) => handleShiftDateChange(val)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-actions">
|
{/* Record type */}
|
||||||
<Link
|
<Field label="Typ záznamu" required>
|
||||||
to="/attendance/admin"
|
<Select
|
||||||
className="admin-btn admin-btn-secondary"
|
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
|
<Field label="Příchod - datum">
|
||||||
</Link>
|
<DateField
|
||||||
<button
|
value={form.arrival_date}
|
||||||
type="submit"
|
onChange={(val) => setForm({ ...form, arrival_date: val })}
|
||||||
disabled={submitting}
|
/>
|
||||||
className="admin-btn admin-btn-primary"
|
</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"}
|
<Field label="Začátek pauzy - datum">
|
||||||
</button>
|
<DateField
|
||||||
</div>
|
value={form.break_start_date}
|
||||||
</form>
|
onChange={(val) =>
|
||||||
</div>
|
setForm({ ...form, break_start_date: val })
|
||||||
</motion.div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
</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,9 +1,9 @@
|
|||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useMemo, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
import {
|
import {
|
||||||
attendanceHistoryOptions,
|
attendanceHistoryOptions,
|
||||||
@@ -25,7 +25,20 @@ import {
|
|||||||
normalizeDateStr,
|
normalizeDateStr,
|
||||||
formatHoursDecimal,
|
formatHoursDecimal,
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
import FormField from "../components/FormField";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
FilterBar,
|
||||||
|
MonthField,
|
||||||
|
ProgressBar,
|
||||||
|
StatusChip,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
"Leden",
|
"Leden",
|
||||||
@@ -42,6 +55,21 @@ const MONTH_NAMES = [
|
|||||||
"Prosinec",
|
"Prosinec",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PrintIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 6 2 18 2 18 9" />
|
||||||
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||||
|
<rect x="6" y="14" width="12" height="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const formatBreakRange = (record: AttendanceRecord): string => {
|
const formatBreakRange = (record: AttendanceRecord): string => {
|
||||||
if (record.break_start && record.break_end) {
|
if (record.break_start && record.break_end) {
|
||||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||||
@@ -122,9 +150,7 @@ function getBusinessDaysInMonth(year: number, month: number): number {
|
|||||||
const renderProjectCell = (record: AttendanceRecord) => {
|
const renderProjectCell = (record: AttendanceRecord) => {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
|
||||||
>
|
|
||||||
{record.project_logs.map((log, i) => {
|
{record.project_logs.map((log, i) => {
|
||||||
let h: number,
|
let h: number,
|
||||||
m: number,
|
m: number,
|
||||||
@@ -145,31 +171,28 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
|||||||
m = mins % 60;
|
m = mins % 60;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
key={log.id || i}
|
key={log.id || i}
|
||||||
className="admin-badge"
|
color={isActive ? "info" : "default"}
|
||||||
style={{
|
label={`${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
|
||||||
fontSize: "0.7rem",
|
sx={{ fontSize: "0.7rem" }}
|
||||||
display: "inline-block",
|
/>
|
||||||
background: isActive ? "var(--accent-light)" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.project_name || `#${log.project_id}`} ({h}:
|
|
||||||
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (record.project_name) {
|
if (record.project_name) {
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
className="admin-badge admin-badge-wrap"
|
color="default"
|
||||||
style={{ fontSize: "0.75rem" }}
|
label={record.project_name}
|
||||||
>
|
sx={{
|
||||||
{record.project_name}
|
fontSize: "0.75rem",
|
||||||
</span>
|
height: "auto",
|
||||||
|
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return "—";
|
return "—";
|
||||||
@@ -373,325 +396,264 @@ 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 (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Historie docházky"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
subtitle={computed.monthName}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
actions={
|
||||||
transition={{ duration: 0.25 }}
|
records.length > 0 ? (
|
||||||
>
|
<Button
|
||||||
<div>
|
variant="outlined"
|
||||||
<h1 className="admin-page-title">Historie docházky</h1>
|
color="inherit"
|
||||||
<p className="admin-page-subtitle">{computed.monthName}</p>
|
startIcon={PrintIcon}
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
{records.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
title="Tisk docházky"
|
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,
|
||||||
|
bgcolor: "info.main",
|
||||||
|
color: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="24"
|
||||||
height="18"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
style={{ marginRight: "0.5rem" }}
|
|
||||||
>
|
>
|
||||||
<polyline points="6 9 6 2 18 2 18 9" />
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
<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" />
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
<rect x="6" y="14" width="12" height="8" />
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
Tisk
|
</Box>
|
||||||
</button>
|
<Box sx={{ flex: 1, minWidth: "200px" }}>
|
||||||
)}
|
<Box
|
||||||
</div>
|
sx={{
|
||||||
</motion.div>
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
{/* Filters */}
|
alignItems: "baseline",
|
||||||
<motion.div
|
mb: 0.75,
|
||||||
className="admin-card mb-6"
|
}}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
Fond: {fund.worked}h / {fund.fund}h
|
||||||
>
|
</Typography>
|
||||||
<div className="admin-card-body">
|
<Typography
|
||||||
<div className="admin-form-row">
|
variant="body2"
|
||||||
<FormField label="Měsíc">
|
color="text.secondary"
|
||||||
<AdminDatePicker
|
sx={{ fontSize: "0.8125rem" }}
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="admin-stat-icon info">
|
{fund.business_days} prac. dnů
|
||||||
<svg
|
{fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
|
||||||
width="24"
|
</Typography>
|
||||||
height="24"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
<ProgressBar value={progressValue} color={progressColor} />
|
||||||
fill="none"
|
<Box
|
||||||
stroke="currentColor"
|
sx={{
|
||||||
strokeWidth="2"
|
display: "flex",
|
||||||
>
|
flexWrap: "wrap",
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
gap: 0.5,
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
mt: 1,
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
minHeight: "1.5rem",
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
alignItems: "center",
|
||||||
</svg>
|
justifyContent: "space-between",
|
||||||
</div>
|
}}
|
||||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
>
|
||||||
<div
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||||
style={{
|
{computed.totalMinutes > 0 && (
|
||||||
display: "flex",
|
<StatusChip
|
||||||
justifyContent: "space-between",
|
color="info"
|
||||||
alignItems: "baseline",
|
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
|
||||||
marginBottom: "0.375rem",
|
title="Odpracováno (reálně)"
|
||||||
}}
|
/>
|
||||||
>
|
)}
|
||||||
<span
|
{computed.vacationHours > 0 && (
|
||||||
style={{
|
<StatusChip
|
||||||
fontWeight: 600,
|
color="success"
|
||||||
fontSize: "1rem",
|
label={`Dov: ${computed.vacationHours}h`}
|
||||||
color: "var(--text-primary)",
|
/>
|
||||||
}}
|
)}
|
||||||
>
|
{computed.sickHours > 0 && (
|
||||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
<StatusChip
|
||||||
{computed.monthlyFund.fund}h
|
color="error"
|
||||||
</span>
|
label={`Nem: ${computed.sickHours}h`}
|
||||||
<span
|
/>
|
||||||
className="text-secondary"
|
)}
|
||||||
style={{ fontSize: "0.8125rem" }}
|
{computed.freeHolidayHours > 0 && (
|
||||||
>
|
<StatusChip
|
||||||
{computed.monthlyFund.business_days} prac. dnů
|
color="warning"
|
||||||
{computed.monthlyFund.holiday_count > 0 &&
|
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
|
||||||
` + ${computed.monthlyFund.holiday_count} svátky`}
|
/>
|
||||||
</span>
|
)}
|
||||||
</div>
|
{computed.unpaidHours > 0 && (
|
||||||
<div className="attendance-balance-bar">
|
<StatusChip
|
||||||
<div
|
color="default"
|
||||||
className="attendance-balance-progress"
|
label={`Nep: ${computed.unpaidHours}h`}
|
||||||
style={{
|
/>
|
||||||
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
|
)}
|
||||||
background:
|
</Box>
|
||||||
computed.delta > 0
|
<Typography
|
||||||
? "linear-gradient(135deg, var(--warning), #d97706)"
|
variant="caption"
|
||||||
: computed.delta >= 0
|
sx={{ fontWeight: 600, color: deltaColor }}
|
||||||
? "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",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Fond měsíce není k dispozici
|
{computed.delta > 0
|
||||||
</div>
|
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
||||||
)}
|
: computed.delta < 0
|
||||||
</>
|
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
||||||
)}
|
: "Fond splněn"}
|
||||||
</div>
|
</Typography>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Records Table */}
|
{/* Records Table */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
className="admin-card"
|
{isPending ? (
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<LoadingState />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
) : (
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
<DataTable<AttendanceRecord>
|
||||||
>
|
columns={columns}
|
||||||
<div className="admin-card-body">
|
rows={records}
|
||||||
{isPending ? (
|
rowKey={(record) => record.id}
|
||||||
<div className="admin-loading">
|
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||||
<div className="admin-spinner" />
|
/>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</Card>
|
||||||
<>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Hidden Print Content */}
|
{/* Hidden Print Content */}
|
||||||
{records.length > 0 && (
|
{records.length > 0 && (
|
||||||
@@ -896,6 +858,24 @@ export default function AttendanceHistory() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||||
|
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||||
|
* sick→error(red), unpaid→default(gray). */
|
||||||
|
function leaveTypeChipColor(
|
||||||
|
type: string,
|
||||||
|
): "default" | "success" | "error" | "warning" | "info" {
|
||||||
|
switch (type) {
|
||||||
|
case "vacation":
|
||||||
|
return "info";
|
||||||
|
case "sick":
|
||||||
|
return "error";
|
||||||
|
case "unpaid":
|
||||||
|
return "default";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,47 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Leaflet map container (was the .attendance-location-map rule). zIndex:0
|
||||||
|
// creates a stacking context so Leaflet's internal z-indexes stay contained.
|
||||||
|
const LocationMap = styled("div")(({ theme }) => ({
|
||||||
|
height: "400px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
background: theme.vars!.palette.background.paper,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 0,
|
||||||
|
"@media (max-width:640px)": {
|
||||||
|
height: "clamp(220px, 45vh, 400px)",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||||
import {
|
import {
|
||||||
attendanceLocationOptions,
|
attendanceLocationOptions,
|
||||||
type LocationRecord,
|
type LocationRecord,
|
||||||
} from "../lib/queries/attendance";
|
} from "../lib/queries/attendance";
|
||||||
|
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
||||||
|
|
||||||
|
const BackIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AttendanceLocation() {
|
export default function AttendanceLocation() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -167,127 +197,164 @@ export default function AttendanceLocation() {
|
|||||||
const month = shiftDateStr.substring(0, 7);
|
const month = shiftDateStr.substring(0, 7);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
{/* Header */}
|
||||||
className="admin-page-header"
|
<PageHeader
|
||||||
initial={{ opacity: 0, y: 12 }}
|
title="Poloha záznamu"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
actions={
|
||||||
transition={{ duration: 0.25 }}
|
<Button
|
||||||
>
|
component={RouterLink}
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
<Link
|
|
||||||
to={`/attendance/admin?month=${month}`}
|
to={`/attendance/admin?month=${month}`}
|
||||||
className="admin-btn admin-btn-secondary"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={BackIcon}
|
||||||
>
|
>
|
||||||
← Zpět na správu
|
Zpět na správu
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</motion.div>
|
/>
|
||||||
|
|
||||||
<motion.div
|
{/* Info + map card */}
|
||||||
className="admin-card"
|
<Card sx={{ mb: 3 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{record.user_name} — {formatDate(record.shift_date)}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
</Typography>
|
||||||
>
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="attendance-location-grid">
|
{/* Leaflet map */}
|
||||||
{/* Arrival */}
|
{hasAnyLocation && <LocationMap ref={mapRef} />}
|
||||||
<div
|
|
||||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
{/* Location detail grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
mt: hasAnyLocation ? 2 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Arrival */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
opacity: hasArrivalLocation ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||||
|
Příchod
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
|
||||||
>
|
>
|
||||||
<h3 className="attendance-location-title">Příchod</h3>
|
{record.arrival_time
|
||||||
<div className="attendance-location-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>
|
</Typography>
|
||||||
{hasArrivalLocation ? (
|
{hasDepartureLocation ? (
|
||||||
<>
|
<>
|
||||||
<div className="attendance-location-address">
|
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||||
</div>
|
</Typography>
|
||||||
<div className="attendance-location-coords">
|
<Typography
|
||||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
variant="body2"
|
||||||
{record.arrival_accuracy &&
|
sx={{
|
||||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
</div>
|
fontSize: "0.75rem",
|
||||||
<a
|
color: "text.secondary",
|
||||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
Otevřít v Google Maps
|
Otevřít v Google Maps
|
||||||
</a>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="attendance-location-address">
|
<Typography variant="body2" color="text.secondary">
|
||||||
<em>Poloha nebyla zaznamenána</em>
|
<em>Poloha nebyla zaznamenána</em>
|
||||||
</div>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
|
)}
|
||||||
{/* Departure */}
|
</Box>
|
||||||
{(hasDepartureLocation || record.departure_time) && (
|
</Card>
|
||||||
<div
|
</PageEnter>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import Pagination from "../components/Pagination";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import { czechPlural } from "../utils/formatters";
|
import { czechPlural } from "../utils/formatters";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
TextField,
|
||||||
|
StatusChip,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
FilterBar,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -30,19 +44,23 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
access_denied: "Přístup odepřen",
|
access_denied: "Přístup odepřen",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_BADGE_CLASS: Record<string, string> = {
|
// Maps legacy admin-badge-* CSS class suffixes → StatusChip color
|
||||||
create: "admin-badge-success",
|
const ACTION_CHIP_COLOR: Record<
|
||||||
update: "admin-badge-info",
|
string,
|
||||||
delete: "admin-badge-danger",
|
"success" | "info" | "warning" | "error" | "default"
|
||||||
login: "admin-badge-secondary",
|
> = {
|
||||||
login_failed: "admin-badge-danger",
|
create: "success",
|
||||||
logout: "admin-badge-secondary",
|
update: "info",
|
||||||
view: "admin-badge-info",
|
delete: "error",
|
||||||
activate: "admin-badge-success",
|
login: "default",
|
||||||
deactivate: "admin-badge-warning",
|
login_failed: "error",
|
||||||
password_change: "admin-badge-info",
|
logout: "default",
|
||||||
permission_change: "admin-badge-warning",
|
view: "info",
|
||||||
access_denied: "admin-badge-danger",
|
activate: "success",
|
||||||
|
deactivate: "warning",
|
||||||
|
password_change: "info",
|
||||||
|
permission_change: "warning",
|
||||||
|
access_denied: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
||||||
@@ -53,6 +71,29 @@ const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
|
|||||||
([value, label]) => ({ value, label }),
|
([value, label]) => ({ value, label }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const CLEANUP_DAYS_OPTIONS = [
|
||||||
|
{ value: "30", label: "30 dní" },
|
||||||
|
{ value: "60", label: "60 dní" },
|
||||||
|
{ value: "90", label: "90 dní" },
|
||||||
|
{ value: "180", label: "180 dní" },
|
||||||
|
{ value: "365", label: "1 rok" },
|
||||||
|
{ value: "0", label: "Vše" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TrashIcon = (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
interface AuditLogEntry {
|
interface AuditLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -82,11 +123,16 @@ export default function AuditLog() {
|
|||||||
date_to: "",
|
date_to: "",
|
||||||
});
|
});
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(50);
|
const [perPage] = useState(50);
|
||||||
const [showCleanup, setShowCleanup] = useState(false);
|
const [showCleanup, setShowCleanup] = useState(false);
|
||||||
const [cleanupDays, setCleanupDays] = useState(90);
|
// cleanupDays stored as string for the kit Select; converted to number at the boundary
|
||||||
|
const [cleanupDaysStr, setCleanupDaysStr] = useState("90");
|
||||||
|
|
||||||
const { data: logsData, isPending } = useQuery({
|
const {
|
||||||
|
data: logsData,
|
||||||
|
isPending,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"audit-log",
|
"audit-log",
|
||||||
{
|
{
|
||||||
@@ -127,6 +173,10 @@ export default function AuditLog() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Keep the current rows on screen while a filter refetches, so the page
|
||||||
|
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
||||||
|
// whole entrance animation on every filter change).
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||||
@@ -154,16 +204,8 @@ export default function AuditLog() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
setPage(newPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePerPageChange = (newPerPage: number) => {
|
|
||||||
setPage(1);
|
|
||||||
setPerPage(newPerPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCleanup = async () => {
|
const handleCleanup = async () => {
|
||||||
|
const cleanupDays = Number(cleanupDaysStr);
|
||||||
try {
|
try {
|
||||||
// Backend requires this literal confirmation token when wiping everything.
|
// Backend requires this literal confirmation token when wiping everything.
|
||||||
await cleanupMutation.mutateAsync({
|
await cleanupMutation.mutateAsync({
|
||||||
@@ -181,229 +223,156 @@ export default function AuditLog() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending && logs.length === 0) {
|
if (isPending && logs.length === 0) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<AuditLogEntry>[] = [
|
||||||
<div>
|
{
|
||||||
<motion.div
|
key: "created_at",
|
||||||
className="admin-page-header"
|
header: "Čas",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
width: "16%",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
mono: true,
|
||||||
transition={{ duration: 0.25 }}
|
render: (log) => formatDatetime(log.created_at),
|
||||||
>
|
},
|
||||||
<div>
|
{
|
||||||
<h1 className="admin-page-title">Audit log</h1>
|
key: "username",
|
||||||
{pagination && (
|
header: "Uživatel",
|
||||||
<p className="admin-page-subtitle">
|
width: "14%",
|
||||||
{pagination.total}{" "}
|
bold: true,
|
||||||
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
|
render: (log) => log.username || "-",
|
||||||
</p>
|
},
|
||||||
)}
|
{
|
||||||
</div>
|
key: "action",
|
||||||
<button
|
header: "Akce",
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
width: "14%",
|
||||||
onClick={() => setShowCleanup(true)}
|
render: (log) => (
|
||||||
>
|
<StatusChip
|
||||||
<svg
|
label={ACTION_LABELS[log.action] || log.action}
|
||||||
width="14"
|
color={ACTION_CHIP_COLOR[log.action] ?? "default"}
|
||||||
height="14"
|
/>
|
||||||
viewBox="0 0 24 24"
|
),
|
||||||
fill="none"
|
},
|
||||||
stroke="currentColor"
|
{
|
||||||
strokeWidth="2"
|
key: "entity_type",
|
||||||
>
|
header: "Typ entity",
|
||||||
<polyline points="3 6 5 6 21 6" />
|
width: "14%",
|
||||||
<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" />
|
render: (log) =>
|
||||||
</svg>
|
ENTITY_TYPE_LABELS[log.entity_type || ""] || log.entity_type || "-",
|
||||||
Vyčistit
|
},
|
||||||
</button>
|
{
|
||||||
</motion.div>
|
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}
|
isOpen={showCleanup}
|
||||||
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
||||||
onSubmit={handleCleanup}
|
onSubmit={handleCleanup}
|
||||||
title="Vyčistit audit log"
|
title="Vyčistit audit log"
|
||||||
submitLabel="Smazat"
|
submitText="Smazat"
|
||||||
loading={cleanupMutation.isPending}
|
loading={cleanupMutation.isPending}
|
||||||
>
|
>
|
||||||
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
|
Smazat záznamy starší než:
|
||||||
<select
|
</Typography>
|
||||||
className="admin-form-select"
|
<Box sx={{ maxWidth: 200, mb: 1 }}>
|
||||||
value={cleanupDays}
|
<Select
|
||||||
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
|
value={cleanupDaysStr}
|
||||||
>
|
onChange={setCleanupDaysStr}
|
||||||
<option value={30}>30 dní</option>
|
options={CLEANUP_DAYS_OPTIONS}
|
||||||
<option value={60}>60 dní</option>
|
|
||||||
<option value={90}>90 dní</option>
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
<Typography variant="body2" sx={{ opacity: 0.6, fontSize: "0.75rem" }}>
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { dashboardOptions } from "../lib/queries/dashboard";
|
import { dashboardOptions } from "../lib/queries/dashboard";
|
||||||
import { require2FAOptions } from "../lib/queries/settings";
|
import { require2FAOptions } from "../lib/queries/settings";
|
||||||
import { getCzechDate } from "../utils/dashboardHelpers";
|
import { getCzechDate } from "../utils/dashboardHelpers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import { Card, Button, StatusChip, PageEnter } from "../ui";
|
||||||
import DashKpiCards from "../components/dashboard/DashKpiCards";
|
import DashKpiCards from "../components/dashboard/DashKpiCards";
|
||||||
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
||||||
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
||||||
@@ -102,9 +104,6 @@ export default function Dashboard() {
|
|||||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||||
const [disableCode, setDisableCode] = useState("");
|
const [disableCode, setDisableCode] = useState("");
|
||||||
|
|
||||||
useModalLock(show2FASetup);
|
|
||||||
useModalLock(show2FADisable);
|
|
||||||
|
|
||||||
// Punch (prichod/odchod) primo z dashboardu
|
// Punch (prichod/odchod) primo z dashboardu
|
||||||
const handleQuickPunch = useCallback(() => {
|
const handleQuickPunch = useCallback(() => {
|
||||||
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
||||||
@@ -221,55 +220,48 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash">
|
<PageEnter>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<Box sx={{ mb: 3 }}>
|
||||||
className="admin-page-header"
|
<Typography variant="h4">
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Vítejte zpět, {user?.fullName || user?.username}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25 }}
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
>
|
{getCzechDate()}
|
||||||
<div>
|
</Typography>
|
||||||
<h1 className="admin-page-title">
|
</Box>
|
||||||
Vítejte zpět, {user?.fullName || user?.username}
|
|
||||||
</h1>
|
|
||||||
<p className="admin-page-subtitle">{getCzechDate()}</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 2FA Required Banner */}
|
{/* 2FA Required Banner */}
|
||||||
{user?.require2FA && !user?.totpEnabled && (
|
{user?.require2FA && !user?.totpEnabled && (
|
||||||
<motion.div
|
<Card
|
||||||
className="admin-card"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
mb: 3,
|
||||||
animate={{ opacity: 1, y: 0 }}
|
border: 2,
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
borderColor: "error.main",
|
||||||
style={{
|
backgroundColor:
|
||||||
border: "2px solid var(--danger)",
|
"rgba(var(--mui-palette-error-mainChannel) / 0.12)",
|
||||||
background: "var(--danger-light)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Box
|
||||||
className="admin-card-body"
|
sx={{
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: "1rem",
|
gap: 2,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-row-gap">
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
background: "var(--danger-light)",
|
bgcolor: "error.main",
|
||||||
color: "var(--danger)",
|
color: "#fff",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -285,35 +277,33 @@ export default function Dashboard() {
|
|||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Box>
|
||||||
<div>
|
<Box>
|
||||||
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
|
<Typography sx={{ fontWeight: 600 }}>
|
||||||
<div
|
Dvoufaktorové ověření je povinné
|
||||||
className="text-secondary"
|
</Typography>
|
||||||
style={{ fontSize: "0.875rem" }}
|
<Typography variant="body2" color="text.secondary">
|
||||||
>
|
|
||||||
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
|
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
|
||||||
nemáte přístup k ostatním sekcím systému.
|
nemáte přístup k ostatním sekcím systému.
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<button
|
<Button
|
||||||
onClick={handleStart2FASetup}
|
onClick={handleStart2FASetup}
|
||||||
disabled={totpSubmitting}
|
disabled={totpSubmitting}
|
||||||
className="admin-btn admin-btn-primary"
|
sx={{ flexShrink: 0 }}
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
>
|
||||||
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
|
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading spinner */}
|
{/* Loading spinner */}
|
||||||
{dashLoading && (
|
{dashLoading && (
|
||||||
<div className="admin-loading">
|
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||||
<div className="admin-spinner" />
|
<CircularProgress />
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPI cards — only show if user has any admin-level permissions */}
|
{/* KPI cards — only show if user has any admin-level permissions */}
|
||||||
@@ -336,11 +326,14 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Main content grid */}
|
{/* Main content grid */}
|
||||||
{!dashLoading && (
|
{!dashLoading && (
|
||||||
<motion.div
|
<Box
|
||||||
className="dash-main-grid"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
gap: 3,
|
||||||
|
alignItems: "start",
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{hasPermission("settings.audit") && (
|
{hasPermission("settings.audit") && (
|
||||||
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
||||||
@@ -351,86 +344,146 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pravy sloupec: projekty + nabidky */}
|
{/* Pravy sloupec: projekty + nabidky */}
|
||||||
<div className="dash-right-col">
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
{dashData?.projects && (
|
{dashData?.projects && (
|
||||||
<div className="admin-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Aktivní projekty</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Aktivní projekty</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/projects"
|
to="/projects"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Vše →
|
Vše →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
{dashData.projects.active_projects.length === 0 && (
|
||||||
{dashData.projects.active_projects.length === 0 && (
|
<Typography
|
||||||
<div className="dash-empty-row">Žádné aktivní projekty</div>
|
variant="body2"
|
||||||
)}
|
color="text.secondary"
|
||||||
{dashData.projects.active_projects.map(
|
sx={{ py: 1 }}
|
||||||
(p: {
|
>
|
||||||
id: number;
|
Žádné aktivní projekty
|
||||||
name: string;
|
</Typography>
|
||||||
customer_name: string | null;
|
)}
|
||||||
}) => (
|
{dashData.projects.active_projects.map((p) => (
|
||||||
<Link
|
<Box
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to={`/projects/${p.id}`}
|
component={RouterLink}
|
||||||
className="dash-project-row"
|
to={`/projects/${p.id}`}
|
||||||
>
|
sx={{
|
||||||
<div className="dash-project-name">{p.name}</div>
|
display: "block",
|
||||||
{p.customer_name && (
|
py: 1.25,
|
||||||
<div className="dash-project-customer">
|
textDecoration: "none",
|
||||||
{p.customer_name}
|
color: "inherit",
|
||||||
</div>
|
borderBottom: 1,
|
||||||
)}
|
borderColor: "divider",
|
||||||
</Link>
|
"&:last-of-type": { borderBottom: 0 },
|
||||||
),
|
"&:hover": { color: "primary.main" },
|
||||||
)}
|
}}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{p.name}
|
||||||
|
</Typography>
|
||||||
|
{p.customer_name && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{p.customer_name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dashData?.offers && (
|
{dashData?.offers && (
|
||||||
<div className="admin-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Nabídky</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Nabídky</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/offers"
|
to="/offers"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Zobrazit →
|
Zobrazit →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
<Box
|
||||||
<div className="dash-stat-row">
|
sx={{
|
||||||
<span>Otevřené</span>
|
display: "flex",
|
||||||
<span className="admin-badge admin-badge-info">
|
alignItems: "center",
|
||||||
{dashData.offers.open_count}
|
justifyContent: "space-between",
|
||||||
</span>
|
py: 1,
|
||||||
</div>
|
borderBottom: 1,
|
||||||
<div className="dash-stat-row">
|
borderColor: "divider",
|
||||||
<span>Převedené na objednávku</span>
|
}}
|
||||||
<span className="admin-badge admin-badge-success">
|
>
|
||||||
{dashData.offers.converted_count}
|
<Typography variant="body2">Otevřené</Typography>
|
||||||
</span>
|
<StatusChip color="info" label={dashData.offers.open_count} />
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-stat-row">
|
<Box
|
||||||
<span>Zneplatněné</span>
|
sx={{
|
||||||
<span className="admin-badge admin-badge-warning">
|
display: "flex",
|
||||||
{dashData.offers.expired_count}
|
alignItems: "center",
|
||||||
</span>
|
justifyContent: "space-between",
|
||||||
</div>
|
py: 1,
|
||||||
</div>
|
borderBottom: 1,
|
||||||
</div>
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Převedené na objednávku
|
||||||
|
</Typography>
|
||||||
|
<StatusChip
|
||||||
|
color="success"
|
||||||
|
label={dashData.offers.converted_count}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
py: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">Zneplatněné</Typography>
|
||||||
|
<StatusChip
|
||||||
|
color="warning"
|
||||||
|
label={dashData.offers.expired_count}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Profile + Sessions */}
|
{/* Profile + Sessions */}
|
||||||
{!dashLoading && (
|
{!dashLoading && (
|
||||||
<div className="dash-bottom">
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
|
||||||
|
gap: 3,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DashProfile
|
<DashProfile
|
||||||
totpEnabled={totpEnabled}
|
totpEnabled={totpEnabled}
|
||||||
totpLoading={totpLoading}
|
totpLoading={totpLoading}
|
||||||
@@ -452,8 +505,8 @@ export default function Dashboard() {
|
|||||||
setDisableCode={setDisableCode}
|
setDisableCode={setDisableCode}
|
||||||
/>
|
/>
|
||||||
<DashSessions />
|
<DashSessions />
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,34 @@ import { useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||||
import { czechPlural } from "../utils/formatters";
|
import { czechPlural } from "../utils/formatters";
|
||||||
import {
|
import {
|
||||||
leavePendingOptions,
|
leavePendingOptions,
|
||||||
leaveProcessedOptions,
|
leaveProcessedOptions,
|
||||||
} from "../lib/queries/leave";
|
} from "../lib/queries/leave";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
ConfirmDialog,
|
||||||
|
Modal,
|
||||||
|
StatusChip,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
Tabs,
|
||||||
|
TabPanel,
|
||||||
|
type DataColumn,
|
||||||
|
type TabDef,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -23,12 +39,6 @@ const leaveTypeLabels: Record<string, string> = {
|
|||||||
unpaid: "Neplacené volno",
|
unpaid: "Neplacené volno",
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveTypeClasses: Record<string, string> = {
|
|
||||||
vacation: "badge-vacation",
|
|
||||||
sick: "badge-sick",
|
|
||||||
unpaid: "badge-unpaid",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending: "Čeká na schválení",
|
pending: "Čeká na schválení",
|
||||||
approved: "Schváleno",
|
approved: "Schváleno",
|
||||||
@@ -36,13 +46,25 @@ const statusLabels: Record<string, string> = {
|
|||||||
cancelled: "Zrušeno",
|
cancelled: "Zrušeno",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusClasses: Record<string, string> = {
|
// Leave type → StatusChip color
|
||||||
pending: "badge-pending",
|
const leaveTypeColor: Record<
|
||||||
approved: "badge-approved",
|
string,
|
||||||
rejected: "badge-rejected",
|
"info" | "error" | "default" | "success" | "warning"
|
||||||
cancelled: "badge-cancelled",
|
> = {
|
||||||
|
vacation: "info",
|
||||||
|
sick: "error",
|
||||||
|
unpaid: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Status → StatusChip color
|
||||||
|
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
|
||||||
|
{
|
||||||
|
pending: "warning",
|
||||||
|
approved: "success",
|
||||||
|
rejected: "error",
|
||||||
|
cancelled: "default",
|
||||||
|
};
|
||||||
|
|
||||||
interface RawLeaveRequest {
|
interface RawLeaveRequest {
|
||||||
id: number;
|
id: number;
|
||||||
leave_type: string;
|
leave_type: string;
|
||||||
@@ -192,290 +214,231 @@ export default function LeaveApproval() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const tabs: TabDef[] = [
|
||||||
<div>
|
{ value: "pending", label: `Ke schválení (${pendingCount})` },
|
||||||
<motion.div
|
{ value: "processed", label: "Vyřízené" },
|
||||||
className="admin-page-header"
|
];
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
|
|
||||||
<p className="admin-page-subtitle">
|
|
||||||
{pendingCount > 0
|
|
||||||
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
|
|
||||||
: "Žádné čekající žádosti"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
const processedColumns: DataColumn<LeaveRequest>[] = [
|
||||||
<motion.div
|
{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
key: "employee_name",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
header: "Zaměstnanec",
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
width: "16%",
|
||||||
>
|
bold: true,
|
||||||
<div className="admin-tabs mb-6">
|
render: (req) => req.employee_name,
|
||||||
<button
|
},
|
||||||
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
|
{
|
||||||
onClick={() => setActiveTab("pending")}
|
key: "leave_type",
|
||||||
>
|
header: "Typ",
|
||||||
Ke schválení
|
width: "13%",
|
||||||
{pendingCount > 0 && (
|
render: (req) => (
|
||||||
<span
|
<StatusChip
|
||||||
className="admin-badge badge-pending"
|
label={leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||||
style={{
|
color={leaveTypeColor[req.leave_type] ?? "default"}
|
||||||
marginLeft: "0.5rem",
|
/>
|
||||||
fontSize: "0.7rem",
|
),
|
||||||
padding: "0.15rem 0.5rem",
|
},
|
||||||
}}
|
{
|
||||||
>
|
key: "date_from",
|
||||||
{pendingCount}
|
header: "Od",
|
||||||
</span>
|
width: "10%",
|
||||||
)}
|
mono: true,
|
||||||
</button>
|
render: (req) => formatDate(req.date_from),
|
||||||
<button
|
},
|
||||||
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
|
{
|
||||||
onClick={() => setActiveTab("processed")}
|
key: "date_to",
|
||||||
>
|
header: "Do",
|
||||||
Vyřízené
|
width: "10%",
|
||||||
</button>
|
mono: true,
|
||||||
</div>
|
render: (req) => formatDate(req.date_to),
|
||||||
</motion.div>
|
},
|
||||||
|
{
|
||||||
|
key: "total_days",
|
||||||
|
header: "Dny",
|
||||||
|
width: "6%",
|
||||||
|
mono: true,
|
||||||
|
render: (req) => String(req.total_days),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Stav",
|
||||||
|
width: "13%",
|
||||||
|
render: (req) => (
|
||||||
|
<StatusChip
|
||||||
|
label={statusLabels[req.status] || req.status}
|
||||||
|
color={statusColor[req.status] ?? "default"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviewer_name",
|
||||||
|
header: "Schválil",
|
||||||
|
width: "12%",
|
||||||
|
render: (req) => req.reviewer_name || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviewer_note",
|
||||||
|
header: "Poznámka",
|
||||||
|
width: "12%",
|
||||||
|
render: (req) =>
|
||||||
|
req.reviewer_note ? (
|
||||||
|
<Box component="span" title={req.reviewer_note}>
|
||||||
|
{req.reviewer_note.length > 40
|
||||||
|
? `${req.reviewer_note.substring(0, 40)}...`
|
||||||
|
: req.reviewer_note}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviewed_at",
|
||||||
|
header: "Vyřízeno",
|
||||||
|
width: "8%",
|
||||||
|
mono: true,
|
||||||
|
render: (req) => formatDatetime(req.reviewed_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageEnter>
|
||||||
|
<PageHeader
|
||||||
|
title="Schvalování nepřítomnosti"
|
||||||
|
subtitle={
|
||||||
|
pendingCount > 0
|
||||||
|
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
|
||||||
|
: "Žádné čekající žádosti"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(v) => setActiveTab(v as "pending" | "processed")}
|
||||||
|
tabs={tabs}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Pending Tab */}
|
{/* Pending Tab */}
|
||||||
{activeTab === "pending" && (
|
<TabPanel value="pending" current={activeTab}>
|
||||||
<motion.div
|
{pendingRequests.length === 0 ? (
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Card>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<EmptyState title="Žádné čekající žádosti" />
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
</Card>
|
||||||
>
|
) : (
|
||||||
{pendingRequests.length === 0 ? (
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<div className="admin-card">
|
{pendingRequests.map((req) => (
|
||||||
<div className="admin-card-body">
|
<Card key={req.id}>
|
||||||
<div className="admin-empty-state">
|
<Box
|
||||||
<svg
|
sx={{
|
||||||
width="48"
|
display: "flex",
|
||||||
height="48"
|
justifyContent: "space-between",
|
||||||
viewBox="0 0 24 24"
|
alignItems: "flex-start",
|
||||||
fill="none"
|
flexWrap: "wrap",
|
||||||
stroke="currentColor"
|
gap: 2,
|
||||||
strokeWidth="1.5"
|
}}
|
||||||
className="text-muted mb-4"
|
>
|
||||||
>
|
<Box sx={{ flex: 1 }}>
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
<Box
|
||||||
<polyline points="22 4 12 14.01 9 11.01" />
|
sx={{
|
||||||
</svg>
|
|
||||||
<p>Žádné čekající žádosti</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pendingRequests.map((req) => (
|
|
||||||
<div key={req.id} className="admin-card">
|
|
||||||
<div
|
|
||||||
className="admin-card-body"
|
|
||||||
style={{ padding: "1.25rem" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
alignItems: "center",
|
||||||
alignItems: "flex-start",
|
gap: 1.5,
|
||||||
flexWrap: "wrap",
|
mb: 1,
|
||||||
gap: "1rem",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<Typography
|
||||||
<div className="flex-row-gap mb-2">
|
component="strong"
|
||||||
<strong style={{ fontSize: "1rem" }}>
|
sx={{ fontSize: "1rem", fontWeight: 600 }}
|
||||||
{req.employee_name}
|
>
|
||||||
</strong>
|
{req.employee_name}
|
||||||
<span
|
</Typography>
|
||||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
<StatusChip
|
||||||
>
|
label={
|
||||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
leaveTypeLabels[req.leave_type] || req.leave_type
|
||||||
</span>
|
}
|
||||||
</div>
|
color={leaveTypeColor[req.leave_type] ?? "default"}
|
||||||
<div
|
/>
|
||||||
className="text-secondary"
|
</Box>
|
||||||
style={{
|
<Box
|
||||||
display: "flex",
|
sx={{
|
||||||
gap: "1.5rem",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
gap: 3,
|
||||||
fontSize: "0.875rem",
|
flexWrap: "wrap",
|
||||||
}}
|
fontSize: "0.875rem",
|
||||||
>
|
color: "text.secondary",
|
||||||
<span>
|
}}
|
||||||
<strong>{formatDate(req.date_from)}</strong> —{" "}
|
>
|
||||||
<strong>{formatDate(req.date_to)}</strong>
|
<Box component="span">
|
||||||
</span>
|
<strong>{formatDate(req.date_from)}</strong> —{" "}
|
||||||
<span>
|
<strong>{formatDate(req.date_to)}</strong>
|
||||||
{req.total_days}{" "}
|
</Box>
|
||||||
{czechPlural(req.total_days, "den", "dny", "dnů")} (
|
<Box component="span">
|
||||||
{req.total_hours}h)
|
{req.total_days}{" "}
|
||||||
</span>
|
{czechPlural(req.total_days, "den", "dny", "dnů")} (
|
||||||
<span className="text-muted">
|
{req.total_hours}h)
|
||||||
Podáno: {formatDatetime(req.created_at)}
|
</Box>
|
||||||
</span>
|
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||||
</div>
|
Podáno: {formatDatetime(req.created_at)}
|
||||||
{req.notes && (
|
</Box>
|
||||||
<div
|
</Box>
|
||||||
className="text-secondary"
|
{req.notes && (
|
||||||
style={{
|
<Box
|
||||||
marginTop: "0.5rem",
|
sx={{
|
||||||
fontSize: "0.875rem",
|
mt: 1,
|
||||||
fontStyle: "italic",
|
fontSize: "0.875rem",
|
||||||
}}
|
fontStyle: "italic",
|
||||||
>
|
color: "text.secondary",
|
||||||
{req.notes}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.5rem",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{req.notes}
|
||||||
onClick={() =>
|
</Box>
|
||||||
setApproveModal({ open: true, request: req })
|
)}
|
||||||
}
|
</Box>
|
||||||
className="admin-btn admin-btn-sm"
|
<Box sx={{ display: "flex", gap: 1, flexShrink: 0 }}>
|
||||||
style={{
|
<Button
|
||||||
background: "var(--success-light)",
|
size="small"
|
||||||
color: "var(--success)",
|
color="success"
|
||||||
border: "none",
|
variant="outlined"
|
||||||
}}
|
onClick={() =>
|
||||||
>
|
setApproveModal({ open: true, request: req })
|
||||||
Schválit
|
}
|
||||||
</button>
|
>
|
||||||
<button
|
Schválit
|
||||||
onClick={() =>
|
</Button>
|
||||||
setRejectModal({ open: true, request: req })
|
<Button
|
||||||
}
|
size="small"
|
||||||
className="admin-btn admin-btn-sm"
|
color="error"
|
||||||
style={{
|
variant="outlined"
|
||||||
background: "var(--danger-light)",
|
onClick={() =>
|
||||||
color: "var(--danger)",
|
setRejectModal({ open: true, request: req })
|
||||||
border: "none",
|
}
|
||||||
}}
|
>
|
||||||
>
|
Zamítnout
|
||||||
Zamítnout
|
</Button>
|
||||||
</button>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</Box>
|
||||||
))}
|
)}
|
||||||
</div>
|
</TabPanel>
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Processed Tab */}
|
{/* Processed Tab */}
|
||||||
{activeTab === "processed" && (
|
<TabPanel value="processed" current={activeTab}>
|
||||||
<motion.div
|
<Card>
|
||||||
className="admin-card"
|
<DataTable<LeaveRequest>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
columns={processedColumns}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
rows={processedRequests}
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
rowKey={(req) => req.id}
|
||||||
>
|
empty={<EmptyState title="Zatím žádné vyřízené žádosti" />}
|
||||||
<div className="admin-card-body">
|
/>
|
||||||
{processedRequests.length === 0 ? (
|
</Card>
|
||||||
<div className="admin-empty-state">
|
</TabPanel>
|
||||||
<p>Zatím žádné vyřízené žádosti</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Zaměstnanec</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Od</th>
|
|
||||||
<th>Do</th>
|
|
||||||
<th>Dny</th>
|
|
||||||
<th>Stav</th>
|
|
||||||
<th>Schválil</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
<th>Vyřízeno</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{processedRequests.map((req) => (
|
|
||||||
<tr key={req.id}>
|
|
||||||
<td>
|
|
||||||
<strong>{req.employee_name}</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
|
||||||
>
|
|
||||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(req.date_from)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(req.date_to)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">{req.total_days}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${statusClasses[req.status] || ""}`}
|
|
||||||
>
|
|
||||||
{statusLabels[req.status] || req.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{req.reviewer_name || "—"}</td>
|
|
||||||
<td style={{ maxWidth: "200px" }}>
|
|
||||||
{req.reviewer_note ? (
|
|
||||||
<span title={req.reviewer_note}>
|
|
||||||
{req.reviewer_note.length > 40
|
|
||||||
? `${req.reviewer_note.substring(0, 40)}...`
|
|
||||||
: req.reviewer_note}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="admin-mono"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{formatDatetime(req.reviewed_at)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Approve Confirmation */}
|
{/* Approve Confirmation */}
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={approveModal.open}
|
isOpen={approveModal.open}
|
||||||
onClose={() => setApproveModal({ open: false, request: null })}
|
onClose={() => setApproveModal({ open: false, request: null })}
|
||||||
onConfirm={handleApprove}
|
onConfirm={handleApprove}
|
||||||
@@ -486,12 +449,11 @@ export default function LeaveApproval() {
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
confirmText="Schválit"
|
confirmText="Schválit"
|
||||||
type="info"
|
|
||||||
loading={approveMutation.isPending}
|
loading={approveMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Reject Modal */}
|
{/* Reject Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={rejectModal.open && !!rejectModal.request}
|
isOpen={rejectModal.open && !!rejectModal.request}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setRejectModal({ open: false, request: null });
|
setRejectModal({ open: false, request: null });
|
||||||
@@ -499,31 +461,31 @@ export default function LeaveApproval() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleReject}
|
onSubmit={handleReject}
|
||||||
title="Zamítnout žádost"
|
title="Zamítnout žádost"
|
||||||
submitLabel="Zamítnout"
|
submitText="Zamítnout"
|
||||||
loading={rejectMutation.isPending}
|
loading={rejectMutation.isPending}
|
||||||
>
|
>
|
||||||
{rejectModal.request && (
|
{rejectModal.request && (
|
||||||
<>
|
<>
|
||||||
<p className="text-secondary mb-4">
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
{rejectModal.request.employee_name} —{" "}
|
{rejectModal.request.employee_name} —{" "}
|
||||||
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
|
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
|
||||||
{formatDate(rejectModal.request.date_from)} —{" "}
|
{formatDate(rejectModal.request.date_from)} —{" "}
|
||||||
{formatDate(rejectModal.request.date_to)} (
|
{formatDate(rejectModal.request.date_to)} (
|
||||||
{rejectModal.request.total_days} dnů)
|
{rejectModal.request.total_days} dnů)
|
||||||
</p>
|
</Typography>
|
||||||
<FormField label="Důvod zamítnutí" required>
|
<Field label="Důvod zamítnutí" required>
|
||||||
<textarea
|
<TextField
|
||||||
value={rejectNote}
|
value={rejectNote}
|
||||||
onChange={(e) => setRejectNote(e.target.value)}
|
onChange={(e) => setRejectNote(e.target.value)}
|
||||||
placeholder="Uveďte důvod zamítnutí..."
|
placeholder="Uveďte důvod zamítnutí..."
|
||||||
className="admin-form-textarea"
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</FormModal>
|
</Modal>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,23 @@ import { useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
ConfirmDialog,
|
||||||
|
StatusChip,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -24,18 +35,40 @@ const statusLabels: Record<string, string> = {
|
|||||||
cancelled: "Zrušeno",
|
cancelled: "Zrušeno",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusClasses: Record<string, string> = {
|
// Leave type → StatusChip color
|
||||||
pending: "badge-pending",
|
const leaveTypeColor: Record<
|
||||||
approved: "badge-approved",
|
string,
|
||||||
rejected: "badge-rejected",
|
"info" | "error" | "default" | "success" | "warning"
|
||||||
cancelled: "badge-cancelled",
|
> = {
|
||||||
|
vacation: "info",
|
||||||
|
sick: "error",
|
||||||
|
unpaid: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveTypeClasses: Record<string, string> = {
|
// Status → StatusChip color
|
||||||
vacation: "badge-vacation",
|
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
|
||||||
sick: "badge-sick",
|
{
|
||||||
unpaid: "badge-unpaid",
|
pending: "warning",
|
||||||
};
|
approved: "success",
|
||||||
|
rejected: "error",
|
||||||
|
cancelled: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CancelIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function LeaveRequests() {
|
export default function LeaveRequests() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -72,167 +105,167 @@ export default function LeaveRequests() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderNoteCell(req: LeaveRequest) {
|
if (isPending) {
|
||||||
const truncate = (text: string) =>
|
return <LoadingState />;
|
||||||
text.length > 40 ? `${text.substring(0, 40)}...` : text;
|
|
||||||
if (req.status === "rejected" && req.reviewer_note) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
|
|
||||||
title={req.reviewer_note}
|
|
||||||
>
|
|
||||||
{truncate(req.reviewer_note)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (req.notes) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="text-secondary"
|
|
||||||
style={{ fontSize: "0.875rem" }}
|
|
||||||
title={req.notes}
|
|
||||||
>
|
|
||||||
{truncate(req.notes)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span className="text-muted">—</span>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPending) {
|
const columns: DataColumn<LeaveRequest>[] = [
|
||||||
return (
|
{
|
||||||
<div className="admin-loading">
|
key: "leave_type",
|
||||||
<div className="admin-spinner" />
|
header: "Typ",
|
||||||
</div>
|
width: "14%",
|
||||||
);
|
render: (req) => (
|
||||||
}
|
<StatusChip
|
||||||
|
label={leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||||
|
color={leaveTypeColor[req.leave_type] ?? "default"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "date_from",
|
||||||
|
header: "Od",
|
||||||
|
width: "11%",
|
||||||
|
mono: true,
|
||||||
|
render: (req) => formatDate(req.date_from),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "date_to",
|
||||||
|
header: "Do",
|
||||||
|
width: "11%",
|
||||||
|
mono: true,
|
||||||
|
render: (req) => formatDate(req.date_to),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "total_days",
|
||||||
|
header: "Dny",
|
||||||
|
width: "7%",
|
||||||
|
mono: true,
|
||||||
|
render: (req) => String(req.total_days),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "total_hours",
|
||||||
|
header: "Hodiny",
|
||||||
|
width: "8%",
|
||||||
|
mono: true,
|
||||||
|
render: (req) => `${req.total_hours}h`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Stav",
|
||||||
|
width: "16%",
|
||||||
|
render: (req) => (
|
||||||
|
<StatusChip
|
||||||
|
label={statusLabels[req.status] || req.status}
|
||||||
|
color={statusColor[req.status] ?? "default"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notes",
|
||||||
|
header: "Poznámka",
|
||||||
|
width: "20%",
|
||||||
|
render: (req) => {
|
||||||
|
const truncate = (text: string) =>
|
||||||
|
text.length > 40 ? `${text.substring(0, 40)}...` : text;
|
||||||
|
if (req.status === "rejected" && req.reviewer_note) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={req.reviewer_note}
|
||||||
|
sx={{
|
||||||
|
color: "error.main",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
display: "block",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncate(req.reviewer_note)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (req.notes) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={req.notes}
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
display: "block",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncate(req.notes)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||||
|
—
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at",
|
||||||
|
header: "Podáno",
|
||||||
|
width: "13%",
|
||||||
|
mono: true,
|
||||||
|
render: (req) => formatDatetime(req.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
width: "8%",
|
||||||
|
align: "right",
|
||||||
|
render: (req) =>
|
||||||
|
req.status === "pending" ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={CancelIcon}
|
||||||
|
onClick={() => setCancelModal({ open: true, id: req.id })}
|
||||||
|
>
|
||||||
|
Zrušit
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Moje žádosti"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
subtitle="Přehled žádostí o nepřítomnost"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
/>
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Moje žádosti</h1>
|
|
||||||
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<Card>
|
||||||
className="admin-card"
|
<DataTable<LeaveRequest>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
columns={columns}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
rows={requests}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
rowKey={(req) => req.id}
|
||||||
>
|
empty={
|
||||||
<div className="admin-card-body">
|
<EmptyState
|
||||||
{requests.length === 0 ? (
|
title="Zatím nemáte žádné žádosti"
|
||||||
<div className="admin-empty-state">
|
description="Novou žádost můžete podat na stránce Docházka"
|
||||||
<div className="admin-empty-icon">
|
/>
|
||||||
<svg
|
}
|
||||||
width="28"
|
/>
|
||||||
height="28"
|
</Card>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>Zatím nemáte žádné žádosti</p>
|
|
||||||
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
|
|
||||||
Novou žádost můžete podat na stránce Docházka
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Od</th>
|
|
||||||
<th>Do</th>
|
|
||||||
<th>Dny</th>
|
|
||||||
<th>Hodiny</th>
|
|
||||||
<th>Stav</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
<th>Podáno</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{requests.map((req) => (
|
|
||||||
<tr key={req.id}>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
|
||||||
>
|
|
||||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(req.date_from)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">{formatDate(req.date_to)}</td>
|
|
||||||
<td className="admin-mono">{req.total_days}</td>
|
|
||||||
<td className="admin-mono">{req.total_hours}h</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${statusClasses[req.status] || ""}`}
|
|
||||||
>
|
|
||||||
{statusLabels[req.status] || req.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ maxWidth: "200px" }}>
|
|
||||||
{renderNoteCell(req)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="admin-mono"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{formatDatetime(req.created_at)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
{req.status === "pending" && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCancelModal({ open: true, id: req.id })
|
|
||||||
}
|
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
>
|
|
||||||
Zrušit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={cancelModal.open}
|
isOpen={cancelModal.open}
|
||||||
onClose={() => setCancelModal({ open: false, id: null })}
|
onClose={() => setCancelModal({ open: false, id: null })}
|
||||||
onConfirm={handleCancel}
|
onConfirm={handleCancel}
|
||||||
title="Zrušit žádost"
|
title="Zrušit žádost"
|
||||||
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
|
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
|
||||||
confirmText="Zrušit žádost"
|
confirmText="Zrušit žádost"
|
||||||
type="warning"
|
|
||||||
loading={cancelMutation.isPending}
|
loading={cancelMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import { keyframes } from "@mui/system";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
@@ -8,7 +14,13 @@ import {
|
|||||||
shouldShowSessionExpiredAlert,
|
shouldShowSessionExpiredAlert,
|
||||||
shouldShowLogoutAlert,
|
shouldShowLogoutAlert,
|
||||||
} from "../utils/api";
|
} from "../utils/api";
|
||||||
import FormField from "../components/FormField";
|
import { TextField, Button, CheckboxField, Field } from "../ui";
|
||||||
|
|
||||||
|
// Drift animation for the decorative background orbs (was @keyframes float).
|
||||||
|
const float = keyframes({
|
||||||
|
"0%, 100%": { transform: "translate(0, 0)" },
|
||||||
|
"50%": { transform: "translate(30px, -30px)" },
|
||||||
|
});
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
|
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
|
||||||
@@ -132,11 +144,17 @@ export default function Login() {
|
|||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-login">
|
<Box
|
||||||
<div className="admin-loading">
|
sx={{
|
||||||
<div className="admin-spinner" />
|
minHeight: ["100vh", "100dvh"],
|
||||||
</div>
|
display: "flex",
|
||||||
</div>
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,32 +162,120 @@ export default function Login() {
|
|||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared glass card sx (replaces .admin-login-card)
|
||||||
|
const cardSx = (theme: Theme) => ({
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
maxHeight: "calc(100dvh - 2rem)",
|
||||||
|
p: { xs: 4, sm: 5 },
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: "16px",
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.04)",
|
||||||
|
...theme.applyStyles("dark", {
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.15)",
|
||||||
|
}),
|
||||||
|
position: "relative" as const,
|
||||||
|
zIndex: 1,
|
||||||
|
overflowY: "auto" as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Framer entrance + shake choreography for the card panels (unchanged)
|
||||||
|
const cardMotion = {
|
||||||
|
initial: { opacity: 0, y: 30 },
|
||||||
|
animate: shake
|
||||||
|
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||||
|
: { opacity: 1, y: 0 },
|
||||||
|
exit: { opacity: 0, y: -20 },
|
||||||
|
transition: shake
|
||||||
|
? { x: { duration: 0.5, ease: "easeOut" as const } }
|
||||||
|
: { duration: 0.3 },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-login"
|
component={motion.div}
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
animate={
|
animate={
|
||||||
animatingOut
|
animatingOut
|
||||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||||
: { scale: 1, opacity: 1, filter: "none" }
|
: { scale: 1, opacity: 1, filter: "none" }
|
||||||
}
|
}
|
||||||
transition={
|
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||||
animatingOut
|
sx={{
|
||||||
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
minHeight: ["100vh", "100dvh"],
|
||||||
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
maxHeight: ["100vh", "100dvh"],
|
||||||
}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
p: 2,
|
||||||
|
bgcolor: "background.default",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-orb bg-orb-1" />
|
{/* Animated background orbs (replaces .bg-orb) */}
|
||||||
<div className="bg-orb bg-orb-2" />
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
borderRadius: "50%",
|
||||||
|
filter: "blur(80px)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
background: "rgba(var(--mui-palette-primary-mainChannel) / 0.16)",
|
||||||
|
top: "10%",
|
||||||
|
left: "20%",
|
||||||
|
animation: `${float} 20s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
borderRadius: "50%",
|
||||||
|
filter: "blur(80px)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
width: 320,
|
||||||
|
height: 320,
|
||||||
|
background: "rgba(120, 119, 198, 0.13)",
|
||||||
|
bottom: "20%",
|
||||||
|
right: "15%",
|
||||||
|
animation: `${float} 25s ease-in-out infinite reverse`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
{/* Theme toggle (replaces .admin-login-theme-btn) */}
|
||||||
|
<IconButton
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="admin-login-theme-btn"
|
|
||||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "1rem",
|
||||||
|
right: "1rem",
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
color: "text.secondary",
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 10,
|
||||||
|
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
color: "text.primary",
|
||||||
|
borderColor: "rgba(var(--mui-palette-text-primaryChannel) / 0.15)",
|
||||||
|
transform: "scale(1.05)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{theme === "light" ? (
|
||||||
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
height="20"
|
height="20"
|
||||||
@@ -181,10 +287,7 @@ export default function Login() {
|
|||||||
<circle cx="12" cy="12" r="5" />
|
<circle cx="12" cy="12" r="5" />
|
||||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
) : (
|
||||||
<span
|
|
||||||
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
height="20"
|
height="20"
|
||||||
@@ -195,122 +298,112 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
)}
|
||||||
</button>
|
</IconButton>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!show2FA ? (
|
{!show2FA ? (
|
||||||
<motion.div
|
<Box key="login" component={motion.div} {...cardMotion} sx={cardSx}>
|
||||||
key="login"
|
<Box sx={{ textAlign: "center", mb: 4 }}>
|
||||||
className="admin-login-card"
|
<Box
|
||||||
initial={{ opacity: 0, y: 30 }}
|
component="img"
|
||||||
animate={
|
|
||||||
shake
|
|
||||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
|
||||||
: { opacity: 1, y: 0 }
|
|
||||||
}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={
|
|
||||||
shake
|
|
||||||
? { x: { duration: 0.5, ease: "easeOut" } }
|
|
||||||
: { duration: 0.3 }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="admin-login-header">
|
|
||||||
<img
|
|
||||||
src={
|
src={
|
||||||
theme === "dark"
|
theme === "dark"
|
||||||
? "/api/admin/company-settings/logo?variant=dark"
|
? "/api/admin/company-settings/logo?variant=dark"
|
||||||
: "/api/admin/company-settings/logo?variant=light"
|
: "/api/admin/company-settings/logo?variant=light"
|
||||||
}
|
}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
className="admin-login-logo"
|
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src =
|
(e.target as HTMLImageElement).src =
|
||||||
theme === "dark"
|
theme === "dark"
|
||||||
? "/images/logo-dark.png"
|
? "/images/logo-dark.png"
|
||||||
: "/images/logo-light.png";
|
: "/images/logo-light.png";
|
||||||
}}
|
}}
|
||||||
|
sx={{ height: 48, width: "auto", mb: 2 }}
|
||||||
/>
|
/>
|
||||||
<h1 className="admin-login-title">Interní systém</h1>
|
<Typography
|
||||||
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>
|
variant="h1"
|
||||||
</div>
|
sx={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "text.primary",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Interní systém
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
|
||||||
|
Přihlaste se ke svému účtu
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="admin-form">
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
<FormField label="Uživatelské jméno nebo e-mail">
|
<Field label="Uživatelské jméno nebo e-mail">
|
||||||
<input
|
<TextField
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Zadejte uživatelské jméno"
|
placeholder="Zadejte uživatelské jméno"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Heslo">
|
<Field label="Heslo">
|
||||||
<input
|
<TextField
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Zadejte heslo"
|
placeholder="Zadejte heslo"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<label className="admin-form-checkbox">
|
<Box sx={{ mb: 2 }}>
|
||||||
<input
|
<CheckboxField
|
||||||
type="checkbox"
|
label="Zapamatovat si mě"
|
||||||
checked={remember}
|
checked={remember}
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
onChange={setRemember}
|
||||||
/>
|
/>
|
||||||
<span>Zapamatovat si mě</span>
|
</Box>
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading} fullWidth>
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<CircularProgress
|
||||||
className="admin-spinner"
|
size={20}
|
||||||
style={{ width: 20, height: 20, borderWidth: 2 }}
|
thickness={5}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
/>
|
/>
|
||||||
Přihlašování...
|
Přihlašování...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Přihlásit se"
|
"Přihlásit se"
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<Box key="2fa" component={motion.div} {...cardMotion} sx={cardSx}>
|
||||||
key="2fa"
|
<Box sx={{ textAlign: "center", mb: 4 }}>
|
||||||
className="admin-login-card"
|
<Box
|
||||||
initial={{ opacity: 0, y: 30 }}
|
sx={{
|
||||||
animate={
|
display: "inline-flex",
|
||||||
shake
|
alignItems: "center",
|
||||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
justifyContent: "center",
|
||||||
: { opacity: 1, y: 0 }
|
width: 56,
|
||||||
}
|
height: 56,
|
||||||
exit={{ opacity: 0, y: -20 }}
|
borderRadius: "50%",
|
||||||
transition={
|
background:
|
||||||
shake
|
"rgba(var(--mui-palette-primary-mainChannel) / 0.1)",
|
||||||
? { x: { duration: 0.5, ease: "easeOut" } }
|
color: "primary.main",
|
||||||
: { duration: 0.3 }
|
mb: 2,
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-login-header">
|
|
||||||
<div className="admin-login-2fa-icon">
|
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
@@ -322,26 +415,31 @@ export default function Login() {
|
|||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Box>
|
||||||
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
|
<Typography
|
||||||
<p className="admin-login-subtitle">
|
variant="h1"
|
||||||
|
sx={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "text.primary",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dvoufaktorové ověření
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
|
||||||
{useBackupCode
|
{useBackupCode
|
||||||
? "Zadejte jeden ze záložních kódů"
|
? "Zadejte jeden ze záložních kódů"
|
||||||
: "Zadejte 6místný kód z autentizační aplikace"}
|
: "Zadejte 6místný kód z autentizační aplikace"}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<form onSubmit={handle2FASubmit} className="admin-form">
|
<Box component="form" onSubmit={handle2FASubmit}>
|
||||||
<FormField
|
<Field label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}>
|
||||||
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
|
<TextField
|
||||||
>
|
inputRef={totpInputRef}
|
||||||
<input
|
|
||||||
ref={totpInputRef}
|
|
||||||
id="totp-code"
|
id="totp-code"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode={useBackupCode ? "text" : "numeric"}
|
|
||||||
pattern={useBackupCode ? undefined : "[0-9]*"}
|
|
||||||
maxLength={useBackupCode ? 8 : 6}
|
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = useBackupCode
|
const val = useBackupCode
|
||||||
@@ -351,80 +449,93 @@ export default function Login() {
|
|||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
className="admin-form-input"
|
|
||||||
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
|
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
|
||||||
style={
|
slotProps={{
|
||||||
useBackupCode
|
htmlInput: {
|
||||||
? {}
|
inputMode: useBackupCode ? "text" : "numeric",
|
||||||
: {
|
pattern: useBackupCode ? undefined : "[0-9]*",
|
||||||
textAlign: "center",
|
maxLength: useBackupCode ? 8 : 6,
|
||||||
fontSize: "1.5rem",
|
style: useBackupCode
|
||||||
letterSpacing: "0.5rem",
|
? {}
|
||||||
fontFamily: "monospace",
|
: {
|
||||||
}
|
textAlign: "center",
|
||||||
}
|
fontSize: "1.5rem",
|
||||||
|
letterSpacing: "0.5rem",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading} fullWidth>
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<CircularProgress
|
||||||
className="admin-spinner"
|
size={20}
|
||||||
style={{ width: 20, height: 20, borderWidth: 2 }}
|
thickness={5}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
/>
|
/>
|
||||||
Ověřování...
|
Ověřování...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Ověřit"
|
"Ověřit"
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Box>
|
||||||
|
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "0.5rem",
|
gap: 1,
|
||||||
marginTop: "0.5rem",
|
mt: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUseBackupCode(!useBackupCode);
|
setUseBackupCode(!useBackupCode);
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
}}
|
}}
|
||||||
className="admin-back-link"
|
sx={{
|
||||||
style={{
|
color: "text.secondary",
|
||||||
border: "none",
|
fontSize: "0.875rem",
|
||||||
background: "none",
|
fontWeight: 400,
|
||||||
cursor: "pointer",
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
color: "text.primary",
|
||||||
|
textDecoration: "underline",
|
||||||
|
background: "transparent",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{useBackupCode
|
{useBackupCode
|
||||||
? "Použít autentizační aplikaci"
|
? "Použít autentizační aplikaci"
|
||||||
: "Použít záložní kód"}
|
: "Použít záložní kód"}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="text"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="admin-back-link"
|
sx={{
|
||||||
style={{
|
color: "text.secondary",
|
||||||
border: "none",
|
fontSize: "0.875rem",
|
||||||
background: "none",
|
fontWeight: 400,
|
||||||
cursor: "pointer",
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
color: "text.primary",
|
||||||
|
textDecoration: "underline",
|
||||||
|
background: "transparent",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
← Zpět na přihlášení
|
← Zpět na přihlášení
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,28 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-empty-state"
|
sx={{
|
||||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
display: "flex",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
flexDirection: "column",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
alignItems: "center",
|
||||||
transition={{ duration: 0.25 }}
|
justifyContent: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
gap: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Typography variant="h1" sx={{ fontSize: "6rem", fontWeight: 700 }}>
|
||||||
className="admin-empty-icon"
|
|
||||||
style={{ width: 80, height: 80, marginBottom: "1.5rem" }}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="36"
|
|
||||||
height="36"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
|
|
||||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
|
||||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
404
|
404
|
||||||
</h2>
|
</Typography>
|
||||||
<p>Stránka nebyla nalezena.</p>
|
<Typography variant="body1">Stránka nebyla nalezena.</Typography>
|
||||||
<Link
|
<Button component={RouterLink} to="/" sx={{ mt: 0.5 }}>
|
||||||
to="/"
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
style={{ marginTop: "0.5rem" }}
|
|
||||||
>
|
|
||||||
Zpět na Dashboard
|
Zpět na Dashboard
|
||||||
</Link>
|
</Button>
|
||||||
</motion.div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,42 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
import apiFetch from "../utils/api";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
|
|
||||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||||
import SortIcon from "../components/SortIcon";
|
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import { orderListOptions } from "../lib/queries/orders";
|
import {
|
||||||
|
orderListOptions,
|
||||||
|
orderNextNumberOptions,
|
||||||
|
} from "../lib/queries/orders";
|
||||||
|
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import Pagination from "../components/Pagination";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
StatusChip,
|
||||||
|
CheckboxField,
|
||||||
|
FileUpload,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
FilterBar,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -23,11 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
stornovana: "Stornována",
|
stornovana: "Stornována",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CLASSES: Record<string, string> = {
|
const STATUS_COLORS: Record<
|
||||||
prijata: "admin-badge-order-prijata",
|
string,
|
||||||
v_realizaci: "admin-badge-order-realizace",
|
"default" | "success" | "error" | "warning" | "info"
|
||||||
dokoncena: "admin-badge-order-dokoncena",
|
> = {
|
||||||
stornovana: "admin-badge-order-stornovana",
|
prijata: "info",
|
||||||
|
v_realizaci: "warning",
|
||||||
|
dokoncena: "success",
|
||||||
|
stornovana: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
@@ -43,12 +70,94 @@ interface Order {
|
|||||||
invoice_id?: number;
|
invoice_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const ViewIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const InvoiceViewIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<text
|
||||||
|
x="12"
|
||||||
|
y="16.5"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
fontSize="9"
|
||||||
|
fontWeight="700"
|
||||||
|
>
|
||||||
|
F
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const InvoiceCreateIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="12" y1="11" x2="12" y2="17" />
|
||||||
|
<line x1="9" y1="14" x2="15" y2="14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function Orders() {
|
export default function Orders() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
const { sort, order, handleSort, activeSort } = useTableSort("order_number");
|
const { sort, order, handleSort } = useTableSort("order_number");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
@@ -71,12 +180,134 @@ export default function Orders() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
customer_id: "",
|
||||||
|
customer_order_number: "",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: "21",
|
||||||
|
apply_vat: true,
|
||||||
|
scope_title: "",
|
||||||
|
scope_description: "",
|
||||||
|
notes: "",
|
||||||
|
create_project: true,
|
||||||
|
price: "",
|
||||||
|
quantity: "1",
|
||||||
|
});
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const customersQuery = useQuery({
|
||||||
|
...offerCustomersOptions(),
|
||||||
|
enabled: showCreate,
|
||||||
|
});
|
||||||
|
const nextNumberQuery = useQuery({
|
||||||
|
...orderNextNumberOptions(),
|
||||||
|
enabled: showCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setCreateForm({
|
||||||
|
customer_id: "",
|
||||||
|
customer_order_number: "",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: "21",
|
||||||
|
apply_vat: true,
|
||||||
|
scope_title: "",
|
||||||
|
scope_description: "",
|
||||||
|
notes: "",
|
||||||
|
create_project: true,
|
||||||
|
price: "",
|
||||||
|
quantity: "1",
|
||||||
|
});
|
||||||
|
setOrderAttachment(null);
|
||||||
|
setShowCreate(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const priceNum = createForm.price ? Number(createForm.price) : 0;
|
||||||
|
const qtyNum = createForm.quantity ? Number(createForm.quantity) : 1;
|
||||||
|
const items =
|
||||||
|
priceNum > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
description: createForm.scope_title || "Položka",
|
||||||
|
quantity: qtyNum,
|
||||||
|
unit_price: priceNum,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let fetchOptions: RequestInit;
|
||||||
|
if (orderAttachment) {
|
||||||
|
const fd = new FormData();
|
||||||
|
if (createForm.customer_id)
|
||||||
|
fd.append("customer_id", createForm.customer_id);
|
||||||
|
fd.append("customer_order_number", createForm.customer_order_number);
|
||||||
|
fd.append("currency", createForm.currency);
|
||||||
|
fd.append("vat_rate", createForm.vat_rate);
|
||||||
|
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
|
||||||
|
fd.append("scope_title", createForm.scope_title);
|
||||||
|
fd.append("scope_description", createForm.scope_description);
|
||||||
|
fd.append("notes", createForm.notes);
|
||||||
|
fd.append("create_project", createForm.create_project ? "1" : "0");
|
||||||
|
if (items) fd.append("items", JSON.stringify(items));
|
||||||
|
fd.append("attachment", orderAttachment);
|
||||||
|
fetchOptions = { method: "POST", body: fd };
|
||||||
|
} else {
|
||||||
|
fetchOptions = {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_id: createForm.customer_id
|
||||||
|
? Number(createForm.customer_id)
|
||||||
|
: null,
|
||||||
|
customer_order_number: createForm.customer_order_number,
|
||||||
|
currency: createForm.currency,
|
||||||
|
vat_rate: createForm.vat_rate,
|
||||||
|
apply_vat: createForm.apply_vat,
|
||||||
|
scope_title: createForm.scope_title,
|
||||||
|
scope_description: createForm.scope_description,
|
||||||
|
notes: createForm.notes,
|
||||||
|
create_project: createForm.create_project,
|
||||||
|
...(items ? { items } : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
setShowCreate(false);
|
||||||
|
alert.success(result.message || "Objednávka byla vytvořena");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
if (result.data?.id) navigate(`/orders/${result.data.id}`);
|
||||||
|
} else {
|
||||||
|
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error("Chyba připojení");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
items: orders,
|
items: orders,
|
||||||
pagination,
|
pagination,
|
||||||
isPending,
|
isPending,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = usePaginatedQuery<Order>(orderListOptions({ search, sort, order, page }));
|
} = usePaginatedQuery<Order>(
|
||||||
|
orderListOptions({ search: debouncedSearch, sort, order, page }),
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||||
|
|
||||||
@@ -93,260 +324,195 @@ export default function Orders() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const total = pagination?.total ?? orders.length;
|
||||||
<div>
|
const subtitle = `${total} ${czechPlural(
|
||||||
<motion.div
|
total,
|
||||||
className="admin-page-header"
|
"objednávka",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
"objednávky",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
"objednávek",
|
||||||
transition={{ duration: 0.25 }}
|
)}`;
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Objednávky</h1>
|
|
||||||
<p className="admin-page-subtitle">
|
|
||||||
{pagination?.total ?? orders.length}{" "}
|
|
||||||
{czechPlural(
|
|
||||||
pagination?.total ?? orders.length,
|
|
||||||
"objednávka",
|
|
||||||
"objednávky",
|
|
||||||
"objednávek",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
const columns: DataColumn<Order>[] = [
|
||||||
className="admin-card"
|
{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
key: "order_number",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
header: "Číslo",
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
width: "14%",
|
||||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
sortKey: "order_number",
|
||||||
>
|
mono: true,
|
||||||
<div className="admin-card-body">
|
render: (o) => (
|
||||||
<div className="admin-search-bar mb-4">
|
<Box
|
||||||
<input
|
component={RouterLink}
|
||||||
type="text"
|
to={`/orders/${o.id}`}
|
||||||
value={search}
|
sx={{
|
||||||
onChange={(e) => {
|
color: "primary.main",
|
||||||
setSearch(e.target.value);
|
textDecoration: "none",
|
||||||
setPage(1);
|
"&:hover": { textDecoration: "underline" },
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
>
|
||||||
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
|
{o.order_number}
|
||||||
/>
|
</Box>
|
||||||
</div>
|
),
|
||||||
|
},
|
||||||
{orders.length === 0 ? (
|
{
|
||||||
<div className="admin-empty-state">
|
key: "quotation",
|
||||||
<div className="admin-empty-icon">
|
header: "Nabídka",
|
||||||
<svg
|
width: "14%",
|
||||||
width="28"
|
render: (o) => (
|
||||||
height="28"
|
<Box
|
||||||
viewBox="0 0 24 24"
|
component={RouterLink}
|
||||||
fill="none"
|
to={`/offers/${o.quotation_id}`}
|
||||||
stroke="currentColor"
|
sx={{
|
||||||
strokeWidth="1.5"
|
color: "text.secondary",
|
||||||
strokeLinecap="round"
|
textDecoration: "none",
|
||||||
strokeLinejoin="round"
|
"&:hover": { textDecoration: "underline" },
|
||||||
>
|
}}
|
||||||
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
>
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
{o.quotation_number}
|
||||||
<path d="M16 10a4 4 0 0 1-8 0" />
|
</Box>
|
||||||
</svg>
|
),
|
||||||
</div>
|
},
|
||||||
<p>Zatím nejsou žádné objednávky.</p>
|
{
|
||||||
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
|
key: "customer",
|
||||||
Objednávky se vytvářejí z nabídek.
|
header: "Zákazník",
|
||||||
</p>
|
width: "20%",
|
||||||
</div>
|
render: (o) => o.customer_name || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Stav",
|
||||||
|
width: "13%",
|
||||||
|
sortKey: "status",
|
||||||
|
render: (o) => (
|
||||||
|
<StatusChip
|
||||||
|
label={STATUS_LABELS[o.status] || o.status}
|
||||||
|
color={STATUS_COLORS[o.status] || "default"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at",
|
||||||
|
header: "Datum",
|
||||||
|
width: "12%",
|
||||||
|
sortKey: "created_at",
|
||||||
|
mono: true,
|
||||||
|
render: (o) => formatDate(o.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "total",
|
||||||
|
header: "Celkem",
|
||||||
|
width: "13%",
|
||||||
|
align: "right",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (o) => formatCurrency(o.total, o.currency),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
width: "14%",
|
||||||
|
align: "right",
|
||||||
|
render: (o) => (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/orders/${o.id}`)}
|
||||||
|
aria-label="Detail"
|
||||||
|
title="Detail"
|
||||||
|
>
|
||||||
|
{ViewIcon}
|
||||||
|
</IconButton>
|
||||||
|
{o.invoice_id ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/invoices/${o.invoice_id}`)}
|
||||||
|
aria-label="Zobrazit fakturu"
|
||||||
|
title="Zobrazit fakturu"
|
||||||
|
>
|
||||||
|
{InvoiceViewIcon}
|
||||||
|
</IconButton>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-table-responsive">
|
hasPermission("invoices.create") && (
|
||||||
<table className="admin-table">
|
<IconButton
|
||||||
<thead>
|
size="small"
|
||||||
<tr>
|
onClick={() => navigate(`/invoices/new?fromOrder=${o.id}`)}
|
||||||
<th
|
aria-label="Vytvořit fakturu"
|
||||||
style={{ cursor: "pointer" }}
|
title="Vytvořit fakturu"
|
||||||
onClick={() => handleSort("order_number")}
|
>
|
||||||
>
|
{InvoiceCreateIcon}
|
||||||
Číslo{" "}
|
</IconButton>
|
||||||
<SortIcon
|
)
|
||||||
column="order_number"
|
|
||||||
sort={activeSort}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>Nabídka</th>
|
|
||||||
<th>Zákazník</th>
|
|
||||||
<th
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => handleSort("status")}
|
|
||||||
>
|
|
||||||
Stav{" "}
|
|
||||||
<SortIcon
|
|
||||||
column="status"
|
|
||||||
sort={activeSort}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => handleSort("created_at")}
|
|
||||||
>
|
|
||||||
Datum{" "}
|
|
||||||
<SortIcon
|
|
||||||
column="created_at"
|
|
||||||
sort={activeSort}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th className="text-right">Celkem</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(orders as Order[]).map((o) => (
|
|
||||||
<tr key={o.id}>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<Link to={`/orders/${o.id}`} className="link-accent">
|
|
||||||
{o.order_number}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link
|
|
||||||
to={`/offers/${o.quotation_id}`}
|
|
||||||
className="text-secondary"
|
|
||||||
style={{ textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
{o.quotation_number}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td>{o.customer_name || "—"}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${STATUS_CLASSES[o.status] || ""}`}
|
|
||||||
>
|
|
||||||
{STATUS_LABELS[o.status] || o.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">{formatDate(o.created_at)}</td>
|
|
||||||
<td className="admin-mono text-right fw-500">
|
|
||||||
{formatCurrency(o.total, o.currency)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<Link
|
|
||||||
to={`/orders/${o.id}`}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Detail"
|
|
||||||
aria-label="Detail"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
{o.invoice_id ? (
|
|
||||||
<Link
|
|
||||||
to={`/invoices/${o.invoice_id}`}
|
|
||||||
className="admin-btn-icon accent"
|
|
||||||
title="Zobrazit fakturu"
|
|
||||||
aria-label="Zobrazit fakturu"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<text
|
|
||||||
x="12"
|
|
||||||
y="16.5"
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="currentColor"
|
|
||||||
stroke="none"
|
|
||||||
fontSize="9"
|
|
||||||
fontWeight="700"
|
|
||||||
>
|
|
||||||
F
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
hasPermission("invoices.create") && (
|
|
||||||
<Link
|
|
||||||
to={`/invoices/new?fromOrder=${o.id}`}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Vytvořit fakturu"
|
|
||||||
aria-label="Vytvořit fakturu"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="12" y1="11" x2="12" y2="17" />
|
|
||||||
<line x1="9" y1="14" x2="15" y2="14" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{hasPermission("orders.delete") && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteConfirm({ show: true, order: o })
|
|
||||||
}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
{hasPermission("orders.delete") && (
|
||||||
</div>
|
<IconButton
|
||||||
</motion.div>
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||||
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
|
>
|
||||||
|
{DeleteIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<ConfirmModal
|
return (
|
||||||
|
<PageEnter>
|
||||||
|
<PageHeader
|
||||||
|
title="Objednávky"
|
||||||
|
subtitle={subtitle}
|
||||||
|
actions={
|
||||||
|
hasPermission("orders.create") ? (
|
||||||
|
<Button startIcon={PlusIcon} onClick={openCreate}>
|
||||||
|
Vytvořit objednávku
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterBar>
|
||||||
|
<Box sx={{ flex: "1 1 320px" }}>
|
||||||
|
<TextField
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||||
|
<DataTable<Order>
|
||||||
|
columns={columns}
|
||||||
|
rows={orders}
|
||||||
|
rowKey={(o) => o.id}
|
||||||
|
sortBy={sort}
|
||||||
|
sortDir={order}
|
||||||
|
onSort={handleSort}
|
||||||
|
empty={
|
||||||
|
<EmptyState
|
||||||
|
title="Zatím nejsou žádné objednávky."
|
||||||
|
description="Objednávky se vytvářejí z nabídek."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteConfirm({ show: false, order: null });
|
setDeleteConfirm({ show: false, order: null });
|
||||||
@@ -355,28 +521,184 @@ export default function Orders() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat objednávku"
|
title="Smazat objednávku"
|
||||||
message={
|
message={
|
||||||
<>
|
deleteConfirm.order
|
||||||
Opravdu chcete smazat objednávku "
|
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
|
||||||
{deleteConfirm.order?.order_number}"? Bude smazán i přidružený
|
: ""
|
||||||
projekt. Tato akce je nevratná.
|
|
||||||
<label
|
|
||||||
className="admin-form-checkbox"
|
|
||||||
style={{ marginTop: "1rem", display: "flex" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={deleteFiles}
|
|
||||||
onChange={(e) => setDeleteFiles(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Smazat i soubory projektu na disku</span>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
confirmVariant="danger"
|
||||||
type="danger"
|
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
/>
|
>
|
||||||
</div>
|
<CheckboxField
|
||||||
|
label="Smazat i soubory projektu na disku"
|
||||||
|
checked={deleteFiles}
|
||||||
|
onChange={setDeleteFiles}
|
||||||
|
/>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
title="Vytvořit objednávku bez nabídky"
|
||||||
|
subtitle={`Číslo objednávky: ${
|
||||||
|
nextNumberQuery.data?.number ??
|
||||||
|
nextNumberQuery.data?.next_number ??
|
||||||
|
"…"
|
||||||
|
} (přiděleno automaticky)`}
|
||||||
|
submitText="Vytvořit"
|
||||||
|
loading={creating}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Zákazník">
|
||||||
|
<Select
|
||||||
|
value={createForm.customer_id}
|
||||||
|
onChange={(value) =>
|
||||||
|
setCreateForm({ ...createForm, customer_id: value })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "— bez zákazníka —" },
|
||||||
|
...(customersQuery.data ?? []).map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Číslo objednávky zákazníka">
|
||||||
|
<TextField
|
||||||
|
value={createForm.customer_order_number}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({
|
||||||
|
...createForm,
|
||||||
|
customer_order_number: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Měna">
|
||||||
|
<Select
|
||||||
|
value={createForm.currency}
|
||||||
|
onChange={(value) =>
|
||||||
|
setCreateForm({ ...createForm, currency: value })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "CZK", label: "CZK" },
|
||||||
|
{ value: "EUR", label: "EUR" },
|
||||||
|
{ value: "USD", label: "USD" },
|
||||||
|
{ value: "GBP", label: "GBP" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Sazba DPH (%)">
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={createForm.vat_rate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, vat_rate: e.target.value })
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Cena za jednotku (bez DPH)">
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={createForm.price}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, price: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Množství">
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={createForm.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, quantity: e.target.value })
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Předmět (scope)">
|
||||||
|
<TextField
|
||||||
|
value={createForm.scope_title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, scope_title: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Popis">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={createForm.scope_description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({
|
||||||
|
...createForm,
|
||||||
|
scope_description: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Poznámka">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={createForm.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, notes: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Příloha (PO zákazníka, PDF)">
|
||||||
|
<FileUpload
|
||||||
|
files={orderAttachment ? [orderAttachment] : []}
|
||||||
|
onFilesChange={(files) => setOrderAttachment(files[0] ?? null)}
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple={false}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<CheckboxField
|
||||||
|
label="Účtovat DPH"
|
||||||
|
checked={createForm.apply_vat}
|
||||||
|
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxField
|
||||||
|
label="Vytvořit propojený projekt"
|
||||||
|
checked={createForm.create_project}
|
||||||
|
onChange={(v) => setCreateForm({ ...createForm, create_project: v })}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +15,7 @@ import PlanGrid from "../components/PlanGrid";
|
|||||||
import PlanCellModal from "../components/PlanCellModal";
|
import PlanCellModal from "../components/PlanCellModal";
|
||||||
import PlanCategoriesModal from "../components/PlanCategoriesModal";
|
import PlanCategoriesModal from "../components/PlanCategoriesModal";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import { Button, Alert, LoadingState, PageEnter } from "../ui";
|
||||||
import { projectListOptions, type Project } from "../lib/queries/projects";
|
import { projectListOptions, type Project } from "../lib/queries/projects";
|
||||||
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
|
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
|
||||||
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
|
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
|
||||||
@@ -542,121 +545,109 @@ export default function PlanWork() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-work-page">
|
<PageEnter sx={{ position: "relative" }}>
|
||||||
<motion.h1
|
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
|
||||||
className="admin-page-title"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
>
|
|
||||||
Plán prací
|
Plán prací
|
||||||
</motion.h1>
|
</Typography>
|
||||||
|
|
||||||
{!canEdit && (
|
{!canEdit && (
|
||||||
<motion.div
|
<Box role="status" sx={{ mb: 2 }}>
|
||||||
className="plan-banner"
|
<Alert severity="info">
|
||||||
role="status"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
|
||||||
>
|
|
||||||
<span className="plan-banner-icon" aria-hidden>
|
|
||||||
i
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
||||||
jsou dostupné oprávněným uživatelům.
|
jsou dostupné oprávněným uživatelům.
|
||||||
</span>
|
</Alert>
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<motion.div
|
<Box
|
||||||
className="plan-toolbar"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "flex",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
flexWrap: "wrap",
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
alignItems: "center",
|
||||||
|
gap: 1.5,
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{/* Nav buttons grouped so they stay on one row when the toolbar
|
||||||
type="button"
|
wraps on mobile. */}
|
||||||
className="admin-btn admin-btn-secondary"
|
<Box sx={{ display: "inline-flex", gap: 1 }}>
|
||||||
onClick={goToToday}
|
<Button variant="outlined" color="inherit" onClick={goToToday}>
|
||||||
|
Dnes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={goPrev}
|
||||||
|
aria-label="Předchozí"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={goNext}
|
||||||
|
aria-label="Další"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 220,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Dnes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={goPrev}
|
|
||||||
aria-label="Předchozí"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={goNext}
|
|
||||||
aria-label="Další"
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
<span className="plan-range-label-slot">
|
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
<motion.span
|
<motion.div
|
||||||
className="plan-range-label"
|
|
||||||
key={`${isoDate(range.from)}-${view}`}
|
key={`${isoDate(range.from)}-${view}`}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
>
|
||||||
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
<Typography
|
||||||
</motion.span>
|
variant="h6"
|
||||||
|
sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</span>
|
</Box>
|
||||||
<div
|
<Box
|
||||||
className="plan-view-toggle"
|
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Měřítko zobrazení"
|
aria-label="Měřítko zobrazení"
|
||||||
|
sx={{ display: "inline-flex", gap: 1 }}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant={view === "week" ? "contained" : "outlined"}
|
||||||
className={
|
color={view === "week" ? "primary" : "inherit"}
|
||||||
view === "week"
|
|
||||||
? "admin-btn admin-btn-primary"
|
|
||||||
: "admin-btn admin-btn-secondary"
|
|
||||||
}
|
|
||||||
onClick={() => setViewWithAnim("week")}
|
onClick={() => setViewWithAnim("week")}
|
||||||
>
|
>
|
||||||
Týden
|
Týden
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant={view === "month" ? "contained" : "outlined"}
|
||||||
className={
|
color={view === "month" ? "primary" : "inherit"}
|
||||||
view === "month"
|
|
||||||
? "admin-btn admin-btn-primary"
|
|
||||||
: "admin-btn admin-btn-secondary"
|
|
||||||
}
|
|
||||||
onClick={() => setViewWithAnim("month")}
|
onClick={() => setViewWithAnim("month")}
|
||||||
>
|
>
|
||||||
Měsíc
|
Měsíc
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary"
|
color="inherit"
|
||||||
onClick={() => setCatModalOpen(true)}
|
onClick={() => setCatModalOpen(true)}
|
||||||
>
|
>
|
||||||
Správa kategorií
|
Správa kategorií
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{gridLoading && (
|
{gridLoading && <LoadingState />}
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Grid entrance: same pattern as the h1 / banner / toolbar above
|
Grid entrance: same pattern as the h1 / banner / toolbar above
|
||||||
@@ -675,20 +666,14 @@ export default function PlanWork() {
|
|||||||
patch the cache in place and pulse a single cell via pulseKey.
|
patch the cache in place and pulse a single cell via pulseKey.
|
||||||
*/}
|
*/}
|
||||||
{grid ? (
|
{grid ? (
|
||||||
<motion.div
|
<PlanGrid
|
||||||
initial={{ opacity: 0, y: 12 }}
|
data={grid}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
projects={projects}
|
||||||
transition={{ duration: 0.25, ease: "easeOut", delay: 0.18 }}
|
categories={categories}
|
||||||
>
|
canEdit={canEdit}
|
||||||
<PlanGrid
|
pulseKey={lastMutated}
|
||||||
data={grid}
|
onCellClick={openCell}
|
||||||
projects={projects}
|
/>
|
||||||
categories={categories}
|
|
||||||
canEdit={canEdit}
|
|
||||||
pulseKey={lastMutated}
|
|
||||||
onCellClick={openCell}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PlanCellModal
|
<PlanCellModal
|
||||||
@@ -712,6 +697,6 @@ export default function PlanWork() {
|
|||||||
onClose={() => setCatModalOpen(false)}
|
onClose={() => setCatModalOpen(false)}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
projectDetailOptions,
|
projectDetailOptions,
|
||||||
@@ -13,11 +16,20 @@ import {
|
|||||||
} from "../lib/queries/projects";
|
} from "../lib/queries/projects";
|
||||||
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import ProjectFileManager from "../components/ProjectFileManager";
|
import ProjectFileManager from "../components/ProjectFileManager";
|
||||||
import apiFetch from "../utils/api";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
StatusChip,
|
||||||
|
CheckboxField,
|
||||||
|
ConfirmDialog,
|
||||||
|
LoadingState,
|
||||||
|
PageEnter,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -27,6 +39,15 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
zruseny: "Zrušený",
|
zruseny: "Zrušený",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<
|
||||||
|
string,
|
||||||
|
"default" | "success" | "error" | "warning" | "info"
|
||||||
|
> = {
|
||||||
|
aktivni: "success",
|
||||||
|
dokonceny: "info",
|
||||||
|
zruseny: "default",
|
||||||
|
};
|
||||||
|
|
||||||
function formatNoteDate(dateStr: string) {
|
function formatNoteDate(dateStr: string) {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return "";
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
@@ -51,6 +72,36 @@ interface ProjectForm {
|
|||||||
responsible_user_id: string;
|
responsible_user_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BackIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrashIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function ProjectDetail() {
|
export default function ProjectDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -81,10 +132,18 @@ export default function ProjectDetail() {
|
|||||||
const notes: ProjectNote[] = project?.project_notes || [];
|
const notes: ProjectNote[] = project?.project_notes || [];
|
||||||
|
|
||||||
const { data: usersData } = useQuery(userListOptions("projects.view"));
|
const { data: usersData } = useQuery(userListOptions("projects.view"));
|
||||||
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
|
// Only active users with projects.view — but keep the project's CURRENT
|
||||||
id: u.id,
|
// assignee in the list even if they're now inactive, so the select still
|
||||||
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
|
// reflects the saved value (marked "(neaktivní)" so it's clear).
|
||||||
}));
|
const assignedUserId = project?.responsible_user_id || "";
|
||||||
|
const users: User[] = (usersData ?? [])
|
||||||
|
.filter((u: ApiUser) => u.is_active || String(u.id) === assignedUserId)
|
||||||
|
.map((u: ApiUser) => ({
|
||||||
|
id: u.id,
|
||||||
|
name:
|
||||||
|
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
|
||||||
|
(u.is_active ? "" : " (neaktivní)"),
|
||||||
|
}));
|
||||||
|
|
||||||
// Reset form sync when navigating to a different project
|
// Reset form sync when navigating to a different project
|
||||||
const formInitialized = useRef(false);
|
const formInitialized = useRef(false);
|
||||||
@@ -218,424 +277,369 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
|
|
||||||
const canEdit = hasPermission("projects.edit");
|
const canEdit = hasPermission("projects.edit");
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<Box>
|
||||||
className="admin-page-header"
|
<Box
|
||||||
initial={{ opacity: 0, y: 12 }}
|
sx={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
display: "flex",
|
||||||
transition={{ duration: 0.25 }}
|
alignItems: "flex-start",
|
||||||
>
|
justifyContent: "space-between",
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
flexWrap: "wrap",
|
||||||
<Link
|
gap: 2,
|
||||||
to="/projects"
|
mb: 3,
|
||||||
className="admin-btn-icon"
|
}}
|
||||||
title="Zpět"
|
>
|
||||||
aria-label="Zpět"
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
>
|
<Button
|
||||||
<svg
|
component={RouterLink}
|
||||||
width="20"
|
to="/projects"
|
||||||
height="20"
|
variant="outlined"
|
||||||
viewBox="0 0 24 24"
|
color="inherit"
|
||||||
fill="none"
|
startIcon={BackIcon}
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
>
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
Zpět
|
||||||
</svg>
|
</Button>
|
||||||
</Link>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
<div>
|
<Typography variant="h4">
|
||||||
<h1 className="admin-page-title">
|
Projekt {project.project_number}
|
||||||
Projekt {project.project_number}
|
</Typography>
|
||||||
</h1>
|
<StatusChip
|
||||||
</div>
|
label={STATUS_LABELS[project.status] || project.status}
|
||||||
</div>
|
color={STATUS_COLORS[project.status] || "default"}
|
||||||
{canEdit && (
|
/>
|
||||||
<div className="admin-page-actions">
|
</Box>
|
||||||
<button
|
</Box>
|
||||||
onClick={handleSave}
|
{canEdit && (
|
||||||
className="admin-btn admin-btn-primary"
|
<Box
|
||||||
disabled={saving}
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{saving ? (
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
<>
|
{saving ? "Ukládání..." : "Uložit"}
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
</Button>
|
||||||
Ukládání...
|
{!project.order_id && (
|
||||||
</>
|
<Button
|
||||||
) : (
|
variant="outlined"
|
||||||
"Uložit"
|
color="error"
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
>
|
||||||
|
Smazat
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Box>
|
||||||
{!project.order_id && (
|
)}
|
||||||
<button
|
</Box>
|
||||||
onClick={() => setDeleteConfirm(true)}
|
</Box>
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
Smazat
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Form */}
|
{/* Basic info form */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Základní údaje
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box
|
||||||
>
|
sx={{
|
||||||
<div className="admin-card-body">
|
display: "grid",
|
||||||
<h3 className="admin-card-title">Základní údaje</h3>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<div className="admin-form">
|
gap: 2,
|
||||||
<div className="admin-form-row">
|
}}
|
||||||
<FormField label="Číslo projektu">
|
>
|
||||||
<input
|
<Field label="Číslo projektu">
|
||||||
type="text"
|
<TextField value={project.project_number} fullWidth disabled />
|
||||||
value={project.project_number}
|
</Field>
|
||||||
className="admin-form-input"
|
<Field label="Název">
|
||||||
readOnly
|
<TextField
|
||||||
style={{
|
value={form.name}
|
||||||
backgroundColor: "var(--bg-secondary)",
|
onChange={(e) => updateForm("name", e.target.value)}
|
||||||
cursor: "default",
|
placeholder="Název projektu"
|
||||||
}}
|
fullWidth
|
||||||
/>
|
disabled={!canEdit}
|
||||||
</FormField>
|
/>
|
||||||
<FormField label="Název">
|
</Field>
|
||||||
<input
|
<Field label="Zákazník">
|
||||||
type="text"
|
<TextField
|
||||||
value={form.name}
|
value={project.customer_name || "—"}
|
||||||
onChange={(e) => updateForm("name", e.target.value)}
|
fullWidth
|
||||||
className="admin-form-input"
|
disabled
|
||||||
placeholder="Název projektu"
|
/>
|
||||||
disabled={!canEdit}
|
</Field>
|
||||||
/>
|
<Field label="Zodpovědná osoba">
|
||||||
</FormField>
|
<Select
|
||||||
</div>
|
value={form.responsible_user_id}
|
||||||
|
onChange={(v) => updateForm("responsible_user_id", v)}
|
||||||
<div className="admin-form-row">
|
disabled={!canEdit}
|
||||||
<FormField label="Zákazník">
|
>
|
||||||
<input
|
<MenuItem value="">— Nevybráno —</MenuItem>
|
||||||
type="text"
|
{users.map((u) => (
|
||||||
value={project.customer_name || "—"}
|
<MenuItem key={u.id} value={String(u.id)}>
|
||||||
className="admin-form-input"
|
{u.name}
|
||||||
readOnly
|
</MenuItem>
|
||||||
style={{
|
))}
|
||||||
backgroundColor: "var(--bg-secondary)",
|
</Select>
|
||||||
cursor: "default",
|
</Field>
|
||||||
}}
|
</Box>
|
||||||
/>
|
<Box
|
||||||
</FormField>
|
sx={{
|
||||||
<FormField label="Zodpovědná osoba">
|
display: "grid",
|
||||||
<select
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||||
value={form.responsible_user_id}
|
gap: 2,
|
||||||
onChange={(e) =>
|
}}
|
||||||
updateForm("responsible_user_id", e.target.value)
|
>
|
||||||
}
|
<Field label="Stav">
|
||||||
className="admin-form-select"
|
<Select
|
||||||
disabled={!canEdit}
|
value={form.status}
|
||||||
>
|
onChange={(v) => updateForm("status", v)}
|
||||||
<option value="">— Nevybráno —</option>
|
disabled={!canEdit}
|
||||||
{users.map((u) => (
|
>
|
||||||
<option key={u.id} value={u.id}>
|
<MenuItem value="aktivni">Aktivní</MenuItem>
|
||||||
{u.name}
|
<MenuItem value="dokonceny">Dokončený</MenuItem>
|
||||||
</option>
|
<MenuItem value="zruseny">Zrušený</MenuItem>
|
||||||
))}
|
</Select>
|
||||||
</select>
|
</Field>
|
||||||
</FormField>
|
<Field label="Datum zahájení">
|
||||||
</div>
|
<DateField
|
||||||
|
value={form.start_date}
|
||||||
<div className="admin-form-row admin-form-row-3">
|
onChange={(val) => updateForm("start_date", val)}
|
||||||
<FormField label="Stav">
|
disabled={!canEdit}
|
||||||
<select
|
/>
|
||||||
value={form.status}
|
</Field>
|
||||||
onChange={(e) => updateForm("status", e.target.value)}
|
<Field label="Datum ukončení">
|
||||||
className="admin-form-select"
|
<DateField
|
||||||
disabled={!canEdit}
|
value={form.end_date}
|
||||||
>
|
onChange={(val) => updateForm("end_date", val)}
|
||||||
<option value="aktivni">Aktivní</option>
|
disabled={!canEdit}
|
||||||
<option value="dokonceny">Dokončený</option>
|
/>
|
||||||
<option value="zruseny">Zrušený</option>
|
</Field>
|
||||||
</select>
|
</Box>
|
||||||
</FormField>
|
</Card>
|
||||||
<FormField label="Datum zahájení">
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="date"
|
|
||||||
value={form.start_date}
|
|
||||||
onChange={(val: string) => updateForm("start_date", val)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Datum ukončení">
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="date"
|
|
||||||
value={form.end_date}
|
|
||||||
onChange={(val: string) => updateForm("end_date", val)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Poznámky
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
|
||||||
>
|
|
||||||
<div className="admin-card-body">
|
|
||||||
<h3 className="admin-card-title">Poznámky</h3>
|
|
||||||
|
|
||||||
{/* Add note */}
|
{/* Add note */}
|
||||||
<div className="mb-4">
|
<Box sx={{ mb: 3 }}>
|
||||||
<textarea
|
<TextField
|
||||||
value={newNote}
|
value={newNote}
|
||||||
onChange={(e) => setNewNote(e.target.value)}
|
onChange={(e) => setNewNote(e.target.value)}
|
||||||
className="admin-form-input"
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
placeholder="Napište poznámku..."
|
placeholder="Napište poznámku..."
|
||||||
style={{ resize: "vertical", width: "100%" }}
|
fullWidth
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
||||||
handleAddNote();
|
handleAddNote();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<Box sx={{ mt: 1 }}>
|
||||||
<button
|
<Button
|
||||||
onClick={handleAddNote}
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
color="inherit"
|
||||||
disabled={addingNote || !newNote.trim()}
|
onClick={handleAddNote}
|
||||||
>
|
disabled={addingNote || !newNote.trim()}
|
||||||
{addingNote ? (
|
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
|
||||||
) : (
|
|
||||||
"Přidat poznámku"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legacy notes (read-only) */}
|
|
||||||
{project.notes && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem",
|
|
||||||
background: "var(--bg-secondary)",
|
|
||||||
borderRadius: "0.5rem",
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
{addingNote ? "Přidávání..." : "Přidat poznámku"}
|
||||||
style={{
|
</Button>
|
||||||
fontSize: "0.75rem",
|
</Box>
|
||||||
color: "var(--text-tertiary)",
|
</Box>
|
||||||
marginBottom: "0.25rem",
|
|
||||||
|
{/* Legacy notes (read-only) */}
|
||||||
|
{project.notes && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 2,
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: "block", mb: 0.5 }}
|
||||||
|
>
|
||||||
|
Starší poznámka (před zavedením systému)
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
{project.notes}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes list */}
|
||||||
|
{notes.length === 0 && !project.notes && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ textAlign: "center", py: 2 }}
|
||||||
|
>
|
||||||
|
Zatím žádné poznámky
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{(notes.length > 0 || project.notes) && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
{notes.map((note) => (
|
||||||
|
<Box
|
||||||
|
key={note.id}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 2,
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Starší poznámka (před zavedením systému)
|
<Box
|
||||||
</div>
|
sx={{
|
||||||
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
|
display: "flex",
|
||||||
</div>
|
justifyContent: "space-between",
|
||||||
)}
|
alignItems: "flex-start",
|
||||||
|
gap: 1,
|
||||||
{/* Notes list */}
|
|
||||||
{notes.length === 0 && !project.notes && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "1rem 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Zatím žádné poznámky
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(notes.length > 0 || project.notes) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notes.map((note) => (
|
|
||||||
<div
|
|
||||||
key={note.id}
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem",
|
|
||||||
background: "var(--bg-secondary)",
|
|
||||||
borderRadius: "0.5rem",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Box sx={{ flex: 1 }}>
|
||||||
style={{
|
<Box
|
||||||
display: "flex",
|
sx={{
|
||||||
justifyContent: "space-between",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: 1,
|
||||||
}}
|
mb: 0.5,
|
||||||
>
|
}}
|
||||||
<div className="flex-1">
|
>
|
||||||
<div
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
style={{
|
{note.user_name}
|
||||||
display: "flex",
|
</Typography>
|
||||||
alignItems: "center",
|
<Typography variant="caption" color="text.secondary">
|
||||||
gap: "0.5rem",
|
{formatNoteDate(note.created_at)}
|
||||||
marginBottom: "0.25rem",
|
</Typography>
|
||||||
}}
|
</Box>
|
||||||
>
|
<Typography
|
||||||
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
|
variant="body2"
|
||||||
{note.user_name}
|
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||||
</span>
|
>
|
||||||
<span
|
{note.content}
|
||||||
style={{
|
</Typography>
|
||||||
color: "var(--text-tertiary)",
|
</Box>
|
||||||
fontSize: "0.75rem",
|
{isAdmin && (
|
||||||
}}
|
<IconButton
|
||||||
>
|
size="small"
|
||||||
{formatNoteDate(note.created_at)}
|
color="error"
|
||||||
</span>
|
onClick={() => handleDeleteNote(note.id)}
|
||||||
</div>
|
title="Smazat poznámku"
|
||||||
<div
|
aria-label="Smazat poznámku"
|
||||||
style={{
|
disabled={deletingNoteId === note.id}
|
||||||
whiteSpace: "pre-wrap",
|
sx={{ flexShrink: 0 }}
|
||||||
fontSize: "0.875rem",
|
>
|
||||||
lineHeight: 1.5,
|
{TrashIcon}
|
||||||
}}
|
</IconButton>
|
||||||
>
|
)}
|
||||||
{note.content}
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
))}
|
||||||
{isAdmin && (
|
</Box>
|
||||||
<button
|
)}
|
||||||
onClick={() => handleDeleteNote(note.id)}
|
</Card>
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Smazat poznámku"
|
|
||||||
disabled={deletingNoteId === note.id}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
opacity: deletingNoteId === note.id ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{deletingNoteId === note.id ? (
|
|
||||||
<div
|
|
||||||
className="admin-spinner"
|
|
||||||
style={{ width: 14, height: 14, borderWidth: 2 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Project File Manager */}
|
{/* Project File Manager */}
|
||||||
<motion.div
|
<Box sx={{ mb: 2 }}>
|
||||||
style={{ marginBottom: "1rem" }}
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
|
||||||
>
|
|
||||||
<ProjectFileManager
|
<ProjectFileManager
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
projectNumber={project.project_number}
|
projectNumber={project.project_number}
|
||||||
hasPermission={hasPermission}
|
hasPermission={hasPermission}
|
||||||
hasNasFolder={project.has_nas_folder ?? false}
|
hasNasFolder={project.has_nas_folder ?? false}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Propojení
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
<Box
|
||||||
>
|
sx={{
|
||||||
<div className="admin-card-body">
|
display: "grid",
|
||||||
<h3 className="admin-card-title">Propojení</h3>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<div className="admin-form-row">
|
gap: 2,
|
||||||
<FormField label="Objednávka">
|
}}
|
||||||
<div>
|
>
|
||||||
{project.order_id ? (
|
<Box>
|
||||||
<Link
|
<Typography variant="caption" color="text.secondary">
|
||||||
to={`/orders/${project.order_id}`}
|
Objednávka
|
||||||
className="link-accent"
|
</Typography>
|
||||||
>
|
<Typography variant="body2">
|
||||||
{project.order_number}
|
{project.order_id ? (
|
||||||
{project.order_status && (
|
<Box
|
||||||
<span
|
component={RouterLink}
|
||||||
className="text-tertiary"
|
to={`/orders/${project.order_id}`}
|
||||||
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
|
sx={{
|
||||||
>
|
color: "primary.main",
|
||||||
(
|
textDecoration: "none",
|
||||||
{STATUS_LABELS[project.order_status] ||
|
"&:hover": { textDecoration: "underline" },
|
||||||
project.order_status}
|
}}
|
||||||
)
|
>
|
||||||
</span>
|
{project.order_number}
|
||||||
)}
|
{project.order_status && (
|
||||||
</Link>
|
<Box
|
||||||
) : (
|
component="span"
|
||||||
"—"
|
sx={{ color: "text.secondary", ml: 1 }}
|
||||||
)}
|
>
|
||||||
</div>
|
(
|
||||||
</FormField>
|
{STATUS_LABELS[project.order_status] ||
|
||||||
<FormField label="Nabídka">
|
project.order_status}
|
||||||
<div>
|
)
|
||||||
{project.quotation_id ? (
|
</Box>
|
||||||
<Link
|
)}
|
||||||
to={`/offers/${project.quotation_id}`}
|
</Box>
|
||||||
className="link-accent"
|
) : (
|
||||||
>
|
"—"
|
||||||
{project.quotation_number}
|
)}
|
||||||
</Link>
|
</Typography>
|
||||||
) : (
|
</Box>
|
||||||
"—"
|
<Box>
|
||||||
)}
|
<Typography variant="caption" color="text.secondary">
|
||||||
</div>
|
Nabídka
|
||||||
</FormField>
|
</Typography>
|
||||||
</div>
|
<Typography variant="body2">
|
||||||
</div>
|
{project.quotation_id ? (
|
||||||
</motion.div>
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/offers/${project.quotation_id}`}
|
||||||
|
sx={{
|
||||||
|
color: "primary.main",
|
||||||
|
textDecoration: "none",
|
||||||
|
"&:hover": { textDecoration: "underline" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.quotation_number}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm}
|
isOpen={deleteConfirm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteConfirm(false);
|
setDeleteConfirm(false);
|
||||||
@@ -643,30 +647,19 @@ export default function ProjectDetail() {
|
|||||||
}}
|
}}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat projekt"
|
title="Smazat projekt"
|
||||||
message={
|
message={`Opravdu chcete smazat projekt „${project.project_number} – ${project.name}"? Tato akce je nevratná.`}
|
||||||
<>
|
|
||||||
Opravdu chcete smazat projekt "{project.project_number} –{" "}
|
|
||||||
{project.name}"? Tato akce je nevratná.
|
|
||||||
{project.has_nas_folder && (
|
|
||||||
<label
|
|
||||||
className="admin-form-checkbox"
|
|
||||||
style={{ marginTop: "1rem", display: "flex" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={deleteFiles}
|
|
||||||
onChange={(e) => setDeleteFiles(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Smazat i soubory na disku</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
confirmVariant="danger"
|
||||||
type="danger"
|
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
>
|
||||||
</div>
|
{project.has_nas_folder && (
|
||||||
|
<CheckboxField
|
||||||
|
label="Smazat i soubory na disku"
|
||||||
|
checked={deleteFiles}
|
||||||
|
onChange={setDeleteFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
import { formatDate } from "../utils/formatters";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
|
|
||||||
import { formatDate, czechPlural } from "../utils/formatters";
|
|
||||||
import SortIcon from "../components/SortIcon";
|
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import { projectListOptions } from "../lib/queries/projects";
|
import {
|
||||||
|
projectListOptions,
|
||||||
|
projectNextNumberOptions,
|
||||||
|
} from "../lib/queries/projects";
|
||||||
|
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||||
|
import { userListOptions } from "../lib/queries/users";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import Pagination from "../components/Pagination";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
FilterBar,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
StatusChip,
|
||||||
|
CheckboxField,
|
||||||
|
LoadingState,
|
||||||
|
PageEnter,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -22,10 +44,13 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
zruseny: "Zrušený",
|
zruseny: "Zrušený",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CLASSES: Record<string, string> = {
|
const STATUS_COLORS: Record<
|
||||||
aktivni: "admin-badge-project-aktivni",
|
string,
|
||||||
dokonceny: "admin-badge-project-dokonceny",
|
"default" | "success" | "error" | "warning" | "info"
|
||||||
zruseny: "admin-badge-project-zruseny",
|
> = {
|
||||||
|
aktivni: "success",
|
||||||
|
dokonceny: "info",
|
||||||
|
zruseny: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -41,12 +66,56 @@ interface Project {
|
|||||||
order_number?: string;
|
order_number?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
const { sort, order, handleSort, activeSort } =
|
const { sort, order, handleSort } = useTableSort("project_number");
|
||||||
useTableSort("project_number");
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||||
@@ -73,6 +142,74 @@ export default function Projects() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
name: "",
|
||||||
|
customer_id: "",
|
||||||
|
responsible_user_id: "",
|
||||||
|
status: "aktivni",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const customersQuery = useQuery({
|
||||||
|
...offerCustomersOptions(),
|
||||||
|
enabled: showCreate,
|
||||||
|
});
|
||||||
|
// Responsible-person picker: only users whose role can view projects
|
||||||
|
// (projects.view, filtered server-side) and who are active.
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
...userListOptions("projects.view"),
|
||||||
|
enabled: showCreate,
|
||||||
|
});
|
||||||
|
const nextNumberQuery = useQuery({
|
||||||
|
...projectNextNumberOptions(),
|
||||||
|
enabled: showCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useApiMutation<typeof createForm, { id: number }>({
|
||||||
|
url: () => `${API_BASE}/projects`,
|
||||||
|
method: () => "POST",
|
||||||
|
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowCreate(false);
|
||||||
|
alert.success("Projekt byl vytvořen");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setCreateForm({
|
||||||
|
name: "",
|
||||||
|
customer_id: "",
|
||||||
|
responsible_user_id: "",
|
||||||
|
status: "aktivni",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
setCreateErrors({});
|
||||||
|
setShowCreate(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
if (!createForm.name.trim()) errs.name = "Zadejte název projektu";
|
||||||
|
setCreateErrors(errs);
|
||||||
|
if (Object.keys(errs).length > 0) return;
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync(createForm);
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
items: projects,
|
items: projects,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -99,239 +236,190 @@ export default function Projects() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<Project>[] = [
|
||||||
<div>
|
{
|
||||||
<motion.div
|
key: "project_number",
|
||||||
className="admin-page-header"
|
header: "Číslo",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
width: "12%",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sortKey: "project_number",
|
||||||
transition={{ duration: 0.25 }}
|
mono: true,
|
||||||
>
|
render: (p) => (
|
||||||
<div>
|
<Box
|
||||||
<h1 className="admin-page-title">Projekty</h1>
|
component={RouterLink}
|
||||||
<p className="admin-page-subtitle">
|
to={`/projects/${p.id}`}
|
||||||
{pagination?.total ?? projects.length}{" "}
|
sx={{
|
||||||
{czechPlural(
|
color: "primary.main",
|
||||||
pagination?.total ?? projects.length,
|
textDecoration: "none",
|
||||||
"projekt",
|
"&:hover": { textDecoration: "underline" },
|
||||||
"projekty",
|
}}
|
||||||
"projektů",
|
>
|
||||||
)}
|
{p.project_number}
|
||||||
</p>
|
</Box>
|
||||||
</div>
|
),
|
||||||
</motion.div>
|
},
|
||||||
|
{
|
||||||
<motion.div
|
key: "name",
|
||||||
className="admin-card"
|
header: "Název",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
width: "16%",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sortKey: "name",
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
bold: true,
|
||||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
render: (p) => p.name || "—",
|
||||||
>
|
},
|
||||||
<div className="admin-card-body">
|
{
|
||||||
<div className="admin-search-bar mb-4">
|
key: "customer",
|
||||||
<input
|
header: "Zákazník",
|
||||||
type="text"
|
width: "13%",
|
||||||
value={search}
|
render: (p) => p.customer_name || "—",
|
||||||
onChange={(e) => {
|
},
|
||||||
setSearch(e.target.value);
|
{
|
||||||
setPage(1);
|
key: "responsible",
|
||||||
}}
|
header: "Zodpovědná osoba",
|
||||||
className="admin-form-input"
|
width: "14%",
|
||||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
render: (p) => p.responsible_user_name || "—",
|
||||||
/>
|
},
|
||||||
</div>
|
{
|
||||||
|
key: "status",
|
||||||
{projects.length === 0 ? (
|
header: "Stav",
|
||||||
<div className="admin-empty-state">
|
width: "10%",
|
||||||
<div className="admin-empty-icon">
|
sortKey: "status",
|
||||||
<svg
|
render: (p) => (
|
||||||
width="28"
|
<StatusChip
|
||||||
height="28"
|
label={STATUS_LABELS[p.status] || p.status}
|
||||||
viewBox="0 0 24 24"
|
color={STATUS_COLORS[p.status] || "default"}
|
||||||
fill="none"
|
/>
|
||||||
stroke="currentColor"
|
),
|
||||||
strokeWidth="1.5"
|
},
|
||||||
strokeLinecap="round"
|
{
|
||||||
strokeLinejoin="round"
|
key: "order",
|
||||||
>
|
header: "Objednávka",
|
||||||
<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" />
|
width: "12%",
|
||||||
</svg>
|
render: (p) =>
|
||||||
</div>
|
p.order_id ? (
|
||||||
<p>Zatím nejsou žádné projekty.</p>
|
<Box
|
||||||
<p
|
component={RouterLink}
|
||||||
style={{
|
to={`/orders/${p.order_id}`}
|
||||||
color: "var(--text-tertiary)",
|
sx={{
|
||||||
fontSize: "0.875rem",
|
color: "primary.main",
|
||||||
}}
|
textDecoration: "none",
|
||||||
>
|
"&:hover": { textDecoration: "underline" },
|
||||||
Projekt se vytvoří automaticky při vytvoření objednávky.
|
}}
|
||||||
</p>
|
>
|
||||||
</div>
|
{p.order_number}
|
||||||
) : (
|
</Box>
|
||||||
<div className="admin-table-responsive">
|
) : (
|
||||||
<table className="admin-table">
|
"—"
|
||||||
<thead>
|
),
|
||||||
<tr>
|
},
|
||||||
<th
|
{
|
||||||
style={{ cursor: "pointer" }}
|
key: "start_date",
|
||||||
onClick={() => handleSort("project_number")}
|
header: "Začátek",
|
||||||
>
|
width: "8%",
|
||||||
Číslo{" "}
|
mono: true,
|
||||||
<SortIcon
|
render: (p) => formatDate(p.start_date),
|
||||||
column="project_number"
|
},
|
||||||
sort={activeSort}
|
{
|
||||||
order={order}
|
key: "end_date",
|
||||||
/>
|
header: "Konec",
|
||||||
</th>
|
width: "8%",
|
||||||
<th
|
mono: true,
|
||||||
style={{ cursor: "pointer" }}
|
render: (p) => formatDate(p.end_date),
|
||||||
onClick={() => handleSort("name")}
|
},
|
||||||
>
|
{
|
||||||
Název{" "}
|
key: "actions",
|
||||||
<SortIcon column="name" sort={activeSort} order={order} />
|
header: "Akce",
|
||||||
</th>
|
width: "7%",
|
||||||
<th>Zákazník</th>
|
align: "right",
|
||||||
<th>Zodpovědná osoba</th>
|
render: (p) => (
|
||||||
<th
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
style={{ cursor: "pointer" }}
|
<IconButton
|
||||||
onClick={() => handleSort("status")}
|
size="small"
|
||||||
>
|
onClick={() => navigate(`/projects/${p.id}`)}
|
||||||
Stav{" "}
|
aria-label="Upravit"
|
||||||
<SortIcon
|
title="Upravit"
|
||||||
column="status"
|
>
|
||||||
sort={activeSort}
|
{EditIcon}
|
||||||
order={order}
|
</IconButton>
|
||||||
/>
|
{hasPermission("projects.delete") && !p.order_id && (
|
||||||
</th>
|
<IconButton
|
||||||
<th
|
size="small"
|
||||||
style={{ cursor: "pointer" }}
|
color="error"
|
||||||
onClick={() => handleSort("start_date")}
|
onClick={() => setDeleteTarget(p)}
|
||||||
>
|
aria-label="Smazat"
|
||||||
Začátek{" "}
|
title="Smazat projekt"
|
||||||
<SortIcon
|
>
|
||||||
column="start_date"
|
{DeleteIcon}
|
||||||
sort={activeSort}
|
</IconButton>
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => handleSort("end_date")}
|
|
||||||
>
|
|
||||||
Konec{" "}
|
|
||||||
<SortIcon
|
|
||||||
column="end_date"
|
|
||||||
sort={activeSort}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>Objednávka</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(projects as Project[]).map((p) => (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<Link to={`/projects/${p.id}`} className="link-accent">
|
|
||||||
{p.project_number}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="fw-500">{p.name || "—"}</td>
|
|
||||||
<td>{p.customer_name || "—"}</td>
|
|
||||||
<td>{p.responsible_user_name || "—"}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${STATUS_CLASSES[p.status] || ""}`}
|
|
||||||
>
|
|
||||||
{STATUS_LABELS[p.status] || p.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">{formatDate(p.start_date)}</td>
|
|
||||||
<td className="admin-mono">{formatDate(p.end_date)}</td>
|
|
||||||
<td>
|
|
||||||
{p.order_id ? (
|
|
||||||
<Link
|
|
||||||
to={`/orders/${p.order_id}`}
|
|
||||||
className="text-secondary"
|
|
||||||
style={{ textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
{p.order_number}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<Link
|
|
||||||
to={`/projects/${p.id}`}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
{!p.order_id && hasPermission("projects.delete") && (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteTarget(p)}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat projekt"
|
|
||||||
disabled={
|
|
||||||
deleteMutation.isPending &&
|
|
||||||
deleteTarget?.id === p.id
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{deleteMutation.isPending &&
|
|
||||||
deleteTarget?.id === p.id ? (
|
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
</Box>
|
||||||
</div>
|
),
|
||||||
</motion.div>
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<ConfirmModal
|
return (
|
||||||
|
<PageEnter>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 3,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4">Projekty</Typography>
|
||||||
|
{hasPermission("projects.create") && (
|
||||||
|
<Button startIcon={PlusIcon} onClick={openCreate}>
|
||||||
|
Přidat projekt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FilterBar>
|
||||||
|
<Box sx={{ flex: "1 1 320px" }}>
|
||||||
|
<TextField
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||||
|
<DataTable<Project>
|
||||||
|
columns={columns}
|
||||||
|
rows={projects}
|
||||||
|
rowKey={(p) => p.id}
|
||||||
|
sortBy={sort}
|
||||||
|
sortDir={order}
|
||||||
|
onSort={handleSort}
|
||||||
|
empty={
|
||||||
|
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
Zatím nejsou žádné projekty.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
|
||||||
|
tlačítkem „Přidat projekt".
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
isOpen={!!deleteTarget}
|
isOpen={!!deleteTarget}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -340,25 +428,119 @@ export default function Projects() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat projekt"
|
title="Smazat projekt"
|
||||||
message={
|
message={
|
||||||
<>
|
deleteTarget
|
||||||
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
|
? `Opravdu chcete smazat projekt ${deleteTarget.project_number}?`
|
||||||
<label
|
: ""
|
||||||
className="admin-form-checkbox"
|
|
||||||
style={{ marginTop: "1rem", display: "flex" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={deleteFiles}
|
|
||||||
onChange={(e) => setDeleteFiles(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Smazat i soubory na disku</span>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
/>
|
>
|
||||||
</div>
|
<CheckboxField
|
||||||
|
label="Smazat i soubory na disku"
|
||||||
|
checked={deleteFiles}
|
||||||
|
onChange={setDeleteFiles}
|
||||||
|
/>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
title="Přidat projekt"
|
||||||
|
subtitle={`Číslo projektu: ${
|
||||||
|
nextNumberQuery.data?.number ??
|
||||||
|
nextNumberQuery.data?.next_number ??
|
||||||
|
"…"
|
||||||
|
} (přiděleno automaticky)`}
|
||||||
|
submitText="Vytvořit"
|
||||||
|
loading={creating}
|
||||||
|
>
|
||||||
|
<Field label="Název" required error={createErrors.name}>
|
||||||
|
<TextField
|
||||||
|
value={createForm.name}
|
||||||
|
placeholder="Název projektu"
|
||||||
|
error={!!createErrors.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCreateForm({ ...createForm, name: e.target.value });
|
||||||
|
setCreateErrors((p) => ({ ...p, name: "" }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Zákazník">
|
||||||
|
<Select
|
||||||
|
value={createForm.customer_id}
|
||||||
|
onChange={(value) =>
|
||||||
|
setCreateForm({ ...createForm, customer_id: value })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "— bez zákazníka —" },
|
||||||
|
...(customersQuery.data ?? []).map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Zodpovědná osoba">
|
||||||
|
<Select
|
||||||
|
value={createForm.responsible_user_id}
|
||||||
|
onChange={(value) =>
|
||||||
|
setCreateForm({ ...createForm, responsible_user_id: value })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "— nepřiřazeno —" },
|
||||||
|
...(usersQuery.data ?? [])
|
||||||
|
.filter((u) => u.is_active)
|
||||||
|
.map((u) => ({
|
||||||
|
value: String(u.id),
|
||||||
|
label: `${u.first_name} ${u.last_name}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Začátek">
|
||||||
|
<DateField
|
||||||
|
value={createForm.start_date}
|
||||||
|
onChange={(val) =>
|
||||||
|
setCreateForm({ ...createForm, start_date: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
|
<Field label="Konec">
|
||||||
|
<DateField
|
||||||
|
value={createForm.end_date}
|
||||||
|
onChange={(val) =>
|
||||||
|
setCreateForm({ ...createForm, end_date: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Poznámka">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={createForm.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, notes: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm, todayLocalStr } from "../utils/formatters";
|
import { formatKm, todayLocalStr } from "../utils/formatters";
|
||||||
@@ -17,9 +14,25 @@ import {
|
|||||||
tripListOptions,
|
tripListOptions,
|
||||||
tripVehiclesOptions,
|
tripVehiclesOptions,
|
||||||
type BackendTrip,
|
type BackendTrip,
|
||||||
type TripVehicle,
|
|
||||||
} from "../lib/queries/trips";
|
} from "../lib/queries/trips";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
StatusChip,
|
||||||
|
LoadingState,
|
||||||
|
StatCard,
|
||||||
|
PageEnter,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface TripForm {
|
interface TripForm {
|
||||||
@@ -33,6 +46,114 @@ interface TripForm {
|
|||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TripsIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="10" />
|
||||||
|
<line x1="18" y1="20" x2="18" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const TotalIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const BusinessIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||||
|
<circle cx="5.5" cy="18" r="2" />
|
||||||
|
<circle cx="18.5" cy="18" r="2" />
|
||||||
|
<path d="M8 18h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const PrivateIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function Trips() {
|
export default function Trips() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -197,11 +318,7 @@ export default function Trips() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tripsLoading) {
|
if (tripsLoading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = trips.reduce(
|
const totals = trips.reduce(
|
||||||
@@ -216,440 +333,341 @@ export default function Trips() {
|
|||||||
{ total: 0, business: 0, private: 0, count: 0 },
|
{ total: 0, business: 0, private: 0, count: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const recentTrips = trips.slice(0, 10);
|
||||||
|
|
||||||
|
const columns: DataColumn<BackendTrip>[] = [
|
||||||
|
{
|
||||||
|
key: "trip_date",
|
||||||
|
header: "Datum",
|
||||||
|
width: "12%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => formatDate(trip.trip_date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vehicle",
|
||||||
|
header: "Vozidlo",
|
||||||
|
width: "12%",
|
||||||
|
render: (trip) => (
|
||||||
|
<StatusChip label={trip.vehicles?.spz ?? ""} color="default" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "driver",
|
||||||
|
header: "Řidič",
|
||||||
|
width: "16%",
|
||||||
|
render: (trip) =>
|
||||||
|
trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "route",
|
||||||
|
header: "Trasa",
|
||||||
|
width: "22%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{trip.route_from} → {trip.route_to}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "distance",
|
||||||
|
header: "Vzdálenost",
|
||||||
|
width: "12%",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (trip) =>
|
||||||
|
`${formatKm(trip.distance ?? trip.end_km - trip.start_km)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Typ",
|
||||||
|
width: "12%",
|
||||||
|
render: (trip) => (
|
||||||
|
<StatusChip
|
||||||
|
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||||
|
color={trip.is_business ? "success" : "warning"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
width: "10%",
|
||||||
|
align: "right",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEditModal(trip)}
|
||||||
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
|
>
|
||||||
|
{EditIcon}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
|
||||||
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
|
>
|
||||||
|
{DeleteIcon}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-page-header"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "flex",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
alignItems: "flex-start",
|
||||||
transition={{ duration: 0.25 }}
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<Box>
|
||||||
<h1 className="admin-page-title">Kniha jízd</h1>
|
<Typography variant="h4">Kniha jízd</Typography>
|
||||||
<p className="admin-page-subtitle">
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
{new Date().toLocaleDateString("cs-CZ", {
|
{new Date().toLocaleDateString("cs-CZ", {
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-page-actions">
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
<button
|
Přidat jízdu
|
||||||
onClick={openCreateModal}
|
</Button>
|
||||||
className="admin-btn admin-btn-primary"
|
</Box>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Přidat jízdu
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-grid admin-grid-4"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: {
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
xs: "1fr",
|
||||||
|
sm: "1fr 1fr",
|
||||||
|
lg: "repeat(4, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-stat-card info">
|
<StatCard
|
||||||
<div className="admin-stat-icon info">
|
label="Počet jízd"
|
||||||
<svg
|
value={totals.count}
|
||||||
width="22"
|
icon={TripsIcon}
|
||||||
height="22"
|
color="info"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
<StatCard
|
||||||
stroke="currentColor"
|
label="Celkem naježděno"
|
||||||
strokeWidth="2"
|
value={`${formatKm(totals.total)} km`}
|
||||||
strokeLinecap="round"
|
icon={TotalIcon}
|
||||||
strokeLinejoin="round"
|
color="default"
|
||||||
>
|
/>
|
||||||
<line x1="12" y1="20" x2="12" y2="10" />
|
<StatCard
|
||||||
<line x1="18" y1="20" x2="18" y2="4" />
|
label="Služební"
|
||||||
<line x1="6" y1="20" x2="6" y2="16" />
|
value={`${formatKm(totals.business)} km`}
|
||||||
</svg>
|
icon={BusinessIcon}
|
||||||
</div>
|
color="success"
|
||||||
<div className="admin-stat-content">
|
/>
|
||||||
<span className="admin-stat-value">{totals.count}</span>
|
<StatCard
|
||||||
<span className="admin-stat-label">Počet jízd</span>
|
label="Soukromé"
|
||||||
</div>
|
value={`${formatKm(totals.private)} km`}
|
||||||
</div>
|
icon={PrivateIcon}
|
||||||
|
color="warning"
|
||||||
<div className="admin-stat-card">
|
/>
|
||||||
<div className="admin-stat-icon">
|
</Box>
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.total)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Celkem naježděno</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-stat-card success">
|
|
||||||
<div className="admin-stat-icon success">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
|
||||||
<circle cx="5.5" cy="18" r="2" />
|
|
||||||
<circle cx="18.5" cy="18" r="2" />
|
|
||||||
<path d="M8 18h8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.business)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Služební</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-stat-card warning">
|
|
||||||
<div className="admin-stat-icon warning">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
||||||
<polyline points="9 22 9 12 15 12 15 22" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.private)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Soukromé</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Recent Trips */}
|
{/* Recent Trips */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
className="admin-card mt-6"
|
<Box
|
||||||
initial={{ opacity: 0, y: 12 }}
|
sx={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
display: "flex",
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
alignItems: "center",
|
||||||
>
|
justifyContent: "space-between",
|
||||||
<div className="admin-card-header flex-between">
|
mb: 2,
|
||||||
<h2 className="admin-card-title">Poslední jízdy</h2>
|
}}
|
||||||
<Link
|
>
|
||||||
|
<Typography variant="h6">Poslední jízdy</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/trips/history"
|
to="/trips/history"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
Zobrazit historii
|
Zobrazit historii
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body">
|
<DataTable<BackendTrip>
|
||||||
{trips.length === 0 ? (
|
columns={columns}
|
||||||
<div className="admin-empty-state">
|
rows={recentTrips}
|
||||||
<div className="admin-empty-icon">
|
rowKey={(trip) => trip.id}
|
||||||
<svg
|
empty={
|
||||||
width="28"
|
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||||
height="28"
|
<Typography color="text.secondary" gutterBottom>
|
||||||
viewBox="0 0 24 24"
|
Zatím nemáte žádné záznamy jízd.
|
||||||
fill="none"
|
</Typography>
|
||||||
stroke="currentColor"
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>Zatím nemáte žádné záznamy jízd.</p>
|
|
||||||
<button
|
|
||||||
onClick={openCreateModal}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
Přidat první jízdu
|
Přidat první jízdu
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
) : (
|
}
|
||||||
<div className="admin-table-responsive">
|
/>
|
||||||
<table className="admin-table">
|
</Card>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>Vozidlo</th>
|
|
||||||
<th>Řidič</th>
|
|
||||||
<th>Trasa</th>
|
|
||||||
<th>Vzdálenost</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trips.slice(0, 10).map((trip) => (
|
|
||||||
<tr key={trip.id}>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(trip.trip_date)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="admin-badge">
|
|
||||||
{trip.vehicles?.spz ?? ""}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{trip.users
|
|
||||||
? `${trip.users.first_name} ${trip.users.last_name}`
|
|
||||||
: ""}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{trip.route_from} → {trip.route_to}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<strong>
|
|
||||||
{formatKm(
|
|
||||||
trip.distance ?? trip.end_km - trip.start_km,
|
|
||||||
)}{" "}
|
|
||||||
km
|
|
||||||
</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
|
||||||
>
|
|
||||||
{trip.is_business ? "Služební" : "Soukromá"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(trip)}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteConfirm({ show: true, tripId: trip.id })
|
|
||||||
}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
{/* Add/Edit Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
|
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
|
||||||
loading={submitMutation.isPending}
|
loading={submitMutation.isPending}
|
||||||
size="lg"
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Box
|
||||||
<div className="admin-form-row">
|
sx={{
|
||||||
<FormField label="Vozidlo" error={errors.vehicle_id} required>
|
display: "grid",
|
||||||
<select
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
value={form.vehicle_id}
|
gap: 2,
|
||||||
onChange={(e) => {
|
}}
|
||||||
handleVehicleChange(e.target.value);
|
>
|
||||||
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
|
<Field label="Vozidlo" required error={errors.vehicle_id}>
|
||||||
}}
|
<Select
|
||||||
className="admin-form-select"
|
value={form.vehicle_id}
|
||||||
>
|
onChange={(value) => {
|
||||||
<option value="">Vyberte vozidlo</option>
|
handleVehicleChange(value);
|
||||||
{vehicles.map((v) => (
|
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
|
||||||
<option key={v.id} value={v.id}>
|
}}
|
||||||
{v.spz} - {v.name}
|
error={!!errors.vehicle_id}
|
||||||
</option>
|
options={[
|
||||||
))}
|
{ value: "", label: "Vyberte vozidlo" },
|
||||||
</select>
|
...vehicles.map((v) => ({
|
||||||
</FormField>
|
value: String(v.id),
|
||||||
|
label: `${v.spz} - ${v.name}`,
|
||||||
<FormField label="Datum jízdy" error={errors.trip_date} required>
|
})),
|
||||||
<AdminDatePicker
|
]}
|
||||||
mode="date"
|
|
||||||
value={form.trip_date}
|
|
||||||
onChange={(val: string) => {
|
|
||||||
setForm({ ...form, trip_date: val });
|
|
||||||
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-row admin-form-row-3">
|
|
||||||
<FormField
|
|
||||||
label="Počáteční stav km"
|
|
||||||
error={errors.start_km}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.start_km}
|
|
||||||
onChange={(e) => {
|
|
||||||
setForm({ ...form, start_km: e.target.value });
|
|
||||||
setErrors((prev) => ({ ...prev, start_km: "" }));
|
|
||||||
}}
|
|
||||||
className="admin-form-input"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Konečný stav km" error={errors.end_km} required>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.end_km}
|
|
||||||
onChange={(e) => {
|
|
||||||
setForm({ ...form, end_km: e.target.value });
|
|
||||||
setErrors((prev) => ({ ...prev, end_km: "" }));
|
|
||||||
}}
|
|
||||||
className="admin-form-input"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Vzdálenost">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={`${formatKm(calculateDistance())} km`}
|
|
||||||
className="admin-form-input"
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Místo odjezdu" error={errors.route_from} required>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.route_from}
|
|
||||||
onChange={(e) => {
|
|
||||||
setForm({ ...form, route_from: e.target.value });
|
|
||||||
setErrors((prev) => ({ ...prev, route_from: "" }));
|
|
||||||
}}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Např. Praha"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Místo příjezdu" error={errors.route_to} required>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.route_to}
|
|
||||||
onChange={(e) => {
|
|
||||||
setForm({ ...form, route_to: e.target.value });
|
|
||||||
setErrors((prev) => ({ ...prev, route_to: "" }));
|
|
||||||
}}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Např. Brno"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField label="Typ jízdy">
|
|
||||||
<select
|
|
||||||
value={form.is_business}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({
|
|
||||||
...form,
|
|
||||||
is_business: parseInt(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="admin-form-select"
|
|
||||||
>
|
|
||||||
<option value={1}>Služební</option>
|
|
||||||
<option value={0}>Soukromá</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Poznámky">
|
|
||||||
<textarea
|
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={2}
|
|
||||||
placeholder="Volitelné poznámky..."
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<Field label="Datum jízdy" required error={errors.trip_date}>
|
||||||
|
<DateField
|
||||||
|
value={form.trip_date}
|
||||||
|
onChange={(val) => {
|
||||||
|
setForm({ ...form, trip_date: val });
|
||||||
|
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Počáteční stav km" required error={errors.start_km}>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.start_km}
|
||||||
|
error={!!errors.start_km}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, start_km: e.target.value });
|
||||||
|
setErrors((prev) => ({ ...prev, start_km: "" }));
|
||||||
|
}}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Konečný stav km" required error={errors.end_km}>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.end_km}
|
||||||
|
error={!!errors.end_km}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, end_km: e.target.value });
|
||||||
|
setErrors((prev) => ({ ...prev, end_km: "" }));
|
||||||
|
}}
|
||||||
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Vzdálenost">
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
value={`${formatKm(calculateDistance())} km`}
|
||||||
|
disabled
|
||||||
|
slotProps={{ htmlInput: { readOnly: true } }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Místo odjezdu" required error={errors.route_from}>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
value={form.route_from}
|
||||||
|
error={!!errors.route_from}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, route_from: e.target.value });
|
||||||
|
setErrors((prev) => ({ ...prev, route_from: "" }));
|
||||||
|
}}
|
||||||
|
placeholder="Např. Praha"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Místo příjezdu" required error={errors.route_to}>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
value={form.route_to}
|
||||||
|
error={!!errors.route_to}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, route_to: e.target.value });
|
||||||
|
setErrors((prev) => ({ ...prev, route_to: "" }));
|
||||||
|
}}
|
||||||
|
placeholder="Např. Brno"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Typ jízdy">
|
||||||
|
<Select
|
||||||
|
value={String(form.is_business)}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm({ ...form, is_business: parseInt(value) })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "1", label: "Služební" },
|
||||||
|
{ value: "0", label: "Soukromá" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Poznámky">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Volitelné poznámky..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
|
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
|
||||||
onConfirm={() => handleDelete(deleteConfirm.tripId!)}
|
onConfirm={() => handleDelete(deleteConfirm.tripId!)}
|
||||||
@@ -657,9 +675,9 @@ export default function Trips() {
|
|||||||
message="Opravdu chcete smazat tento záznam?"
|
message="Opravdu chcete smazat tento záznam?"
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,42 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
import {
|
import {
|
||||||
tripListOptions,
|
tripListOptions,
|
||||||
tripVehiclesOptions,
|
tripVehiclesOptions,
|
||||||
tripUsersOptions,
|
tripUsersOptions,
|
||||||
type BackendTrip,
|
type BackendTrip,
|
||||||
type TripVehicle,
|
|
||||||
type TripUser,
|
|
||||||
} from "../lib/queries/trips";
|
} from "../lib/queries/trips";
|
||||||
import {
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
companySettingsOptions,
|
|
||||||
type CompanySettingsData,
|
|
||||||
} from "../lib/queries/settings";
|
|
||||||
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
MonthField,
|
||||||
|
StatusChip,
|
||||||
|
FilterBar,
|
||||||
|
LoadingState,
|
||||||
|
EmptyState,
|
||||||
|
StatCard,
|
||||||
|
PageEnter,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
@@ -71,15 +83,109 @@ function mapTrip(bt: BackendTrip): Trip {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PrintIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 6 2 18 2 18 9" />
|
||||||
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||||
|
<rect x="6" y="14" width="12" height="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TripsIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="10" />
|
||||||
|
<line x1="18" y1="20" x2="18" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const TotalIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const BusinessIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||||
|
<circle cx="5.5" cy="18" r="2" />
|
||||||
|
<circle cx="18.5" cy="18" r="2" />
|
||||||
|
<path d="M8 18h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function TripsAdmin() {
|
export default function TripsAdmin() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [filterMonth, setFilterMonth] = useState(() =>
|
const [filterPeriod, setFilterPeriod] = useState(() => {
|
||||||
String(new Date().getMonth() + 1),
|
const now = new Date();
|
||||||
);
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
const [filterYear, setFilterYear] = useState(() =>
|
});
|
||||||
String(new Date().getFullYear()),
|
|
||||||
);
|
|
||||||
const [filterVehicleId, setFilterVehicleId] = useState("");
|
const [filterVehicleId, setFilterVehicleId] = useState("");
|
||||||
const [filterUserId, setFilterUserId] = useState("");
|
const [filterUserId, setFilterUserId] = useState("");
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -113,8 +219,8 @@ export default function TripsAdmin() {
|
|||||||
|
|
||||||
const { data: tripsData, isPending } = useQuery(
|
const { data: tripsData, isPending } = useQuery(
|
||||||
tripListOptions({
|
tripListOptions({
|
||||||
month: Number(filterMonth) || undefined,
|
month: Number(filterPeriod.slice(5, 7)) || undefined,
|
||||||
year: Number(filterYear) || undefined,
|
year: Number(filterPeriod.slice(0, 4)) || undefined,
|
||||||
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
||||||
userId: filterUserId ? Number(filterUserId) : undefined,
|
userId: filterUserId ? Number(filterUserId) : undefined,
|
||||||
perPage: 100,
|
perPage: 100,
|
||||||
@@ -212,10 +318,10 @@ export default function TripsAdmin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPeriodName = () =>
|
const getPeriodName = () =>
|
||||||
new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString(
|
new Date(
|
||||||
"cs-CZ",
|
Number(filterPeriod.slice(0, 4)),
|
||||||
{ month: "long", year: "numeric" },
|
Number(filterPeriod.slice(5, 7)) - 1,
|
||||||
);
|
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
|
||||||
const getSelectedVehicleName = () => {
|
const getSelectedVehicleName = () => {
|
||||||
if (!filterVehicleId) return null;
|
if (!filterVehicleId) return null;
|
||||||
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
||||||
@@ -331,463 +437,352 @@ export default function TripsAdmin() {
|
|||||||
.reduce((sum, t) => sum + t.distance, 0),
|
.reduce((sum, t) => sum + t.distance, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columns: DataColumn<Trip>[] = [
|
||||||
|
{
|
||||||
|
key: "trip_date",
|
||||||
|
header: "Datum",
|
||||||
|
width: "11%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => formatDate(trip.trip_date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "driver",
|
||||||
|
header: "Řidič",
|
||||||
|
width: "16%",
|
||||||
|
render: (trip) => trip.driver_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vehicle",
|
||||||
|
header: "Vozidlo",
|
||||||
|
width: "11%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => <StatusChip label={trip.spz} color="default" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "route",
|
||||||
|
header: "Trasa",
|
||||||
|
width: "20%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{trip.route_from} → {trip.route_to}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "km",
|
||||||
|
header: "Stav km",
|
||||||
|
width: "14%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "distance",
|
||||||
|
header: "Vzdálenost",
|
||||||
|
width: "12%",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (trip) => `${formatKm(trip.distance)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Typ",
|
||||||
|
width: "10%",
|
||||||
|
render: (trip) => (
|
||||||
|
<StatusChip
|
||||||
|
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||||
|
color={trip.is_business ? "success" : "warning"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
width: "10%",
|
||||||
|
align: "right",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEditModal(trip)}
|
||||||
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
|
>
|
||||||
|
{EditIcon}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteConfirm({ show: true, trip })}
|
||||||
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
|
>
|
||||||
|
{DeleteIcon}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-page-header"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "flex",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
alignItems: "flex-start",
|
||||||
transition={{ duration: 0.25 }}
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<Typography variant="h4">Správa knihy jízd</Typography>
|
||||||
<h1 className="admin-page-title">Správa knihy jízd</h1>
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
{trips.length > 0 && (
|
{trips.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={PrintIcon}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
title="Tisk knihy jízd"
|
title="Tisk knihy jízd"
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ marginRight: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<polyline points="6 9 6 2 18 2 18 9" />
|
|
||||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
|
||||||
<rect x="6" y="14" width="12" height="8" />
|
|
||||||
</svg>
|
|
||||||
Tisk
|
Tisk
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Link to="/vehicles" className="admin-btn admin-btn-secondary">
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
|
to="/vehicles"
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
Vozidla
|
Vozidla
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card"
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Box>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
>
|
<Select
|
||||||
<div className="admin-card-body">
|
value={filterVehicleId}
|
||||||
<div className="admin-form-row admin-form-row-4">
|
onChange={setFilterVehicleId}
|
||||||
<FormField label="Měsíc" style={{ marginBottom: 0 }}>
|
options={[
|
||||||
<select
|
{ value: "", label: "Všechna vozidla" },
|
||||||
value={filterMonth}
|
...vehicles.map((v) => ({
|
||||||
onChange={(e) => setFilterMonth(e.target.value)}
|
value: String(v.id),
|
||||||
className="admin-form-select"
|
label: `${v.spz} - ${v.name}`,
|
||||||
>
|
})),
|
||||||
{Array.from({ length: 12 }, (_, i) => (
|
]}
|
||||||
<option key={i + 1} value={i + 1}>
|
/>
|
||||||
{new Date(2000, i).toLocaleString("cs-CZ", {
|
</Box>
|
||||||
month: "long",
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
})}
|
<Select
|
||||||
</option>
|
value={filterUserId}
|
||||||
))}
|
onChange={setFilterUserId}
|
||||||
</select>
|
options={[
|
||||||
</FormField>
|
{ value: "", label: "Všichni řidiči" },
|
||||||
<FormField label="Rok" style={{ marginBottom: 0 }}>
|
...tripUsers.map((u) => ({
|
||||||
<select
|
value: String(u.id),
|
||||||
value={filterYear}
|
label: u.name,
|
||||||
onChange={(e) => setFilterYear(e.target.value)}
|
})),
|
||||||
className="admin-form-select"
|
]}
|
||||||
>
|
/>
|
||||||
{Array.from({ length: 5 }, (_, i) => {
|
</Box>
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
</FilterBar>
|
||||||
return (
|
|
||||||
<option key={y} value={y}>
|
|
||||||
{y}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Vozidlo" style={{ marginBottom: 0 }}>
|
|
||||||
<select
|
|
||||||
value={filterVehicleId}
|
|
||||||
onChange={(e) => setFilterVehicleId(e.target.value)}
|
|
||||||
className="admin-form-select"
|
|
||||||
>
|
|
||||||
<option value="">Všechna vozidla</option>
|
|
||||||
{vehicles.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.spz} - {v.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Řidič" style={{ marginBottom: 0 }}>
|
|
||||||
<select
|
|
||||||
value={filterUserId}
|
|
||||||
onChange={(e) => setFilterUserId(e.target.value)}
|
|
||||||
className="admin-form-select"
|
|
||||||
>
|
|
||||||
<option value="">Všichni řidiči</option>
|
|
||||||
{tripUsers.map((u) => (
|
|
||||||
<option key={u.id} value={u.id}>
|
|
||||||
{u.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
{/* Stats Cards */}
|
||||||
className="admin-grid admin-grid-3 mt-6"
|
<Box
|
||||||
initial={{ opacity: 0, y: 12 }}
|
sx={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
display: "grid",
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
sm: "repeat(3, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-stat-card info">
|
<StatCard
|
||||||
<div className="admin-stat-icon info">
|
label="Počet jízd"
|
||||||
<svg
|
value={totals.count}
|
||||||
width="22"
|
icon={TripsIcon}
|
||||||
height="22"
|
color="info"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
<StatCard
|
||||||
stroke="currentColor"
|
label="Celkem naježděno"
|
||||||
strokeWidth="2"
|
value={`${formatKm(totals.total)} km`}
|
||||||
strokeLinecap="round"
|
icon={TotalIcon}
|
||||||
strokeLinejoin="round"
|
color="default"
|
||||||
>
|
/>
|
||||||
<line x1="12" y1="20" x2="12" y2="10" />
|
<StatCard
|
||||||
<line x1="18" y1="20" x2="18" y2="4" />
|
label="Služební km"
|
||||||
<line x1="6" y1="20" x2="6" y2="16" />
|
value={`${formatKm(totals.business)} km`}
|
||||||
</svg>
|
icon={BusinessIcon}
|
||||||
</div>
|
color="success"
|
||||||
<div className="admin-stat-content">
|
/>
|
||||||
<span className="admin-stat-value">{totals.count}</span>
|
</Box>
|
||||||
<span className="admin-stat-label">Počet jízd</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card">
|
|
||||||
<div className="admin-stat-icon">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.total)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Celkem naježděno</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card success">
|
|
||||||
<div className="admin-stat-icon success">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
|
||||||
<circle cx="5.5" cy="18" r="2" />
|
|
||||||
<circle cx="18.5" cy="18" r="2" />
|
|
||||||
<path d="M8 18h8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.business)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Služební km</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Trips Table */}
|
{/* Trips Table */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
className="admin-card mt-6"
|
{isPending ? (
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<LoadingState />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
) : (
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
<DataTable<Trip>
|
||||||
>
|
columns={columns}
|
||||||
<div className="admin-card-body">
|
rows={trips}
|
||||||
{isPending ? (
|
rowKey={(trip) => trip.id}
|
||||||
<div className="admin-loading">
|
empty={
|
||||||
<div className="admin-spinner" />
|
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||||
</div>
|
}
|
||||||
) : (
|
/>
|
||||||
<>
|
)}
|
||||||
{trips.length === 0 && (
|
</Card>
|
||||||
<div className="admin-empty-state">
|
|
||||||
<p>Žádné záznamy jízd pro vybrané období.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{trips.length > 0 && (
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>Řidič</th>
|
|
||||||
<th>Vozidlo</th>
|
|
||||||
<th>Trasa</th>
|
|
||||||
<th>Stav km</th>
|
|
||||||
<th>Vzdálenost</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trips.map((trip) => (
|
|
||||||
<tr key={trip.id}>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(trip.trip_date)}
|
|
||||||
</td>
|
|
||||||
<td>{trip.driver_name}</td>
|
|
||||||
<td>
|
|
||||||
<span className="admin-badge">{trip.spz}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{trip.route_from} → {trip.route_to}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<span style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatKm(trip.start_km)} -{" "}
|
|
||||||
{formatKm(trip.end_km)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<strong>{formatKm(trip.distance)} km</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
|
||||||
>
|
|
||||||
{trip.is_business ? "Služební" : "Soukromá"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(trip)}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteConfirm({ show: true, trip })
|
|
||||||
}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showEditModal && !!editingTrip}
|
isOpen={showEditModal && !!editingTrip}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
title="Upravit jízdu"
|
title="Upravit jízdu"
|
||||||
|
subtitle={editingTrip?.driver_name}
|
||||||
loading={editMutation.isPending}
|
loading={editMutation.isPending}
|
||||||
size="lg"
|
maxWidth="lg"
|
||||||
>
|
>
|
||||||
{editingTrip && (
|
{editingTrip && (
|
||||||
<div className="admin-form">
|
<>
|
||||||
<p
|
<Box
|
||||||
className="text-secondary"
|
sx={{
|
||||||
style={{ marginTop: "0", marginBottom: "0.75rem" }}
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{editingTrip.driver_name}
|
<Field label="Vozidlo">
|
||||||
</p>
|
<Select
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Vozidlo">
|
|
||||||
<select
|
|
||||||
value={editForm.vehicle_id}
|
value={editForm.vehicle_id}
|
||||||
onChange={(e) =>
|
onChange={(value) =>
|
||||||
setEditForm({
|
setEditForm({ ...editForm, vehicle_id: value })
|
||||||
...editForm,
|
|
||||||
vehicle_id: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-select"
|
options={vehicles.map((v) => ({
|
||||||
>
|
value: String(v.id),
|
||||||
{vehicles.map((v) => (
|
label: `${v.spz} - ${v.name}`,
|
||||||
<option key={v.id} value={v.id}>
|
}))}
|
||||||
{v.spz} - {v.name}
|
/>
|
||||||
</option>
|
</Field>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Datum jízdy">
|
<Field label="Datum jízdy">
|
||||||
<AdminDatePicker
|
<DateField
|
||||||
mode="date"
|
|
||||||
value={editForm.trip_date}
|
value={editForm.trip_date}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setEditForm({ ...editForm, trip_date: val })
|
setEditForm({ ...editForm, trip_date: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="Počáteční stav km">
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Počáteční stav km">
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={editForm.start_km}
|
value={editForm.start_km}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, start_km: e.target.value })
|
setEditForm({ ...editForm, start_km: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Konečný stav km">
|
<Field label="Konečný stav km">
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={editForm.end_km}
|
value={editForm.end_km}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, end_km: e.target.value })
|
setEditForm({ ...editForm, end_km: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Vzdálenost">
|
<Field label="Vzdálenost">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={`${formatKm(calculateDistance())} km`}
|
value={`${formatKm(calculateDistance())} km`}
|
||||||
className="admin-form-input"
|
|
||||||
readOnly
|
|
||||||
disabled
|
disabled
|
||||||
|
slotProps={{ htmlInput: { readOnly: true } }}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="Místo odjezdu">
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Místo odjezdu">
|
||||||
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={editForm.route_from}
|
value={editForm.route_from}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({
|
setEditForm({ ...editForm, route_from: e.target.value })
|
||||||
...editForm,
|
|
||||||
route_from: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Místo příjezdu">
|
<Field label="Místo příjezdu">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={editForm.route_to}
|
value={editForm.route_to}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, route_to: e.target.value })
|
setEditForm({ ...editForm, route_to: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<FormField label="Typ jízdy">
|
<Field label="Typ jízdy">
|
||||||
<select
|
<Select
|
||||||
value={editForm.is_business}
|
value={String(editForm.is_business)}
|
||||||
onChange={(e) =>
|
onChange={(value) =>
|
||||||
setEditForm({
|
setEditForm({ ...editForm, is_business: parseInt(value) })
|
||||||
...editForm,
|
|
||||||
is_business: parseInt(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "1", label: "Služební" },
|
||||||
<option value={1}>Služební</option>
|
{ value: "0", label: "Soukromá" },
|
||||||
<option value={0}>Soukromá</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Poznámky">
|
<Field label="Poznámky">
|
||||||
<textarea
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
value={editForm.notes}
|
value={editForm.notes}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, notes: e.target.value })
|
setEditForm({ ...editForm, notes: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={2}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</FormModal>
|
</Modal>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, trip: null })}
|
onClose={() => setDeleteConfirm({ show: false, trip: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -915,6 +910,6 @@ export default function TripsAdmin() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import Box from "@mui/material/Box";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import FormField from "../components/FormField";
|
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
|
||||||
import {
|
import {
|
||||||
tripHistoryOptions,
|
Card,
|
||||||
tripVehiclesOptions,
|
DataTable,
|
||||||
type BackendTrip,
|
Select,
|
||||||
type TripVehicle,
|
MonthField,
|
||||||
} from "../lib/queries/trips";
|
StatusChip,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
FilterBar,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
StatCard,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -29,8 +35,56 @@ interface Trip {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TripsIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="10" />
|
||||||
|
<line x1="18" y1="20" x2="18" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const TotalIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const BusinessIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||||
|
<circle cx="5.5" cy="18" r="2" />
|
||||||
|
<circle cx="18.5" cy="18" r="2" />
|
||||||
|
<path d="M8 18h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function TripsHistory() {
|
export default function TripsHistory() {
|
||||||
const alert = useAlert();
|
|
||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const [month, setMonth] = useState(() => {
|
const [month, setMonth] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -48,7 +102,7 @@ export default function TripsHistory() {
|
|||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const trips = (tripsData ?? []).map((t) => ({
|
const trips: Trip[] = (tripsData ?? []).map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
trip_date: t.trip_date,
|
trip_date: t.trip_date,
|
||||||
spz: t.vehicles?.spz ?? "",
|
spz: t.vehicles?.spz ?? "",
|
||||||
@@ -81,222 +135,156 @@ export default function TripsHistory() {
|
|||||||
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
|
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columns: DataColumn<Trip>[] = [
|
||||||
|
{
|
||||||
|
key: "trip_date",
|
||||||
|
header: "Datum",
|
||||||
|
width: "11%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => formatDate(trip.trip_date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vehicle",
|
||||||
|
header: "Vozidlo",
|
||||||
|
width: "10%",
|
||||||
|
render: (trip) => <StatusChip label={trip.spz} color="default" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "driver",
|
||||||
|
header: "Řidič",
|
||||||
|
width: "14%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
|
{trip.driver_name}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "route",
|
||||||
|
header: "Trasa",
|
||||||
|
width: "18%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{trip.route_from} → {trip.route_to}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "km",
|
||||||
|
header: "Stav km",
|
||||||
|
width: "13%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{ whiteSpace: "nowrap", color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "distance",
|
||||||
|
header: "Vzdálenost",
|
||||||
|
width: "10%",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (trip) => `${formatKm(trip.distance)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Typ",
|
||||||
|
width: "10%",
|
||||||
|
render: (trip) => (
|
||||||
|
<StatusChip
|
||||||
|
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||||
|
color={trip.is_business ? "success" : "warning"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notes",
|
||||||
|
header: "Poznámka",
|
||||||
|
width: "14%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
|
{trip.notes || "—"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader title="Historie jízd" subtitle={getMonthName(month)} />
|
||||||
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 jízd</h1>
|
|
||||||
<p className="admin-page-subtitle">{getMonthName(month)}</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card"
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Box>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
>
|
<Select
|
||||||
<div className="admin-card-body">
|
value={vehicleId}
|
||||||
<div className="admin-form-row">
|
onChange={(value) => setVehicleId(value)}
|
||||||
<FormField label="Měsíc">
|
options={[
|
||||||
<AdminDatePicker
|
{ value: "", label: "Všechna vozidla" },
|
||||||
mode="month"
|
...vehicles.map((v) => ({
|
||||||
value={month}
|
value: String(v.id),
|
||||||
onChange={(val: string) => setMonth(val)}
|
label: `${v.spz} - ${v.name}`,
|
||||||
/>
|
})),
|
||||||
</FormField>
|
]}
|
||||||
<FormField label="Vozidlo">
|
/>
|
||||||
<select
|
</Box>
|
||||||
value={vehicleId}
|
</FilterBar>
|
||||||
onChange={(e) => setVehicleId(e.target.value)}
|
|
||||||
className="admin-form-select"
|
|
||||||
>
|
|
||||||
<option value="">Všechna vozidla</option>
|
|
||||||
{vehicles.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.spz} - {v.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-grid admin-grid-3 mt-6"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: {
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
xs: "1fr",
|
||||||
|
sm: "1fr 1fr",
|
||||||
|
lg: "repeat(3, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mt: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-stat-card info">
|
<StatCard
|
||||||
<div className="admin-stat-icon info">
|
label="Počet jízd"
|
||||||
<svg
|
value={totals.count}
|
||||||
width="22"
|
icon={TripsIcon}
|
||||||
height="22"
|
color="info"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
<StatCard
|
||||||
stroke="currentColor"
|
label="Celkem naježděno"
|
||||||
strokeWidth="2"
|
value={`${formatKm(totals.total)} km`}
|
||||||
strokeLinecap="round"
|
icon={TotalIcon}
|
||||||
strokeLinejoin="round"
|
color="default"
|
||||||
>
|
/>
|
||||||
<line x1="12" y1="20" x2="12" y2="10" />
|
<StatCard
|
||||||
<line x1="18" y1="20" x2="18" y2="4" />
|
label="Služební km"
|
||||||
<line x1="6" y1="20" x2="6" y2="16" />
|
value={`${formatKm(totals.business)} km`}
|
||||||
</svg>
|
icon={BusinessIcon}
|
||||||
</div>
|
color="success"
|
||||||
<div className="admin-stat-content">
|
/>
|
||||||
<span className="admin-stat-value">{totals.count}</span>
|
</Box>
|
||||||
<span className="admin-stat-label">Počet jízd</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card">
|
|
||||||
<div className="admin-stat-icon">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.total)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Celkem naježděno</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card success">
|
|
||||||
<div className="admin-stat-icon success">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
|
||||||
<circle cx="5.5" cy="18" r="2" />
|
|
||||||
<circle cx="18.5" cy="18" r="2" />
|
|
||||||
<path d="M8 18h8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.business)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Služební km</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Trips Table */}
|
{/* Trips Table */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
className="admin-card mt-6"
|
{isPending ? (
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<LoadingState />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
) : (
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
<DataTable<Trip>
|
||||||
>
|
columns={columns}
|
||||||
<div className="admin-card-body">
|
rows={trips}
|
||||||
{isPending ? (
|
rowKey={(trip) => trip.id}
|
||||||
<div className="admin-loading">
|
empty={
|
||||||
<div className="admin-spinner" />
|
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||||
</div>
|
}
|
||||||
) : (
|
/>
|
||||||
<>
|
)}
|
||||||
{trips.length === 0 && (
|
</Card>
|
||||||
<div className="admin-empty-state">
|
</PageEnter>
|
||||||
<p>Žádné záznamy jízd pro vybrané období.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{trips.length > 0 && (
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>Vozidlo</th>
|
|
||||||
<th>Řidič</th>
|
|
||||||
<th>Trasa</th>
|
|
||||||
<th>Stav km</th>
|
|
||||||
<th>Vzdálenost</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trips.map((trip) => (
|
|
||||||
<tr key={trip.id}>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(trip.trip_date)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="admin-badge">{trip.spz}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{trip.driver_name}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{trip.route_from} → {trip.route_to}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatKm(trip.start_km)} -{" "}
|
|
||||||
{formatKm(trip.end_km)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<strong>{formatKm(trip.distance)} km</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
|
||||||
>
|
|
||||||
{trip.is_business ? "Služební" : "Soukromá"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
maxWidth: "200px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{trip.notes || "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
366
src/admin/pages/UiKit.tsx
Normal file
366
src/admin/pages/UiKit.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
StatusChip,
|
||||||
|
Tabs,
|
||||||
|
TabPanel,
|
||||||
|
Pagination,
|
||||||
|
CheckboxField,
|
||||||
|
Alert,
|
||||||
|
DataTable,
|
||||||
|
DateField,
|
||||||
|
type DataColumn,
|
||||||
|
PageHeader,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
FilterBar,
|
||||||
|
StatCard,
|
||||||
|
} from "../ui";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
const BoxIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InboxIcon = (
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||||
|
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface DemoRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
const DEMO_ROWS: DemoRow[] = [
|
||||||
|
{ id: 1, name: "Beta", qty: 30 },
|
||||||
|
{ id: 2, name: "Alfa", qty: 10 },
|
||||||
|
{ id: 3, name: "Gama", qty: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
variant="overline"
|
||||||
|
color="text.secondary"
|
||||||
|
display="block"
|
||||||
|
mt={2}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UiKit() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const [selectVal, setSelectVal] = useState("a");
|
||||||
|
const [tab, setTab] = useState("one");
|
||||||
|
const [page, setPage] = useState(2);
|
||||||
|
const [checked, setChecked] = useState(true);
|
||||||
|
const [date, setDate] = useState("2026-06-06");
|
||||||
|
const [sortBy, setSortBy] = useState("name");
|
||||||
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||||
|
const [lastClicked, setLastClicked] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onSort = (key: string) => {
|
||||||
|
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
else {
|
||||||
|
setSortBy(key);
|
||||||
|
setSortDir("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sortedRows = [...DEMO_ROWS].sort((a, b) => {
|
||||||
|
const cmp = String(a[sortBy as keyof DemoRow]).localeCompare(
|
||||||
|
String(b[sortBy as keyof DemoRow]),
|
||||||
|
"cs",
|
||||||
|
{ numeric: true },
|
||||||
|
);
|
||||||
|
return sortDir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
const columns: DataColumn<DemoRow>[] = [
|
||||||
|
{ key: "name", header: "Název", sortKey: "name", render: (r) => r.name },
|
||||||
|
{
|
||||||
|
key: "qty",
|
||||||
|
header: "Množství",
|
||||||
|
align: "right",
|
||||||
|
mono: true,
|
||||||
|
sortKey: "qty",
|
||||||
|
render: (r) => r.qty,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<Box sx={{ p: 4, minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
|
||||||
|
<Typography variant="h4">UI Kit</Typography>
|
||||||
|
<Button color="inherit" variant="outlined" onClick={toggleTheme}>
|
||||||
|
Theme: {theme}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={3} sx={{ maxWidth: 640 }}>
|
||||||
|
<Card>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Faktury
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
12 vystavených tento měsíc
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} mb={2}>
|
||||||
|
<StatusChip label="Zaplaceno" color="success" />
|
||||||
|
<StatusChip label="Po splatnosti" color="error" />
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Hledat fakturu"
|
||||||
|
placeholder="Číslo nebo klient…"
|
||||||
|
/>
|
||||||
|
<Box mt={2}>
|
||||||
|
<Button>Nová faktura</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Typography variant="h6">Kit — Phase 3</Typography>
|
||||||
|
|
||||||
|
<SectionLabel>Select</SectionLabel>
|
||||||
|
<Select
|
||||||
|
value={selectVal}
|
||||||
|
onChange={setSelectVal}
|
||||||
|
options={[
|
||||||
|
{ value: "a", label: "Možnost A" },
|
||||||
|
{ value: "b", label: "Možnost B" },
|
||||||
|
{ value: "c", label: "Možnost C" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel>StatusChip</SectionLabel>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
<StatusChip label="default" />
|
||||||
|
<StatusChip label="success" color="success" />
|
||||||
|
<StatusChip label="error" color="error" />
|
||||||
|
<StatusChip label="warning" color="warning" />
|
||||||
|
<StatusChip label="info" color="info" />
|
||||||
|
<StatusChip
|
||||||
|
label="clickable"
|
||||||
|
color="success"
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<SectionLabel>Tabs</SectionLabel>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
tabs={[
|
||||||
|
{ value: "one", label: "První" },
|
||||||
|
{ value: "two", label: "Druhá" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TabPanel value="one" current={tab}>
|
||||||
|
<Typography>Obsah první záložky.</Typography>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="two" current={tab}>
|
||||||
|
<Typography>Obsah druhé záložky.</Typography>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<SectionLabel>Sortable DataTable + Pagination</SectionLabel>
|
||||||
|
<DataTable<DemoRow>
|
||||||
|
columns={columns}
|
||||||
|
rows={sortedRows}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<Pagination page={page} pageCount={5} onChange={setPage} />
|
||||||
|
|
||||||
|
<SectionLabel>DateField + Checkbox</SectionLabel>
|
||||||
|
<DateField value={date} onChange={setDate} label="Datum" />
|
||||||
|
<Box>
|
||||||
|
<CheckboxField
|
||||||
|
label="Souhlasím"
|
||||||
|
checked={checked}
|
||||||
|
onChange={setChecked}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SectionLabel>Alert</SectionLabel>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Alert severity="success">Úspěšně uloženo.</Alert>
|
||||||
|
<Alert severity="error">Něco se pokazilo.</Alert>
|
||||||
|
<Alert severity="warning">Pozor na toto.</Alert>
|
||||||
|
<Alert severity="info">Informace.</Alert>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Phase 6 primitives ── */}
|
||||||
|
<Card>
|
||||||
|
<Typography variant="h6">Kit — Phase 6 Primitives</Typography>
|
||||||
|
|
||||||
|
<SectionLabel>PageHeader — title only</SectionLabel>
|
||||||
|
<PageHeader title="Přehled projektů" />
|
||||||
|
|
||||||
|
<SectionLabel>PageHeader — with subtitle + actions</SectionLabel>
|
||||||
|
<PageHeader
|
||||||
|
title="Sklady"
|
||||||
|
subtitle="Správa skladových zásob"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="outlined" color="inherit" size="small">
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button size="small">Nová položka</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel>EmptyState — icon + action</SectionLabel>
|
||||||
|
<EmptyState
|
||||||
|
icon={InboxIcon}
|
||||||
|
title="Žádné záznamy"
|
||||||
|
description="Zatím nebyly přidány žádné položky."
|
||||||
|
action={<Button size="small">Přidat první položku</Button>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel>EmptyState — title only</SectionLabel>
|
||||||
|
<EmptyState title="Žádné výsledky" />
|
||||||
|
|
||||||
|
<SectionLabel>LoadingState</SectionLabel>
|
||||||
|
<LoadingState label="Načítám data…" />
|
||||||
|
|
||||||
|
<SectionLabel>FilterBar</SectionLabel>
|
||||||
|
<FilterBar>
|
||||||
|
<Box sx={{ width: 200 }}>
|
||||||
|
<TextField placeholder="Hledat…" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ width: 160 }}>
|
||||||
|
<Select
|
||||||
|
value={selectVal}
|
||||||
|
onChange={setSelectVal}
|
||||||
|
options={[
|
||||||
|
{ value: "a", label: "Všechny stavy" },
|
||||||
|
{ value: "b", label: "Aktivní" },
|
||||||
|
{ value: "c", label: "Uzavřené" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button size="small" variant="outlined" color="inherit">
|
||||||
|
Resetovat
|
||||||
|
</Button>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<SectionLabel>StatCard grid — all colors</SectionLabel>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard label="Celkem" value="1 240" icon={BoxIcon} />
|
||||||
|
<StatCard
|
||||||
|
label="Aktivní"
|
||||||
|
value="38"
|
||||||
|
icon={BoxIcon}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Dokončené"
|
||||||
|
value="112"
|
||||||
|
icon={BoxIcon}
|
||||||
|
color="success"
|
||||||
|
footer="↑ 12 oproti minulému měsíci"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Varování"
|
||||||
|
value="5"
|
||||||
|
icon={BoxIcon}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
<StatCard label="Chyby" value="2" icon={BoxIcon} color="error" />
|
||||||
|
<StatCard
|
||||||
|
label="Informace"
|
||||||
|
value="99"
|
||||||
|
icon={BoxIcon}
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SectionLabel>
|
||||||
|
DataTable — onRowClick + rowDanger (row 2 = danger)
|
||||||
|
</SectionLabel>
|
||||||
|
{lastClicked && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
mb={1}
|
||||||
|
display="block"
|
||||||
|
>
|
||||||
|
Naposledy kliknuto: {lastClicked}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<DataTable<DemoRow>
|
||||||
|
columns={columns}
|
||||||
|
rows={DEMO_ROWS}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
onRowClick={(r) => setLastClicked(r.name)}
|
||||||
|
rowDanger={(r) => r.id === 2}
|
||||||
|
rowInactive={(r) => r.id === 3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel>Button as RouterLink</SectionLabel>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
|
to="/ui-kit"
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
RouterLink button (current page)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href="https://mui.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
Anchor button
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,33 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { motion } from "framer-motion";
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import {
|
import {
|
||||||
userListOptions,
|
userListOptions,
|
||||||
roleListOptions,
|
roleListOptions,
|
||||||
type User,
|
type User,
|
||||||
} from "../lib/queries/users";
|
} from "../lib/queries/users";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
SwitchField,
|
||||||
|
Select,
|
||||||
|
StatusChip,
|
||||||
|
LoadingState,
|
||||||
|
PageEnter,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -22,10 +37,59 @@ interface FormData {
|
|||||||
password: string;
|
password: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
role_id: number | string;
|
role_id: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payload sent to the API. The kit Select is string-based, so the form keeps
|
||||||
|
// role_id as a string and we convert it to a number at the submit boundary
|
||||||
|
// (the server expects a numeric role_id).
|
||||||
|
type UserPayload = Omit<FormData, "role_id"> & { role_id: number };
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function Users() {
|
export default function Users() {
|
||||||
const { user: currentUser, updateUser, hasPermission } = useAuth();
|
const { user: currentUser, updateUser, hasPermission } = useAuth();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -39,6 +103,7 @@ export default function Users() {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
}>({ isOpen: false, user: null });
|
}>({ isOpen: false, user: null });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [formData, setFormData] = useState<FormData>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -58,8 +123,8 @@ export default function Users() {
|
|||||||
"projects",
|
"projects",
|
||||||
];
|
];
|
||||||
|
|
||||||
const saveUser = useApiMutation<FormData, void>({
|
const saveUser = useApiMutation<UserPayload, void>({
|
||||||
url: (input) =>
|
url: () =>
|
||||||
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
|
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
|
||||||
method: () => (editingUser ? "PUT" : "POST"),
|
method: () => (editingUser ? "PUT" : "POST"),
|
||||||
invalidate: USER_INVALIDATE,
|
invalidate: USER_INVALIDATE,
|
||||||
@@ -115,9 +180,10 @@ export default function Users() {
|
|||||||
password: "",
|
password: "",
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
role_id: roles[0]?.id || "",
|
role_id: roles[0]?.id ? String(roles[0].id) : "",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
|
setErrors({});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,9 +195,10 @@ export default function Users() {
|
|||||||
password: "",
|
password: "",
|
||||||
first_name: user.first_name,
|
first_name: user.first_name,
|
||||||
last_name: user.last_name,
|
last_name: user.last_name,
|
||||||
role_id: user.role_id,
|
role_id: String(user.role_id),
|
||||||
is_active: user.is_active,
|
is_active: user.is_active,
|
||||||
});
|
});
|
||||||
|
setErrors({});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,9 +208,22 @@ export default function Users() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!formData.first_name) newErrors.first_name = "Zadejte jméno";
|
||||||
|
if (!formData.last_name) newErrors.last_name = "Zadejte příjmení";
|
||||||
|
if (!formData.username) newErrors.username = "Zadejte uživatelské jméno";
|
||||||
|
if (!formData.email) newErrors.email = "Zadejte e-mail";
|
||||||
|
if (!editingUser && !formData.password)
|
||||||
|
newErrors.password = "Zadejte heslo";
|
||||||
|
setErrors(newErrors);
|
||||||
|
if (Object.keys(newErrors).length > 0) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveUser.mutateAsync(formData);
|
await saveUser.mutateAsync({
|
||||||
|
...formData,
|
||||||
|
role_id: Number(formData.role_id),
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,14 +231,6 @@ export default function Users() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteModal = (user: User) => {
|
|
||||||
setDeleteModal({ isOpen: true, user });
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDeleteModal = () => {
|
|
||||||
setDeleteModal({ isOpen: false, user: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteModal.user) return;
|
if (!deleteModal.user) return;
|
||||||
try {
|
try {
|
||||||
@@ -175,295 +247,225 @@ export default function Users() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoleBadgeClass = (roleName: string): string => {
|
|
||||||
switch (roleName) {
|
|
||||||
case "admin":
|
|
||||||
return "admin-badge admin-badge-admin";
|
|
||||||
default:
|
|
||||||
return "admin-badge admin-badge-viewer";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<User>[] = [
|
||||||
<div>
|
{
|
||||||
<motion.div
|
key: "user",
|
||||||
className="admin-page-header"
|
header: "Uživatel",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
render: (u) => (
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
transition={{ duration: 0.25 }}
|
<Avatar sx={{ bgcolor: "primary.main", width: 36, height: 36 }}>
|
||||||
>
|
{(u.first_name || u.username).charAt(0).toUpperCase()}
|
||||||
<div>
|
</Avatar>
|
||||||
<h1 className="admin-page-title">Uživatelé</h1>
|
<Box>
|
||||||
<p className="admin-page-subtitle">
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
Správa uživatelských účtů a oprávnění
|
{u.first_name} {u.last_name}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
<Typography variant="caption" color="text.secondary">
|
||||||
<button
|
@{u.username}
|
||||||
onClick={openCreateModal}
|
</Typography>
|
||||||
className="admin-btn admin-btn-primary"
|
</Box>
|
||||||
>
|
</Box>
|
||||||
<svg
|
),
|
||||||
width="20"
|
},
|
||||||
height="20"
|
{ key: "email", header: "E-mail", render: (u) => u.email },
|
||||||
viewBox="0 0 24 24"
|
{
|
||||||
fill="none"
|
key: "role",
|
||||||
stroke="currentColor"
|
header: "Role",
|
||||||
strokeWidth="2"
|
render: (u) => (
|
||||||
|
<StatusChip
|
||||||
|
label={u.roles?.display_name || u.roles?.name || "—"}
|
||||||
|
color={u.roles?.name === "admin" ? "warning" : "default"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Stav",
|
||||||
|
render: (u) => (
|
||||||
|
<StatusChip
|
||||||
|
label={u.is_active ? "Aktivní" : "Neaktivní"}
|
||||||
|
color={u.is_active ? "success" : "default"}
|
||||||
|
onClick={u.id === currentUser?.id ? undefined : () => toggleActive(u)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
align: "right",
|
||||||
|
render: (u) => (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEditModal(u)}
|
||||||
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
>
|
>
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
{EditIcon}
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
</IconButton>
|
||||||
</svg>
|
{u.id !== currentUser?.id && (
|
||||||
Přidat uživatele
|
<IconButton
|
||||||
</button>
|
size="small"
|
||||||
</motion.div>
|
color="error"
|
||||||
|
onClick={() => setDeleteModal({ isOpen: true, user: u })}
|
||||||
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
|
>
|
||||||
|
{DeleteIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
className="admin-card"
|
<PageEnter>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Box
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sx={{
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 3,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<Typography variant="h4">Správa uživatelů</Typography>
|
||||||
<div className="admin-table-responsive">
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
<table className="admin-table">
|
Přidat uživatele
|
||||||
<thead>
|
</Button>
|
||||||
<tr>
|
</Box>
|
||||||
<th>Uživatel</th>
|
|
||||||
<th>E-mail</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th>Stav</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<tr key={user.id}>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-user">
|
|
||||||
<div className="admin-table-avatar">
|
|
||||||
{(user.first_name || user.username)
|
|
||||||
.charAt(0)
|
|
||||||
.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="admin-table-name">
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</div>
|
|
||||||
<div className="admin-table-username">
|
|
||||||
@{user.username}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{user.email}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={getRoleBadgeClass(user.roles?.name ?? "")}
|
|
||||||
>
|
|
||||||
{user.roles?.display_name || user.roles?.name || "—"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
user.id !== currentUser?.id && toggleActive(user)
|
|
||||||
}
|
|
||||||
disabled={user.id === currentUser?.id}
|
|
||||||
className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
|
|
||||||
style={{
|
|
||||||
cursor:
|
|
||||||
user.id === currentUser?.id
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.is_active ? "Aktivní" : "Neaktivní"}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(user)}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{user.id !== currentUser?.id && (
|
|
||||||
<button
|
|
||||||
onClick={() => openDeleteModal(user)}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<FormModal
|
<Card>
|
||||||
|
<DataTable<User>
|
||||||
|
columns={columns}
|
||||||
|
rows={users}
|
||||||
|
rowKey={(u) => u.id}
|
||||||
|
rowInactive={(u) => !u.is_active}
|
||||||
|
empty={
|
||||||
|
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
Zatím nejsou žádní uživatelé.
|
||||||
|
</Typography>
|
||||||
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
|
Přidat prvního uživatele
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingUser ? "Upravit uživatele" : "Přidat nového uživatele"}
|
title={editingUser ? "Upravit uživatele" : "Přidat nového uživatele"}
|
||||||
submitLabel={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
|
|
||||||
loading={saving}
|
loading={saving}
|
||||||
|
submitText={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
<div className="admin-form-row">
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
<FormField label="Jméno">
|
<Field label="Jméno" required error={errors.first_name}>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={formData.first_name}
|
value={formData.first_name}
|
||||||
onChange={(e) =>
|
error={!!errors.first_name}
|
||||||
setFormData({
|
onChange={(e) => {
|
||||||
...formData,
|
setFormData({ ...formData, first_name: e.target.value });
|
||||||
first_name: e.target.value,
|
setErrors((prev) => ({ ...prev, first_name: "" }));
|
||||||
})
|
}}
|
||||||
}
|
|
||||||
required
|
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Příjmení">
|
</Box>
|
||||||
<input
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
type="text"
|
<Field label="Příjmení" required error={errors.last_name}>
|
||||||
|
<TextField
|
||||||
value={formData.last_name}
|
value={formData.last_name}
|
||||||
onChange={(e) =>
|
error={!!errors.last_name}
|
||||||
setFormData({
|
onChange={(e) => {
|
||||||
...formData,
|
setFormData({ ...formData, last_name: e.target.value });
|
||||||
last_name: e.target.value,
|
setErrors((prev) => ({ ...prev, last_name: "" }));
|
||||||
})
|
}}
|
||||||
}
|
|
||||||
required
|
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<FormField label="Uživatelské jméno">
|
<Field label="Uživatelské jméno" required error={errors.username}>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
value={formData.username}
|
||||||
value={formData.username}
|
error={!!errors.username}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setFormData({ ...formData, username: e.target.value })
|
setFormData({ ...formData, username: e.target.value });
|
||||||
}
|
setErrors((prev) => ({ ...prev, username: "" }));
|
||||||
required
|
}}
|
||||||
className="admin-form-input"
|
/>
|
||||||
/>
|
</Field>
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="E-mail">
|
<Field label="E-mail" required error={errors.email}>
|
||||||
<input
|
<TextField
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) =>
|
error={!!errors.email}
|
||||||
setFormData({ ...formData, email: e.target.value })
|
onChange={(e) => {
|
||||||
}
|
setFormData({ ...formData, email: e.target.value });
|
||||||
required
|
setErrors((prev) => ({ ...prev, email: "" }));
|
||||||
className="admin-form-input"
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField
|
<Field
|
||||||
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
|
label={`Heslo${editingUser ? " (ponechte prázdné pro zachování stávajícího)" : ""}`}
|
||||||
>
|
required={!editingUser}
|
||||||
<input
|
error={errors.password}
|
||||||
type="password"
|
>
|
||||||
value={formData.password}
|
<TextField
|
||||||
onChange={(e) =>
|
type="password"
|
||||||
setFormData({ ...formData, password: e.target.value })
|
value={formData.password}
|
||||||
}
|
error={!!errors.password}
|
||||||
required={!editingUser}
|
onChange={(e) => {
|
||||||
className="admin-form-input"
|
setFormData({ ...formData, password: e.target.value });
|
||||||
/>
|
setErrors((prev) => ({ ...prev, password: "" }));
|
||||||
</FormField>
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<FormField label="Role">
|
<Field label="Role">
|
||||||
<select
|
<Select
|
||||||
value={formData.role_id}
|
value={formData.role_id}
|
||||||
onChange={(e) =>
|
onChange={(value) => setFormData({ ...formData, role_id: value })}
|
||||||
setFormData({ ...formData, role_id: e.target.value })
|
options={roles.map((role) => ({
|
||||||
}
|
value: String(role.id),
|
||||||
required
|
label: role.display_name || role.name,
|
||||||
className="admin-form-select"
|
}))}
|
||||||
>
|
/>
|
||||||
{roles.map((role) => (
|
</Field>
|
||||||
<option key={role.id} value={role.id}>
|
|
||||||
{role.display_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<label className="admin-form-checkbox">
|
<SwitchField
|
||||||
<input
|
label="Účet je aktivní"
|
||||||
type="checkbox"
|
checked={formData.is_active}
|
||||||
checked={formData.is_active}
|
onChange={(v) => setFormData({ ...formData, is_active: v })}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({
|
</Modal>
|
||||||
...formData,
|
|
||||||
is_active: e.target.checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>Účet je aktivní</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
onClose={closeDeleteModal}
|
onClose={() => setDeleteModal({ isOpen: false, user: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat uživatele"
|
title="Smazat uživatele"
|
||||||
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
|
message={
|
||||||
|
deleteModal.user
|
||||||
|
? `Opravdu chcete smazat uživatele "${deleteModal.user.first_name} ${deleteModal.user.last_name}"? Tato akce je nevratná.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
confirmVariant="danger"
|
||||||
type="danger"
|
|
||||||
loading={deleteUser.isPending}
|
loading={deleteUser.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
|
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { vehicleListOptions } from "../lib/queries/vehicles";
|
import { vehicleListOptions } from "../lib/queries/vehicles";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
SwitchField,
|
||||||
|
LoadingState,
|
||||||
|
PageEnter,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -35,6 +47,50 @@ interface VehicleForm {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function Vehicles() {
|
export default function Vehicles() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -167,279 +223,197 @@ export default function Vehicles() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<Vehicle>[] = [
|
||||||
<div>
|
{ key: "spz", header: "SPZ", mono: true, bold: true, render: (v) => v.spz },
|
||||||
<motion.div
|
{ key: "name", header: "Název", render: (v) => v.name },
|
||||||
className="admin-page-header"
|
{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
key: "brand",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
header: "Značka / Model",
|
||||||
transition={{ duration: 0.25 }}
|
render: (v) =>
|
||||||
>
|
v.brand || v.model ? `${v.brand || ""} ${v.model || ""}`.trim() : "—",
|
||||||
<div>
|
},
|
||||||
<h1 className="admin-page-title">Správa vozidel</h1>
|
{
|
||||||
</div>
|
key: "initial_km",
|
||||||
<div className="admin-page-actions">
|
header: "Počáteční km",
|
||||||
<button
|
align: "right",
|
||||||
onClick={openCreateModal}
|
mono: true,
|
||||||
className="admin-btn admin-btn-primary"
|
render: (v) => `${formatKm(v.initial_km)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "current_km",
|
||||||
|
header: "Aktuální km",
|
||||||
|
align: "right",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (v) => `${formatKm(v.current_km)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "trips",
|
||||||
|
header: "Počet jízd",
|
||||||
|
align: "right",
|
||||||
|
mono: true,
|
||||||
|
render: (v) => v.trip_count,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Stav",
|
||||||
|
render: (v) => (
|
||||||
|
<Chip
|
||||||
|
label={v.is_active ? "Aktivní" : "Neaktivní"}
|
||||||
|
size="small"
|
||||||
|
color={v.is_active ? "success" : "default"}
|
||||||
|
onClick={() => toggleActive(v)}
|
||||||
|
sx={{ cursor: "pointer", fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
align: "right",
|
||||||
|
render: (v) => (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEditModal(v)}
|
||||||
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
>
|
>
|
||||||
<svg
|
{EditIcon}
|
||||||
width="20"
|
</IconButton>
|
||||||
height="20"
|
<IconButton
|
||||||
viewBox="0 0 24 24"
|
size="small"
|
||||||
fill="none"
|
color="error"
|
||||||
stroke="currentColor"
|
onClick={() => setDeleteConfirm({ show: true, vehicle: v })}
|
||||||
strokeWidth="2"
|
aria-label="Smazat"
|
||||||
>
|
title="Smazat"
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
>
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
{DeleteIcon}
|
||||||
</svg>
|
</IconButton>
|
||||||
Přidat vozidlo
|
</Box>
|
||||||
</button>
|
),
|
||||||
</div>
|
},
|
||||||
</motion.div>
|
];
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
className="admin-card"
|
<PageEnter>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Box
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sx={{
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 3,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<Typography variant="h4">Správa vozidel</Typography>
|
||||||
{vehicles.length === 0 && (
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
<div className="admin-empty-state">
|
Přidat vozidlo
|
||||||
<div className="admin-empty-icon">
|
</Button>
|
||||||
<svg
|
</Box>
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="1" y="3" width="15" height="13" />
|
|
||||||
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
|
|
||||||
<circle cx="5.5" cy="18.5" r="2.5" />
|
|
||||||
<circle cx="18.5" cy="18.5" r="2.5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>Zatím nejsou žádná vozidla.</p>
|
|
||||||
<button
|
|
||||||
onClick={openCreateModal}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
Přidat první vozidlo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{vehicles.length > 0 && (
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>SPZ</th>
|
|
||||||
<th>Název</th>
|
|
||||||
<th>Značka / Model</th>
|
|
||||||
<th>Počáteční km</th>
|
|
||||||
<th>Aktuální km</th>
|
|
||||||
<th>Počet jízd</th>
|
|
||||||
<th>Stav</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{vehicles.map((vehicle) => (
|
|
||||||
<tr
|
|
||||||
key={vehicle.id}
|
|
||||||
className={
|
|
||||||
!vehicle.is_active ? "admin-table-row-inactive" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<td className="admin-mono fw-500">{vehicle.spz}</td>
|
|
||||||
<td>{vehicle.name}</td>
|
|
||||||
<td>
|
|
||||||
{vehicle.brand || vehicle.model
|
|
||||||
? `${vehicle.brand || ""} ${vehicle.model || ""}`.trim()
|
|
||||||
: "—"}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatKm(vehicle.initial_km)} km
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono fw-500">
|
|
||||||
{formatKm(vehicle.current_km)} km
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">{vehicle.trip_count}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleActive(vehicle)}
|
|
||||||
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
|
|
||||||
>
|
|
||||||
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(vehicle)}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteConfirm({ show: true, vehicle })
|
|
||||||
}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
<Card>
|
||||||
<FormModal
|
<DataTable<Vehicle>
|
||||||
|
columns={columns}
|
||||||
|
rows={vehicles}
|
||||||
|
rowKey={(v) => v.id}
|
||||||
|
rowInactive={(v) => !v.is_active}
|
||||||
|
empty={
|
||||||
|
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
Zatím nejsou žádná vozidla.
|
||||||
|
</Typography>
|
||||||
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
|
Přidat první vozidlo
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
|
title={editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
<div className="admin-form-row">
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
<FormField label="SPZ" error={errors.spz} required>
|
<Field label="SPZ" required error={errors.spz}>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={form.spz}
|
value={form.spz}
|
||||||
|
placeholder="1AB 2345"
|
||||||
|
error={!!errors.spz}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({
|
setForm({ ...form, spz: e.target.value.toUpperCase() });
|
||||||
...form,
|
|
||||||
spz: e.target.value.toUpperCase(),
|
|
||||||
});
|
|
||||||
setErrors((prev) => ({ ...prev, spz: "" }));
|
setErrors((prev) => ({ ...prev, spz: "" }));
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="1AB 2345"
|
|
||||||
aria-invalid={!!errors.spz}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
</Box>
|
||||||
<FormField label="Název" error={errors.name} required>
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
<input
|
<Field label="Název" required error={errors.name}>
|
||||||
type="text"
|
<TextField
|
||||||
value={form.name}
|
value={form.name}
|
||||||
|
placeholder="Služební #1"
|
||||||
|
error={!!errors.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, name: e.target.value });
|
setForm({ ...form, name: e.target.value });
|
||||||
setErrors((prev) => ({ ...prev, name: "" }));
|
setErrors((prev) => ({ ...prev, name: "" }));
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Služební #1"
|
|
||||||
aria-invalid={!!errors.name}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
<FormField label="Značka">
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
<input
|
<Field label="Značka">
|
||||||
type="text"
|
<TextField
|
||||||
value={form.brand}
|
value={form.brand}
|
||||||
onChange={(e) => setForm({ ...form, brand: e.target.value })}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Škoda"
|
placeholder="Škoda"
|
||||||
|
onChange={(e) => setForm({ ...form, brand: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
</Box>
|
||||||
<FormField label="Model">
|
<Box sx={{ flex: "1 1 200px" }}>
|
||||||
<input
|
<Field label="Model">
|
||||||
type="text"
|
<TextField
|
||||||
value={form.model}
|
value={form.model}
|
||||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Octavia Combi"
|
placeholder="Octavia Combi"
|
||||||
|
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field
|
||||||
<label className="admin-form-label">Počáteční stav km</label>
|
label="Počáteční stav km"
|
||||||
<input
|
hint="Stav tachometru při přidání vozidla"
|
||||||
type="number"
|
>
|
||||||
inputMode="numeric"
|
<TextField
|
||||||
value={form.initial_km}
|
type="number"
|
||||||
onChange={(e) =>
|
value={form.initial_km}
|
||||||
setForm({
|
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
|
||||||
...form,
|
onChange={(e) =>
|
||||||
initial_km: parseInt(e.target.value) || 0,
|
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
|
||||||
})
|
}
|
||||||
}
|
/>
|
||||||
className="admin-form-input"
|
</Field>
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<small className="admin-form-hint">
|
|
||||||
Stav tachometru při přidání vozidla
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="admin-form-checkbox">
|
<SwitchField
|
||||||
<input
|
label="Vozidlo je aktivní"
|
||||||
type="checkbox"
|
checked={form.is_active}
|
||||||
checked={form.is_active}
|
onChange={(v) => setForm({ ...form, is_active: v })}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setForm({ ...form, is_active: e.target.checked })
|
</Modal>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>Vozidlo je aktivní</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
<ConfirmDialog
|
||||||
<ConfirmModal
|
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
|
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -452,6 +426,6 @@ export default function Vehicles() {
|
|||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,89 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import {
|
import {
|
||||||
warehouseStockStatusOptions,
|
warehouseStockStatusOptions,
|
||||||
warehouseBelowMinimumOptions,
|
warehouseBelowMinimumOptions,
|
||||||
warehouseReservationListOptions,
|
warehouseReservationListOptions,
|
||||||
warehouseMovementLogOptions,
|
warehouseMovementLogOptions,
|
||||||
type WarehouseItem,
|
type WarehouseItem,
|
||||||
type WarehouseReservation,
|
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||||
import useReducedMotion from "../hooks/useReducedMotion";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
StatCard,
|
||||||
|
StatusChip,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
type DataColumn,
|
||||||
|
type StatCardColor,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
receipt: "Příjem",
|
receipt: "Příjem",
|
||||||
issue: "Výdej",
|
issue: "Výdej",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_TONE: Record<string, string> = {
|
// Movement type → StatusChip color (legacy: incoming=success, outgoing=info)
|
||||||
receipt: "admin-badge-incoming",
|
const TYPE_COLOR: Record<string, "success" | "info"> = {
|
||||||
issue: "admin-badge-outgoing",
|
receipt: "success",
|
||||||
|
issue: "info",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ReceiptIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="17 11 12 6 7 11" />
|
||||||
|
<line x1="12" y1="6" x2="12" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IssueIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="7 13 12 18 17 13" />
|
||||||
|
<line x1="12" y1="18" x2="12" y2="6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface BelowMinRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
available_quantity: number;
|
||||||
|
min_quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovementRow {
|
||||||
|
_idx: number;
|
||||||
|
date: string;
|
||||||
|
type: string;
|
||||||
|
document_number: string;
|
||||||
|
item_name: string;
|
||||||
|
quantity: number;
|
||||||
|
supplier_or_project: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Warehouse() {
|
export default function Warehouse() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
|
|
||||||
const { data: stockItems, isPending: stockPending } = useQuery(
|
const { data: stockItems, isPending: stockPending } = useQuery(
|
||||||
warehouseStockStatusOptions(),
|
warehouseStockStatusOptions(),
|
||||||
@@ -60,253 +117,249 @@ export default function Warehouse() {
|
|||||||
const isLoading =
|
const isLoading =
|
||||||
stockPending || belowMinPending || reservationsPending || movementsLoading;
|
stockPending || belowMinPending || reservationsPending || movementsLoading;
|
||||||
|
|
||||||
|
const belowMinColor: StatCardColor =
|
||||||
|
belowMinimumCount > 0 ? "error" : "warning";
|
||||||
|
|
||||||
|
const belowMinColumns: DataColumn<BelowMinRow>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Název",
|
||||||
|
width: "50%",
|
||||||
|
bold: true,
|
||||||
|
render: (item) => item.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "available_quantity",
|
||||||
|
header: "K dispozici",
|
||||||
|
width: "25%",
|
||||||
|
mono: true,
|
||||||
|
render: (item) => String(item.available_quantity),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "min_quantity",
|
||||||
|
header: "Min. množství",
|
||||||
|
width: "25%",
|
||||||
|
mono: true,
|
||||||
|
render: (item) => String(item.min_quantity),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const movementColumns: DataColumn<MovementRow>[] = [
|
||||||
|
{
|
||||||
|
key: "date",
|
||||||
|
header: "Datum",
|
||||||
|
width: "12%",
|
||||||
|
mono: true,
|
||||||
|
render: (row) => formatDate(row.date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Typ",
|
||||||
|
width: "12%",
|
||||||
|
render: (row) => (
|
||||||
|
<StatusChip
|
||||||
|
label={TYPE_LABEL[row.type] ?? row.type}
|
||||||
|
color={TYPE_COLOR[row.type] ?? "info"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "document_number",
|
||||||
|
header: "Doklad",
|
||||||
|
width: "16%",
|
||||||
|
mono: true,
|
||||||
|
render: (row) => row.document_number || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "item_name",
|
||||||
|
header: "Položka",
|
||||||
|
width: "28%",
|
||||||
|
bold: true,
|
||||||
|
render: (row) => row.item_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quantity",
|
||||||
|
header: "Množství",
|
||||||
|
width: "12%",
|
||||||
|
mono: true,
|
||||||
|
render: (row) => String(row.quantity),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "supplier_or_project",
|
||||||
|
header: "Dodavatel / Projekt",
|
||||||
|
width: "20%",
|
||||||
|
render: (row) => row.supplier_or_project || "—",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Sklad"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
subtitle="Přehled skladových zásob"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
actions={
|
||||||
transition={{ duration: 0.25 }}
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
>
|
{hasPermission("warehouse.operate") && (
|
||||||
<div>
|
<>
|
||||||
<h1 className="admin-page-title">Sklad</h1>
|
<Button
|
||||||
<p className="admin-page-subtitle">Přehled skladových zásob</p>
|
component={RouterLink}
|
||||||
</div>
|
to="/warehouse/receipts/new"
|
||||||
<div className="admin-page-actions">
|
startIcon={ReceiptIcon}
|
||||||
{hasPermission("warehouse.operate") && (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/warehouse/receipts/new"
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
>
|
||||||
<polyline points="17 11 12 6 7 11" />
|
Nový příjem
|
||||||
<line x1="12" y1="6" x2="12" y2="18" />
|
</Button>
|
||||||
</svg>
|
<Button
|
||||||
Nový příjem
|
component={RouterLink}
|
||||||
</Link>
|
to="/warehouse/issues/new"
|
||||||
<Link
|
variant="outlined"
|
||||||
to="/warehouse/issues/new"
|
color="inherit"
|
||||||
className="admin-btn admin-btn-secondary"
|
startIcon={IssueIcon}
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
>
|
||||||
<polyline points="7 13 12 18 17 13" />
|
Nový výdej
|
||||||
<line x1="12" y1="18" x2="12" y2="6" />
|
</Button>
|
||||||
</svg>
|
</>
|
||||||
Nový výdej
|
)}
|
||||||
</Link>
|
{hasPermission("warehouse.manage") && (
|
||||||
</>
|
<Button
|
||||||
)}
|
component={RouterLink}
|
||||||
{hasPermission("warehouse.manage") && (
|
to="/warehouse/categories"
|
||||||
<Link
|
variant="outlined"
|
||||||
to="/warehouse/categories"
|
color="inherit"
|
||||||
className="admin-btn admin-btn-secondary"
|
>
|
||||||
|
Kategorie
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
|
to="/warehouse/items"
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
>
|
>
|
||||||
Kategorie
|
Položky
|
||||||
</Link>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary">
|
component={RouterLink}
|
||||||
Položky
|
to="/warehouse/reports"
|
||||||
</Link>
|
variant="outlined"
|
||||||
<Link
|
color="inherit"
|
||||||
to="/warehouse/reports"
|
>
|
||||||
className="admin-btn admin-btn-secondary"
|
Reporty
|
||||||
>
|
</Button>
|
||||||
Reporty
|
</Box>
|
||||||
</Link>
|
}
|
||||||
</div>
|
/>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* KPI cards */}
|
{/* KPI cards */}
|
||||||
<div className="admin-kpi-grid admin-kpi-4">
|
<Box
|
||||||
<motion.div
|
sx={{
|
||||||
className="admin-stat-card info"
|
display: "grid",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
gridTemplateColumns: {
|
||||||
animate={{ opacity: 1, y: 0 }}
|
xs: "1fr",
|
||||||
transition={{
|
sm: "repeat(2, 1fr)",
|
||||||
duration: reducedMotion ? 0 : 0.25,
|
lg: "repeat(4, 1fr)",
|
||||||
delay: reducedMotion ? 0 : 0.06,
|
},
|
||||||
}}
|
gap: 2,
|
||||||
>
|
mb: 3,
|
||||||
<div className="admin-stat-label">Celkem položek</div>
|
}}
|
||||||
<div className="admin-stat-value admin-mono">
|
>
|
||||||
{isLoading ? "—" : totalItems}
|
<StatCard
|
||||||
</div>
|
color="info"
|
||||||
</motion.div>
|
label="Celkem položek"
|
||||||
|
value={isLoading ? "—" : totalItems}
|
||||||
<motion.div
|
/>
|
||||||
className="admin-stat-card success"
|
<StatCard
|
||||||
initial={{ opacity: 0, y: 12 }}
|
color="success"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
label="Hodnota zásob"
|
||||||
transition={{
|
value={isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
|
||||||
duration: reducedMotion ? 0 : 0.25,
|
/>
|
||||||
delay: reducedMotion ? 0 : 0.08,
|
<StatCard
|
||||||
}}
|
color={belowMinColor}
|
||||||
>
|
label="Pod minimem"
|
||||||
<div className="admin-stat-label">Hodnota zásob</div>
|
value={isLoading ? "—" : belowMinimumCount}
|
||||||
<div className="admin-stat-value admin-mono">
|
/>
|
||||||
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
|
<StatCard
|
||||||
</div>
|
color="info"
|
||||||
</motion.div>
|
label="Aktivní rezervace"
|
||||||
|
value={isLoading ? "—" : activeReservationCount}
|
||||||
<motion.div
|
/>
|
||||||
className={`admin-stat-card ${belowMinimumCount > 0 ? "danger" : "warning"}`}
|
</Box>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{
|
|
||||||
duration: reducedMotion ? 0 : 0.25,
|
|
||||||
delay: reducedMotion ? 0 : 0.1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="admin-stat-label">Pod minimem</div>
|
|
||||||
<div className="admin-stat-value admin-mono">
|
|
||||||
{isLoading ? "—" : belowMinimumCount}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="admin-stat-card info"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{
|
|
||||||
duration: reducedMotion ? 0 : 0.25,
|
|
||||||
delay: reducedMotion ? 0 : 0.12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="admin-stat-label">Aktivní rezervace</div>
|
|
||||||
<div className="admin-stat-value admin-mono">
|
|
||||||
{isLoading ? "—" : activeReservationCount}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Below minimum alert */}
|
{/* Below minimum alert */}
|
||||||
{belowMin.length > 0 && (
|
{belowMin.length > 0 && (
|
||||||
<motion.div
|
<Box sx={{ mb: 3 }}>
|
||||||
className="admin-card admin-card-danger"
|
<Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Box
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sx={{
|
||||||
transition={{
|
display: "flex",
|
||||||
duration: reducedMotion ? 0 : 0.25,
|
alignItems: "center",
|
||||||
delay: reducedMotion ? 0 : 0.14,
|
justifyContent: "space-between",
|
||||||
}}
|
gap: 2,
|
||||||
>
|
flexWrap: "wrap",
|
||||||
<div className="admin-card-header flex-between">
|
mb: 2,
|
||||||
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
|
}}
|
||||||
Položky pod minimem
|
|
||||||
</h2>
|
|
||||||
<Link
|
|
||||||
to="/warehouse/reports"
|
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
>
|
>
|
||||||
Reporty →
|
<Typography variant="h6" sx={{ color: "error.main" }}>
|
||||||
</Link>
|
Položky pod minimem
|
||||||
</div>
|
</Typography>
|
||||||
<div className="admin-card-body">
|
<Button
|
||||||
<div className="admin-table-responsive">
|
component={RouterLink}
|
||||||
<table className="admin-table">
|
to="/warehouse/reports"
|
||||||
<thead>
|
size="small"
|
||||||
<tr>
|
sx={{ flexShrink: 0 }}
|
||||||
<th>Název</th>
|
>
|
||||||
<th>K dispozici</th>
|
Reporty →
|
||||||
<th>Min. množství</th>
|
</Button>
|
||||||
</tr>
|
</Box>
|
||||||
</thead>
|
<DataTable<BelowMinRow>
|
||||||
<tbody>
|
columns={belowMinColumns}
|
||||||
{belowMin.map((item) => (
|
rows={belowMin.map((item) => ({
|
||||||
<tr key={item.id} className="admin-warehouse-row-danger">
|
id: item.id,
|
||||||
<td className="fw-500">{item.name}</td>
|
name: item.name,
|
||||||
<td className="admin-mono admin-warehouse-danger-value">
|
available_quantity: item.available_quantity ?? 0,
|
||||||
{item.available_quantity ?? 0}
|
min_quantity: item.min_quantity ?? 0,
|
||||||
</td>
|
}))}
|
||||||
<td className="admin-mono">{item.min_quantity ?? 0}</td>
|
rowKey={(item) => item.id}
|
||||||
</tr>
|
rowDanger={() => true}
|
||||||
))}
|
/>
|
||||||
</tbody>
|
</Card>
|
||||||
</table>
|
</Box>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent movements */}
|
{/* Recent movements */}
|
||||||
<motion.div
|
<Box>
|
||||||
className="admin-card"
|
<Card>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Box
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sx={{
|
||||||
transition={{
|
display: "flex",
|
||||||
duration: reducedMotion ? 0 : 0.25,
|
alignItems: "center",
|
||||||
delay: reducedMotion ? 0 : 0.16,
|
justifyContent: "space-between",
|
||||||
}}
|
gap: 2,
|
||||||
>
|
flexWrap: "wrap",
|
||||||
<div className="admin-card-header flex-between">
|
mb: 2,
|
||||||
<h2 className="admin-card-title">Poslední pohyby</h2>
|
}}
|
||||||
<Link
|
|
||||||
to="/warehouse/reports"
|
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
>
|
>
|
||||||
Vše →
|
<Typography variant="h6">Poslední pohyby</Typography>
|
||||||
</Link>
|
<Button
|
||||||
</div>
|
component={RouterLink}
|
||||||
<div className="admin-card-body">
|
to="/warehouse/reports"
|
||||||
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Vše →
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
{movementsLoading ? (
|
{movementsLoading ? (
|
||||||
<div className="admin-loading">
|
<LoadingState />
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
) : recentMovements.length === 0 ? (
|
|
||||||
<div className="admin-empty-state">Žádné nedávné pohyby</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-table-responsive">
|
<DataTable<MovementRow>
|
||||||
<table className="admin-table">
|
columns={movementColumns}
|
||||||
<thead>
|
rows={recentMovements.map((row, i) => ({ ...row, _idx: i }))}
|
||||||
<tr>
|
rowKey={(row) => row._idx}
|
||||||
<th>Datum</th>
|
empty={<EmptyState title="Žádné nedávné pohyby" />}
|
||||||
<th>Typ</th>
|
/>
|
||||||
<th>Doklad</th>
|
|
||||||
<th>Položka</th>
|
|
||||||
<th>Množství</th>
|
|
||||||
<th>Dodavatel / Projekt</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{recentMovements.map((row, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="admin-mono">{formatDate(row.date)}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${TYPE_TONE[row.type] ?? ""}`}
|
|
||||||
>
|
|
||||||
{TYPE_LABEL[row.type] ?? row.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{row.document_number || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="fw-500">{row.item_name}</td>
|
|
||||||
<td className="admin-mono">{row.quantity}</td>
|
|
||||||
<td>{row.supplier_or_project || "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
</Box>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import {
|
import {
|
||||||
warehouseCategoryListOptions,
|
warehouseCategoryListOptions,
|
||||||
type WarehouseCategory,
|
type WarehouseCategory,
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin/warehouse/categories";
|
const API_BASE = "/api/admin/warehouse/categories";
|
||||||
|
|
||||||
@@ -22,6 +33,50 @@ interface CategoryForm {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WarehouseCategories() {
|
export default function WarehouseCategories() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -121,207 +176,145 @@ export default function WarehouseCategories() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const total = categories.length;
|
||||||
<div>
|
const subtitle = `${total} ${
|
||||||
<motion.div
|
total === 1
|
||||||
className="admin-page-header"
|
? "kategorie"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
: total >= 2 && total <= 4
|
||||||
animate={{ opacity: 1, y: 0 }}
|
? "kategorie"
|
||||||
transition={{ duration: 0.25 }}
|
: "kategorií"
|
||||||
>
|
}`;
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Kategorie skladu</h1>
|
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
<button
|
|
||||||
onClick={openCreateModal}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Přidat kategorii
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
const columns: DataColumn<WarehouseCategory>[] = [
|
||||||
className="admin-card"
|
{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
key: "name",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
header: "Název",
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
width: "35%",
|
||||||
>
|
bold: true,
|
||||||
<div className="admin-card-body">
|
render: (c) => c.name,
|
||||||
{categories.length === 0 && (
|
},
|
||||||
<div className="admin-empty-state">
|
{
|
||||||
<div className="admin-empty-icon">
|
key: "description",
|
||||||
<svg
|
header: "Popis",
|
||||||
width="28"
|
width: "40%",
|
||||||
height="28"
|
render: (c) => c.description || "—",
|
||||||
viewBox="0 0 24 24"
|
},
|
||||||
fill="none"
|
{
|
||||||
stroke="currentColor"
|
key: "sort_order",
|
||||||
strokeWidth="1.5"
|
header: "Pořadí",
|
||||||
strokeLinecap="round"
|
width: "15%",
|
||||||
strokeLinejoin="round"
|
mono: true,
|
||||||
>
|
render: (c) => String(c.sort_order),
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
},
|
||||||
</svg>
|
{
|
||||||
</div>
|
key: "actions",
|
||||||
<p>Zatím nejsou žádné kategorie.</p>
|
header: "Akce",
|
||||||
<button
|
width: "10%",
|
||||||
onClick={openCreateModal}
|
align: "right",
|
||||||
className="admin-btn admin-btn-primary"
|
render: (c) => (
|
||||||
>
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
Přidat první kategorii
|
<IconButton
|
||||||
</button>
|
size="small"
|
||||||
</div>
|
onClick={() => openEditModal(c)}
|
||||||
)}
|
aria-label="Upravit"
|
||||||
{categories.length > 0 && (
|
title="Upravit"
|
||||||
<div className="admin-table-responsive">
|
>
|
||||||
<table className="admin-table">
|
{EditIcon}
|
||||||
<thead>
|
</IconButton>
|
||||||
<tr>
|
<IconButton
|
||||||
<th>Název</th>
|
size="small"
|
||||||
<th>Popis</th>
|
color="error"
|
||||||
<th>Pořadí</th>
|
onClick={() => setDeleteConfirm({ show: true, category: c })}
|
||||||
<th>Akce</th>
|
aria-label="Smazat"
|
||||||
</tr>
|
title="Smazat"
|
||||||
</thead>
|
>
|
||||||
<tbody>
|
{DeleteIcon}
|
||||||
{categories.map((category) => (
|
</IconButton>
|
||||||
<tr key={category.id}>
|
</Box>
|
||||||
<td className="fw-500">{category.name}</td>
|
),
|
||||||
<td>{category.description || "—"}</td>
|
},
|
||||||
<td className="admin-mono">{category.sort_order}</td>
|
];
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
return (
|
||||||
<button
|
<PageEnter>
|
||||||
onClick={() => openEditModal(category)}
|
<PageHeader
|
||||||
className="admin-btn-icon"
|
title="Kategorie skladu"
|
||||||
title="Upravit"
|
subtitle={subtitle}
|
||||||
aria-label="Upravit"
|
actions={
|
||||||
>
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
<svg
|
Přidat kategorii
|
||||||
width="18"
|
</Button>
|
||||||
height="18"
|
}
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
<Card>
|
||||||
strokeWidth="2"
|
<DataTable<WarehouseCategory>
|
||||||
strokeLinecap="round"
|
columns={columns}
|
||||||
strokeLinejoin="round"
|
rows={categories}
|
||||||
>
|
rowKey={(c) => c.id}
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
empty={
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
<EmptyState
|
||||||
</svg>
|
title="Zatím nejsou žádné kategorie."
|
||||||
</button>
|
action={
|
||||||
<button
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
onClick={() =>
|
Přidat první kategorii
|
||||||
setDeleteConfirm({ show: true, category })
|
</Button>
|
||||||
}
|
}
|
||||||
className="admin-btn-icon danger"
|
/>
|
||||||
title="Smazat"
|
}
|
||||||
aria-label="Smazat"
|
/>
|
||||||
>
|
</Card>
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
{/* Add/Edit Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
|
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Field label="Název" required error={errors.name}>
|
||||||
<FormField label="Název" error={errors.name} required>
|
<TextField
|
||||||
<input
|
value={form.name}
|
||||||
type="text"
|
error={!!errors.name}
|
||||||
value={form.name}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
setForm({ ...form, name: e.target.value });
|
||||||
setForm({ ...form, name: e.target.value });
|
setErrors((prev) => ({ ...prev, name: "" }));
|
||||||
setErrors((prev) => ({ ...prev, name: "" }));
|
}}
|
||||||
}}
|
placeholder="Název kategorie"
|
||||||
className="admin-form-input"
|
/>
|
||||||
placeholder="Název kategorie"
|
</Field>
|
||||||
aria-invalid={!!errors.name}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Popis">
|
<Field label="Popis">
|
||||||
<textarea
|
<TextField
|
||||||
value={form.description}
|
multiline
|
||||||
onChange={(e) =>
|
minRows={3}
|
||||||
setForm({ ...form, description: e.target.value })
|
value={form.description}
|
||||||
}
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
className="admin-form-input"
|
placeholder="Volitelný popis kategorie"
|
||||||
rows={3}
|
/>
|
||||||
placeholder="Volitelný popis kategorie"
|
</Field>
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Pořadí řazení">
|
<Field label="Pořadí řazení">
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
value={String(form.sort_order)}
|
||||||
value={form.sort_order}
|
onChange={(e) =>
|
||||||
onChange={(e) =>
|
setForm({
|
||||||
setForm({
|
...form,
|
||||||
...form,
|
sort_order: parseInt(e.target.value) || 0,
|
||||||
sort_order: parseInt(e.target.value) || 0,
|
})
|
||||||
})
|
}
|
||||||
}
|
inputProps={{ min: 0 }}
|
||||||
className="admin-form-input"
|
/>
|
||||||
min="0"
|
</Field>
|
||||||
/>
|
</Modal>
|
||||||
<small className="admin-form-hint">
|
|
||||||
Nižší hodnota = výše v seznamu
|
|
||||||
</small>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, category: null })}
|
onClose={() => setDeleteConfirm({ show: false, category: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -333,7 +326,8 @@ export default function WarehouseCategories() {
|
|||||||
}
|
}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import Pagination from "../components/Pagination";
|
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
|
|
||||||
import { formatDate } from "../utils/formatters";
|
import { formatDate } from "../utils/formatters";
|
||||||
@@ -11,12 +10,29 @@ import {
|
|||||||
warehouseInventoryListOptions,
|
warehouseInventoryListOptions,
|
||||||
type WarehouseInventorySession,
|
type WarehouseInventorySession,
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
Select,
|
||||||
|
StatusChip,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
FilterBar,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const PER_PAGE = 20;
|
const PER_PAGE = 20;
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_COLOR: Record<
|
||||||
DRAFT: "admin-badge-warning",
|
string,
|
||||||
CONFIRMED: "admin-badge-active",
|
"warning" | "success" | "error" | "info" | "default"
|
||||||
|
> = {
|
||||||
|
DRAFT: "warning",
|
||||||
|
CONFIRMED: "success",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
@@ -24,8 +40,23 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
CONFIRMED: "Potvrzeno",
|
CONFIRMED: "Potvrzeno",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WarehouseInventory() {
|
export default function WarehouseInventory() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
@@ -39,190 +70,129 @@ export default function WarehouseInventory() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||||
|
|
||||||
const handleRowClick = (session: WarehouseInventorySession) => {
|
|
||||||
navigate(`/warehouse/inventory/${session.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setStatusFilter("");
|
setStatusFilter("");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const total = pagination?.total ?? items.length;
|
||||||
|
const subtitle = `${total} ${
|
||||||
|
total === 1
|
||||||
|
? "inventura"
|
||||||
|
: total >= 2 && total <= 4
|
||||||
|
? "inventury"
|
||||||
|
: "inventur"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const columns: DataColumn<WarehouseInventorySession>[] = [
|
||||||
|
{
|
||||||
|
key: "session_number",
|
||||||
|
header: "Číslo inventury",
|
||||||
|
width: "30%",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (s) => s.session_number || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Stav",
|
||||||
|
width: "20%",
|
||||||
|
render: (s) => (
|
||||||
|
<StatusChip
|
||||||
|
label={STATUS_LABEL[s.status] ?? s.status}
|
||||||
|
color={STATUS_COLOR[s.status] ?? "default"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at",
|
||||||
|
header: "Vytvořeno",
|
||||||
|
width: "30%",
|
||||||
|
mono: true,
|
||||||
|
render: (s) => formatDate(s.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "items",
|
||||||
|
header: "Položky",
|
||||||
|
width: "20%",
|
||||||
|
mono: true,
|
||||||
|
render: (s) => String(s.items?.length ?? 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Inventury"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
subtitle={subtitle}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
actions={
|
||||||
transition={{ duration: 0.25 }}
|
<Button
|
||||||
>
|
startIcon={PlusIcon}
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Inventury</h1>
|
|
||||||
<p className="admin-page-subtitle">
|
|
||||||
{pagination?.total ?? items.length}{" "}
|
|
||||||
{pagination?.total === 1
|
|
||||||
? "inventura"
|
|
||||||
: pagination?.total !== undefined &&
|
|
||||||
pagination.total >= 2 &&
|
|
||||||
pagination.total <= 4
|
|
||||||
? "inventury"
|
|
||||||
: "inventur"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/warehouse/inventory/new")}
|
onClick={() => navigate("/warehouse/inventory/new")}
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Nová inventura
|
Nová inventura
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</motion.div>
|
/>
|
||||||
|
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card"
|
<Box sx={{ flex: "0 0 160px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Select
|
||||||
animate={{ opacity: 1, y: 0 }}
|
value={statusFilter}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
onChange={(val) => {
|
||||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
setStatusFilter(val);
|
||||||
>
|
setPage(1);
|
||||||
<div className="admin-card-body">
|
}}
|
||||||
<div className="admin-search-bar mb-4">
|
options={[
|
||||||
<select
|
{ value: "", label: "Všechny stavy" },
|
||||||
value={statusFilter}
|
{ value: "DRAFT", label: "Návrh" },
|
||||||
onChange={(e) => {
|
{ value: "CONFIRMED", label: "Potvrzeno" },
|
||||||
setStatusFilter(e.target.value);
|
]}
|
||||||
setPage(1);
|
/>
|
||||||
}}
|
</Box>
|
||||||
className="admin-form-select"
|
{statusFilter && (
|
||||||
>
|
<Button variant="outlined" onClick={resetFilters}>
|
||||||
<option value="">Všechny stavy</option>
|
Zrušit filtry
|
||||||
<option value="DRAFT">Návrh</option>
|
</Button>
|
||||||
<option value="CONFIRMED">Potvrzeno</option>
|
)}
|
||||||
</select>
|
</FilterBar>
|
||||||
{statusFilter && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={resetFilters}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
Zrušit filtry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||||
<motion.div
|
<DataTable<WarehouseInventorySession>
|
||||||
key={`${statusFilter}-${page}-${items.length}`}
|
columns={columns}
|
||||||
initial={{ opacity: 0, y: 6 }}
|
rows={items}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
rowKey={(s) => s.id}
|
||||||
exit={{ opacity: 0 }}
|
onRowClick={(s) => navigate(`/warehouse/inventory/${s.id}`)}
|
||||||
transition={{ duration: 0.15 }}
|
empty={
|
||||||
>
|
statusFilter ? (
|
||||||
{items.length === 0 && (
|
<EmptyState title="Žádné inventury pro zadaný filtr." />
|
||||||
<div className="admin-empty-state">
|
) : (
|
||||||
<div className="admin-empty-icon">
|
<EmptyState
|
||||||
<svg
|
title="Zatím nejsou žádné inventury."
|
||||||
width="28"
|
action={
|
||||||
height="28"
|
<Button
|
||||||
viewBox="0 0 24 24"
|
startIcon={PlusIcon}
|
||||||
fill="none"
|
onClick={() => navigate("/warehouse/inventory/new")}
|
||||||
stroke="currentColor"
|
>
|
||||||
strokeWidth="1.5"
|
Vytvořit první inventuru
|
||||||
strokeLinecap="round"
|
</Button>
|
||||||
strokeLinejoin="round"
|
}
|
||||||
>
|
/>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
)
|
||||||
<polyline points="14 2 14 8 20 8" />
|
}
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
/>
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
<Pagination
|
||||||
</svg>
|
page={page}
|
||||||
</div>
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
<p>
|
onChange={setPage}
|
||||||
{statusFilter
|
/>
|
||||||
? "Žádné inventury pro zadaný filtr."
|
</Card>
|
||||||
: "Zatím nejsou žádné inventury."}
|
</PageEnter>
|
||||||
</p>
|
|
||||||
{!statusFilter && (
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/warehouse/inventory/new")}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
Vytvořit první inventuru
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{items.length > 0 && (
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Číslo inventury</th>
|
|
||||||
<th>Stav</th>
|
|
||||||
<th>Vytvořeno</th>
|
|
||||||
<th>Položky</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((session) => (
|
|
||||||
<tr
|
|
||||||
key={session.id}
|
|
||||||
onClick={() => handleRowClick(session)}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<td className="admin-mono fw-500">
|
|
||||||
{session.session_number || "—"}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${STATUS_BADGE[session.status] ?? ""}`}
|
|
||||||
>
|
|
||||||
{STATUS_LABEL[session.status] ?? session.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(session.created_at)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{session.items?.length ?? 0}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, Link } from "react-router-dom";
|
import { useParams, Link as RouterLink } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
|
|
||||||
import { formatDate } from "../utils/formatters";
|
import { formatDate } from "../utils/formatters";
|
||||||
import {
|
import {
|
||||||
warehouseInventoryDetailOptions,
|
warehouseInventoryDetailOptions,
|
||||||
type WarehouseInventorySession,
|
type WarehouseInventorySession,
|
||||||
|
type WarehouseInventoryItem,
|
||||||
} from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
StatusChip,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
ConfirmDialog,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_COLOR: Record<
|
||||||
DRAFT: "admin-badge-warning",
|
string,
|
||||||
CONFIRMED: "admin-badge-active",
|
"default" | "success" | "error" | "warning" | "info"
|
||||||
|
> = {
|
||||||
|
DRAFT: "warning",
|
||||||
|
CONFIRMED: "success",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
@@ -25,6 +40,19 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
CONFIRMED: "Potvrzeno",
|
CONFIRMED: "Potvrzeno",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BackIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WarehouseInventoryDetail() {
|
export default function WarehouseInventoryDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -65,206 +93,204 @@ export default function WarehouseInventoryDetail() {
|
|||||||
const confirming = confirmMutation.isPending;
|
const confirming = confirmMutation.isPending;
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !session) {
|
if (error || !session) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Box>
|
||||||
<div className="admin-page-header">
|
<PageHeader
|
||||||
<div>
|
title="Inventura"
|
||||||
<Link
|
actions={
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/warehouse/inventory"
|
to="/warehouse/inventory"
|
||||||
className="admin-btn-icon"
|
variant="outlined"
|
||||||
title="Zpět na seznam"
|
color="inherit"
|
||||||
|
startIcon={BackIcon}
|
||||||
>
|
>
|
||||||
<svg
|
Zpět
|
||||||
width="20"
|
</Button>
|
||||||
height="20"
|
}
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
<Card>
|
||||||
stroke="currentColor"
|
<EmptyState title="Inventura nebyla nalezena." />
|
||||||
strokeWidth="2"
|
</Card>
|
||||||
>
|
</Box>
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-card">
|
|
||||||
<div className="admin-card-body">
|
|
||||||
<div className="admin-empty-state">
|
|
||||||
<p>Inventura nebyla nalezena.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = session as WarehouseInventorySession;
|
const s = session as WarehouseInventorySession;
|
||||||
const items = s.items ?? [];
|
const items = s.items ?? [];
|
||||||
|
|
||||||
|
const itemColumns: DataColumn<WarehouseInventoryItem>[] = [
|
||||||
|
{
|
||||||
|
key: "item",
|
||||||
|
header: "Položka",
|
||||||
|
width: "25%",
|
||||||
|
bold: true,
|
||||||
|
render: (row) => row.item?.name || `ID: ${row.item_id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "location",
|
||||||
|
header: "Lokace",
|
||||||
|
width: "15%",
|
||||||
|
mono: true,
|
||||||
|
render: (row) => row.location?.code || "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "system_qty",
|
||||||
|
header: "Systémové množství",
|
||||||
|
width: "18%",
|
||||||
|
mono: true,
|
||||||
|
render: (row) => String(Number(row.system_qty)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actual_qty",
|
||||||
|
header: "Skutečné množství",
|
||||||
|
width: "18%",
|
||||||
|
mono: true,
|
||||||
|
render: (row) => String(Number(row.actual_qty)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "difference",
|
||||||
|
header: "Rozdíl",
|
||||||
|
width: "12%",
|
||||||
|
render: (row) => {
|
||||||
|
const diff = Number(row.difference);
|
||||||
|
const color =
|
||||||
|
diff > 0
|
||||||
|
? "success.main"
|
||||||
|
: diff < 0
|
||||||
|
? "error.main"
|
||||||
|
: "text.secondary";
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
fontWeight: 500,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diff > 0 ? `+${diff}` : diff}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notes",
|
||||||
|
header: "Poznámka",
|
||||||
|
width: "12%",
|
||||||
|
render: (row) => row.notes || "—",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title={s.session_number || "Inventura"}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
actions={
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
transition={{ duration: 0.25 }}
|
<Button
|
||||||
>
|
component={RouterLink}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
to="/warehouse/inventory"
|
||||||
<Link
|
variant="outlined"
|
||||||
to="/warehouse/inventory"
|
color="inherit"
|
||||||
className="admin-btn-icon"
|
startIcon={BackIcon}
|
||||||
title="Zpět na seznam"
|
|
||||||
aria-label="Zpět na seznam"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
>
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
Zpět
|
||||||
</svg>
|
</Button>
|
||||||
</Link>
|
<StatusChip
|
||||||
<div>
|
label={STATUS_LABEL[s.status] ?? s.status}
|
||||||
<h1 className="admin-page-title">
|
color={STATUS_COLOR[s.status] ?? "default"}
|
||||||
{s.session_number || "Inventura"}
|
/>
|
||||||
</h1>
|
{s.status === "DRAFT" && (
|
||||||
</div>
|
<Button
|
||||||
<span
|
onClick={() => setShowConfirmModal(true)}
|
||||||
className={`admin-badge ${STATUS_BADGE[s.status] ?? ""}`}
|
disabled={confirming}
|
||||||
style={{ marginLeft: "0.5rem" }}
|
>
|
||||||
>
|
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
|
||||||
{STATUS_LABEL[s.status] ?? s.status}
|
</Button>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-page-actions">
|
}
|
||||||
{s.status === "DRAFT" && (
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setShowConfirmModal(true)}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
disabled={confirming}
|
|
||||||
>
|
|
||||||
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Header info */}
|
{/* Header info */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Základní údaje
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box
|
||||||
>
|
sx={{
|
||||||
<div className="admin-card-body">
|
display: "grid",
|
||||||
<h3 className="admin-card-title">Základní údaje</h3>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<div className="admin-form">
|
gap: 2,
|
||||||
<div className="admin-form-row">
|
}}
|
||||||
<FormField label="Číslo inventury">
|
>
|
||||||
<div className="admin-mono fw-500">
|
<Box>
|
||||||
{s.session_number || "—"}
|
<Typography variant="caption" color="text.secondary">
|
||||||
</div>
|
Číslo inventury
|
||||||
</FormField>
|
</Typography>
|
||||||
<FormField label="Vytvořeno">
|
<Typography
|
||||||
<div className="admin-mono">{formatDate(s.created_at)}</div>
|
variant="body2"
|
||||||
</FormField>
|
sx={{
|
||||||
</div>
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
{s.notes && (
|
fontWeight: 500,
|
||||||
<FormField label="Poznámky">
|
}}
|
||||||
<div style={{ whiteSpace: "pre-wrap" }}>{s.notes}</div>
|
>
|
||||||
</FormField>
|
{s.session_number || "—"}
|
||||||
)}
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
<Box>
|
||||||
</motion.div>
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Vytvořeno
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||||
|
>
|
||||||
|
{formatDate(s.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{s.notes && (
|
||||||
|
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Poznámky
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{s.notes}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Lines table */}
|
{/* Lines table */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Položky
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
<DataTable<WarehouseInventoryItem>
|
||||||
>
|
columns={itemColumns}
|
||||||
<div className="admin-card-body">
|
rows={items}
|
||||||
<h3 className="admin-card-title">Položky</h3>
|
rowKey={(row) => row.id}
|
||||||
{items.length === 0 ? (
|
empty={<EmptyState title="Žádné řádky" />}
|
||||||
<div className="admin-empty-state">Žádné řádky</div>
|
/>
|
||||||
) : (
|
</Card>
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Položka</th>
|
|
||||||
<th>Lokace</th>
|
|
||||||
<th>Systémové množství</th>
|
|
||||||
<th>Skutečné množství</th>
|
|
||||||
<th>Rozdíl</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item) => {
|
|
||||||
const diff = Number(item.difference);
|
|
||||||
const diffColor =
|
|
||||||
diff > 0
|
|
||||||
? "var(--success)"
|
|
||||||
: diff < 0
|
|
||||||
? "var(--danger)"
|
|
||||||
: "var(--text-secondary)";
|
|
||||||
return (
|
|
||||||
<tr key={item.id}>
|
|
||||||
<td className="fw-500">
|
|
||||||
{item.item?.name || `ID: ${item.item_id}`}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{item.location?.code || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{Number(item.system_qty)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{Number(item.actual_qty)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="admin-mono fw-500"
|
|
||||||
style={{ color: diffColor }}
|
|
||||||
>
|
|
||||||
{diff > 0 ? `+${diff}` : diff}
|
|
||||||
</td>
|
|
||||||
<td>{item.notes || "—"}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Confirm modal */}
|
{/* Confirm modal */}
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={showConfirmModal}
|
isOpen={showConfirmModal}
|
||||||
onClose={() => setShowConfirmModal(false)}
|
onClose={() => setShowConfirmModal(false)}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
title="Potvrdit inventuru"
|
title="Potvrdit inventuru"
|
||||||
message={`Opravdu chcete potvrdit inventuru „${s.session_number || s.id}"? Potvrzením se aktualizují skladové zásoby.`}
|
message={`Opravdu chcete potvrdit inventuru „${s.session_number || s.id}"? Potvrzením se aktualizují skladové zásoby.`}
|
||||||
confirmText="Potvrdit inventuru"
|
confirmText="Potvrdit inventuru"
|
||||||
type="warning"
|
|
||||||
loading={confirming}
|
loading={confirming}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useState, useId, useRef } from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import ItemPicker from "../components/warehouse/ItemPicker";
|
import ItemPicker from "../components/warehouse/ItemPicker";
|
||||||
import LocationSelect from "../components/warehouse/LocationSelect";
|
import {
|
||||||
import WarehouseMovementTable from "../components/warehouse/WarehouseMovementTable";
|
warehouseLocationListOptions,
|
||||||
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
|
type WarehouseLocation,
|
||||||
import type { WarehouseLocation } from "../lib/queries/warehouse";
|
} from "../lib/queries/warehouse";
|
||||||
|
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import { Button, Card, TextField, Select, Field, PageEnter } from "../ui";
|
||||||
|
|
||||||
interface InventoryItem {
|
interface InventoryItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -21,22 +22,63 @@ interface InventoryItem {
|
|||||||
actual_qty: number;
|
actual_qty: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultInventoryItem = () => ({
|
const BackIcon = (
|
||||||
item_id: null,
|
<svg
|
||||||
location_id: null,
|
width="20"
|
||||||
actual_qty: 0,
|
height="20"
|
||||||
});
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RemoveIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PlusIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WarehouseInventoryForm() {
|
export default function WarehouseInventoryForm() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const baseId = useId();
|
||||||
|
const counterRef = useRef(0);
|
||||||
|
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
||||||
|
|
||||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const { data: locations = [] } = useQuery(warehouseLocationListOptions());
|
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||||
|
|
||||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||||
|
|
||||||
@@ -90,151 +132,153 @@ export default function WarehouseInventoryForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ key: nextKey(), item_id: null, location_id: null, actual_qty: 0 },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (
|
||||||
|
index: number,
|
||||||
|
field: keyof InventoryItem,
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const locationOptions = [
|
||||||
|
{ value: "", label: "-- bez lokace --" },
|
||||||
|
...(locations ?? []).map((loc: WarehouseLocation) => ({
|
||||||
|
value: String(loc.id),
|
||||||
|
label: `${loc.code} - ${loc.name}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-page-header"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "flex",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
alignItems: "flex-start",
|
||||||
transition={{ duration: 0.25 }}
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
<Link
|
<IconButton
|
||||||
|
component={RouterLink}
|
||||||
to="/warehouse/inventory"
|
to="/warehouse/inventory"
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Zpět na seznam"
|
title="Zpět na seznam"
|
||||||
aria-label="Zpět na seznam"
|
aria-label="Zpět na seznam"
|
||||||
>
|
>
|
||||||
<svg
|
{BackIcon}
|
||||||
width="20"
|
</IconButton>
|
||||||
height="20"
|
<Typography variant="h4">Nová inventura</Typography>
|
||||||
viewBox="0 0 24 24"
|
</Box>
|
||||||
fill="none"
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
stroke="currentColor"
|
<Button onClick={handleSubmit} disabled={saving}>
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Nová inventura</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? "Ukládání..." : "Vytvořit inventuru"}
|
{saving ? "Ukládání..." : "Vytvořit inventuru"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Základní údaje
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Field label="Poznámky">
|
||||||
>
|
<TextField
|
||||||
<div className="admin-card-body">
|
multiline
|
||||||
<h3 className="admin-card-title">Základní údaje</h3>
|
minRows={3}
|
||||||
<FormField label="Poznámky">
|
value={notes}
|
||||||
<textarea
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
value={notes}
|
placeholder="Volitelné poznámky k inventuře"
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
/>
|
||||||
className="admin-form-input"
|
</Field>
|
||||||
rows={3}
|
</Card>
|
||||||
placeholder="Volitelné poznámky k inventuře"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Lines */}
|
{/* Lines */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Položky
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
>
|
{items.map((item, index) => (
|
||||||
<div className="admin-card-body">
|
<Box
|
||||||
<h3 className="admin-card-title">Položky</h3>
|
key={item.key}
|
||||||
<WarehouseMovementTable<InventoryItem>
|
sx={{
|
||||||
items={items}
|
display: "grid",
|
||||||
onChange={setItems}
|
gridTemplateColumns: {
|
||||||
mode="inventory"
|
xs: "1fr",
|
||||||
defaultItem={defaultInventoryItem}
|
md: "minmax(180px, 2fr) minmax(140px, 1.5fr) 120px auto",
|
||||||
renderHeader={() => (
|
},
|
||||||
<>
|
gap: 1,
|
||||||
<th className="admin-warehouse-col-item">Položka</th>
|
alignItems: "start",
|
||||||
<th className="admin-warehouse-col-location">Lokace</th>
|
}}
|
||||||
<th className="admin-warehouse-col-qty">Skutečné množství</th>
|
>
|
||||||
<th>Akce</th>
|
<ItemPicker
|
||||||
</>
|
value={item.item_id}
|
||||||
)}
|
onChange={(id) => updateItem(index, "item_id", id)}
|
||||||
renderRow={(item, _index, updateItem, removeItem) => (
|
/>
|
||||||
<>
|
<Select
|
||||||
<td className="admin-warehouse-col-item">
|
value={
|
||||||
<ItemPicker
|
item.location_id === null ? "" : String(item.location_id)
|
||||||
value={item.item_id}
|
}
|
||||||
onChange={(id) => updateItem("item_id", id)}
|
onChange={(val) =>
|
||||||
/>
|
updateItem(index, "location_id", val ? Number(val) : null)
|
||||||
</td>
|
}
|
||||||
<td className="admin-warehouse-col-location">
|
options={locationOptions}
|
||||||
<LocationSelect
|
/>
|
||||||
value={item.location_id}
|
<TextField
|
||||||
onChange={(locId) => updateItem("location_id", locId)}
|
type="number"
|
||||||
/>
|
value={item.actual_qty || ""}
|
||||||
</td>
|
onChange={(e) =>
|
||||||
<td className="admin-warehouse-col-qty">
|
updateItem(index, "actual_qty", Number(e.target.value))
|
||||||
<input
|
}
|
||||||
type="number"
|
inputProps={{
|
||||||
className="admin-form-input"
|
min: 0,
|
||||||
value={item.actual_qty || ""}
|
step: 0.001,
|
||||||
onChange={(e) =>
|
inputMode: "decimal",
|
||||||
updateItem("actual_qty", Number(e.target.value))
|
pattern: "[0-9]+([\\.,][0-9]+)?",
|
||||||
}
|
"aria-label": `Skutečné množství, řádek ${index + 1}`,
|
||||||
min="0"
|
}}
|
||||||
step="0.001"
|
placeholder="Skutečné množství"
|
||||||
inputMode="decimal"
|
/>
|
||||||
pattern="[0-9]+([\.,][0-9]+)?"
|
<IconButton
|
||||||
aria-label={`Skutečné množství, řádek ${_index + 1}`}
|
color="error"
|
||||||
/>
|
onClick={() => removeItem(index)}
|
||||||
</td>
|
title="Odebrat řádek"
|
||||||
<td>
|
aria-label="Odebrat řádek"
|
||||||
<div className="admin-table-actions">
|
sx={{ justifySelf: { xs: "start", md: "center" } }}
|
||||||
<button
|
>
|
||||||
type="button"
|
{RemoveIcon}
|
||||||
className="admin-btn-icon danger"
|
</IconButton>
|
||||||
onClick={removeItem}
|
</Box>
|
||||||
title="Odebrat řádek"
|
))}
|
||||||
aria-label="Odebrat řádek"
|
</Box>
|
||||||
>
|
<Button
|
||||||
<svg
|
variant="outlined"
|
||||||
width="16"
|
color="inherit"
|
||||||
height="16"
|
startIcon={PlusIcon}
|
||||||
viewBox="0 0 24 24"
|
onClick={addItem}
|
||||||
fill="none"
|
sx={{ mt: 2 }}
|
||||||
stroke="currentColor"
|
>
|
||||||
strokeWidth="2"
|
Přidat položku
|
||||||
strokeLinecap="round"
|
</Button>
|
||||||
strokeLinejoin="round"
|
</Card>
|
||||||
>
|
</PageEnter>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user