Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
941caf9d85 | ||
|
|
b00d18d0b3 | ||
|
|
decadd895e | ||
|
|
54f3c414f5 | ||
|
|
ca092c6166 | ||
|
|
c7f9d9aa36 | ||
|
|
5aaac88b56 | ||
|
|
671323b9ac | ||
|
|
2e4fe2a8fe | ||
|
|
27c690285a | ||
|
|
8e7ab9158c | ||
|
|
03285c6ff6 | ||
|
|
d424ef8c15 | ||
|
|
2a228ea659 | ||
|
|
96e35df535 | ||
|
|
fa90bcc93b | ||
|
|
3117e80027 | ||
|
|
2f7ca0f5a8 | ||
|
|
2367e8a0ff | ||
|
|
b168c68264 | ||
|
|
aeec0b703c | ||
|
|
2ff26241d7 | ||
|
|
f0b5fb8a21 | ||
|
|
1e155f6ffb | ||
|
|
8cee828212 | ||
|
|
b23d15453d | ||
|
|
c4e71475ce | ||
|
|
bf5d38df14 | ||
|
|
9278d48a6d | ||
|
|
4062029939 | ||
|
|
f017867d5f | ||
|
|
f1c533488a | ||
|
|
f9bb655f6a | ||
|
|
c1bfabb033 | ||
|
|
ff3df907f4 | ||
|
|
39fe84ce99 | ||
|
|
b273614128 | ||
|
|
1a0f57a247 | ||
|
|
c67dff4725 | ||
|
|
58da09cdc0 | ||
|
|
ccdf0b68ea | ||
|
|
3b072ceefa | ||
|
|
68a8dae0dc | ||
|
|
e13f977c4d | ||
|
|
6f428b2297 | ||
|
|
67f98c1f66 | ||
|
|
85d7ffaa14 | ||
|
|
088fe39bab | ||
|
|
9b43d4e827 | ||
|
|
5630beee7c | ||
|
|
90a27b893e | ||
|
|
2878041687 | ||
|
|
262f4c6ca5 | ||
|
|
063ffd15ef | ||
|
|
fa11d3e7e0 | ||
|
|
d46827dfdf | ||
|
|
601db41a09 | ||
|
|
8259c48f9e | ||
|
|
1163407440 | ||
|
|
1cefcab697 | ||
|
|
c228d7a8ca | ||
|
|
c997a22a3c | ||
|
|
4d016cdab5 | ||
|
|
177d3f1902 | ||
|
|
ef0bb9f27e | ||
|
|
126b567b7b | ||
|
|
e4038051e9 | ||
|
|
c7e42a923a | ||
|
|
ebda3ecf23 | ||
|
|
cc4b14f862 | ||
|
|
5459d8c325 | ||
|
|
5ca03bd662 | ||
|
|
734e4266e6 | ||
|
|
c690bd9a24 | ||
|
|
3eb364fba5 | ||
|
|
1f85a84e06 | ||
|
|
abbff0bfbf | ||
|
|
19eda2ffa3 | ||
|
|
9e5038c553 | ||
|
|
9ab2bd7ef2 | ||
|
|
3d1bd49ad6 | ||
|
|
f51d5fcba4 | ||
|
|
5d919c3c90 | ||
|
|
4b94efbb43 | ||
|
|
c8c40d80d6 | ||
|
|
acc5434aca | ||
|
|
ff527ca577 | ||
|
|
b54fa84ac7 | ||
|
|
ca386c9d98 | ||
|
|
a05f0f1665 | ||
|
|
1d0793d2bb | ||
|
|
74c2f9aa30 | ||
|
|
1074adee0d | ||
|
|
a5103ee738 | ||
|
|
4fb52e9626 | ||
|
|
06c6e242e7 | ||
|
|
91686f5999 | ||
|
|
03015bbe9d | ||
|
|
af07703e7f | ||
|
|
0dbe78bfe6 | ||
|
|
b22cd6b4da | ||
|
|
ba18cb07f0 | ||
|
|
26f12b01a0 | ||
|
|
1b372b93ea | ||
|
|
56bad52de5 | ||
|
|
a80905755d | ||
|
|
ea6c943a51 | ||
|
|
c0e1b718e6 | ||
|
|
fbcd5a8939 | ||
|
|
f5ec9fa0f2 | ||
|
|
fa8f0efc65 | ||
|
|
2efd1c6df4 | ||
|
|
305c3fd97b | ||
|
|
6c186acc94 | ||
|
|
c055bf8267 | ||
|
|
094c190ae1 | ||
|
|
55a2de3a04 | ||
|
|
1398d4b4dc | ||
|
|
9aa90ff5f4 | ||
|
|
225cf56433 | ||
|
|
58a3bcad70 | ||
|
|
ca55529ff6 | ||
|
|
b76731d790 | ||
|
|
05f25931c9 | ||
|
|
5855c94b50 | ||
|
|
81adebd63a | ||
|
|
16db585022 | ||
|
|
645d0127ec | ||
|
|
c550763f2a | ||
|
|
bd658cd00c | ||
|
|
efaf49e9df | ||
|
|
cc5ffb27b9 | ||
|
|
9dbb5dbaab | ||
|
|
ab7e1eda41 | ||
|
|
7a21c85cce | ||
|
|
a353178c2d | ||
|
|
eeac0c6b8b | ||
|
|
e511e203b9 | ||
|
|
9791166ed6 | ||
|
|
a853d12d9c | ||
|
|
6bc4a22338 | ||
|
|
859ae1a458 | ||
|
|
090cfd7400 | ||
|
|
bb7e53f47a | ||
|
|
d2e368a05e | ||
|
|
b5df88b62d | ||
|
|
116797ca17 | ||
|
|
ed4f1efd03 | ||
|
|
6b76d4336e | ||
|
|
a8c0e413f4 | ||
|
|
596d21712a | ||
|
|
b504d9d0b2 | ||
|
|
e082a6ef06 | ||
|
|
1662453d9c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ dist-client/
|
||||
# Claude worktrees
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Superpowers brainstorm mockups
|
||||
.superpowers/
|
||||
|
||||
53
CLAUDE.md
53
CLAUDE.md
@@ -14,7 +14,7 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
|
||||
| ORM | Prisma 6.19.2 → MySQL |
|
||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||
| Validation | Zod 4.3.6 |
|
||||
| Frontend | React 18.3.1 + Vite 8.0.0 |
|
||||
| Frontend | React 18.3.1 + Vite 8.0.0 + Material UI v7 (Emotion) |
|
||||
| Testing | Vitest 4.1.0 + Supertest |
|
||||
| PDF | Puppeteer 24.x |
|
||||
| Email | nodemailer 8.x |
|
||||
@@ -35,17 +35,21 @@ src/
|
||||
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
||||
├── config/ # env.ts (config singleton, Date.toJSON override)
|
||||
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
|
||||
├── admin/ # React 18 frontend (57 .tsx files)
|
||||
├── admin/ # React 18 + Material UI frontend (~105 .tsx files)
|
||||
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||
│ ├── contexts/ # AuthContext, AlertContext
|
||||
│ ├── components/ # Layout, modals, tables, editors
|
||||
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
|
||||
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
|
||||
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
|
||||
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
||||
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets
|
||||
│ ├── pages/ # One file per page/feature
|
||||
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
|
||||
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
|
||||
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
|
||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
||||
└── __tests__/ # Vitest tests (auth, numbering)
|
||||
└── __tests__/ # Vitest tests (~14 files: auth, numbering, warehouse, plan, invoices, …)
|
||||
|
||||
prisma/
|
||||
├── schema.prisma # 32 models, MySQL, snake_case columns
|
||||
├── schema.prisma # 49 models, MySQL, snake_case columns
|
||||
└── migrations/ # Applied migrations
|
||||
|
||||
dist/ # Compiled server (CommonJS, ES2022)
|
||||
@@ -236,7 +240,7 @@ if ("error" in body) return error(reply, body.error, 400);
|
||||
|
||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
|
||||
|
||||
- Test coverage is minimal: only `auth` and `numbering` are tested.
|
||||
- The suite spans ~14 files / 152 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create. Server-side only (no component tests yet).
|
||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
|
||||
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
|
||||
@@ -250,9 +254,9 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
|
||||
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
||||
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
|
||||
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
|
||||
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
|
||||
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
|
||||
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
|
||||
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
|
||||
|
||||
### Query Invalidation Convention
|
||||
|
||||
@@ -274,6 +278,33 @@ When entity A embeds/references entity B, A's mutation handlers invalidate B's d
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
|
||||
|
||||
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
|
||||
|
||||
**Where styling lives**
|
||||
|
||||
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
|
||||
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
|
||||
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
|
||||
|
||||
**Building / editing pages**
|
||||
|
||||
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
|
||||
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
|
||||
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
|
||||
|
||||
**Conventions learned — don't regress**
|
||||
|
||||
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
|
||||
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
|
||||
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
|
||||
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
|
||||
|
||||
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
|
||||
|
||||
---
|
||||
|
||||
## Database Conventions
|
||||
|
||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
# MUI Migration — Phase 0: Theming Foundation — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stand up the MUI theming foundation — install MUI, build `theme.ts` (light+dark from existing tokens), wire the provider + `data-theme` color-scheme bridge + `ThemeContext` sync, apply the token-level palette refresh app-wide, and ship a dev-only `/ui-kit` route proving the theme on the first wrappers (`Button`/`Card`/`TextField`).
|
||||
|
||||
**Architecture:** MUI's CSS-variable theme is configured with `colorSchemeSelector: "[data-theme='%s']"` so it reads the same `data-theme` attribute `ThemeContext` already writes — one toggle drives both MUI and legacy CSS. A tiny sync component mirrors the attribute into MUI's JS color-scheme state. `ScopedCssBaseline` scopes MUI's reset to migrated subtrees (here, only `/ui-kit`); legacy pages keep `base.css`.
|
||||
|
||||
**Tech Stack:** React 18.3, Vite 8, TypeScript (strict), MUI v7 (`@mui/material`) + Emotion + MUI X v8 pickers, Vitest (node).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§3, §4, §7 token-level layer, §8 Phase 0).
|
||||
|
||||
**Scope note:** This plan is Phase 0 ONLY. The rest of the `ui/` kit (Modal, Toast, Select, Switch, Tabs, DataTable, DatePicker), the AppShell (Phase 1), and the Vozidla pilot (Phase 2) are separate plans.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| `package.json` | MUI/Emotion/X-pickers deps; `@types/react` reconciled to 18 | Modify |
|
||||
| `src/admin/theme.ts` | The MUI theme: cssVariables + colorSchemeSelector, light/dark palettes, typography, shape, component overrides | Create |
|
||||
| `src/admin/theme.test.ts` | Unit test asserting theme palette/typography/shape values | Create |
|
||||
| `src/admin/ui/MuiProvider.tsx` | Wraps children in MUI `ThemeProvider` + renders the sync component | Create |
|
||||
| `src/admin/ui/MuiColorSchemeSync.tsx` | Mirrors `useTheme()` (custom) → MUI `useColorScheme().setMode` | Create |
|
||||
| `src/admin/ui/Button.tsx`, `Card.tsx`, `TextField.tsx` | Thin brand wrappers over MUI (stable app-facing API) | Create |
|
||||
| `src/admin/ui/index.ts` | Barrel export for the kit | Create |
|
||||
| `src/admin/pages/UiKit.tsx` | Dev-only showcase, wrapped in `ScopedCssBaseline` | Create |
|
||||
| `src/admin/AdminApp.tsx` | Mount `MuiProvider`; add DEV-only `/ui-kit` route | Modify |
|
||||
| `src/admin/variables.css` | Token-level palette refresh (canvas, chip tints) — app-wide | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install dependencies and reconcile React types
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json` (via npm)
|
||||
|
||||
- [ ] **Step 1: Install MUI + Emotion + X pickers**
|
||||
|
||||
Run (date-fns ^4 is already a dependency):
|
||||
|
||||
```bash
|
||||
npm install @mui/material@^7 @emotion/react@^11 @emotion/styled@^11 @mui/x-date-pickers@^8
|
||||
```
|
||||
|
||||
Expected: packages added to `dependencies`; no peer-dependency errors that block install.
|
||||
|
||||
- [ ] **Step 2: Reconcile `@types/react` to the React 18 runtime**
|
||||
|
||||
The repo runs `react@^18.3.1` but pins `@types/react@^19`. MUI's component types are sensitive to the `@types/react` major. Align the types to the runtime:
|
||||
|
||||
```bash
|
||||
npm install -D @types/react@^18.3 @types/react-dom@^18.3
|
||||
```
|
||||
|
||||
Expected: devDependencies now show `@types/react@^18.3.x`.
|
||||
|
||||
- [ ] **Step 3: Verify the project still type-checks and builds**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: both succeed with no errors (this is the pre-change baseline; if `tsc` was already failing, capture that before proceeding).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json
|
||||
git commit -m "build(mui): add MUI v7 + Emotion + X pickers; align @types/react to 18"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `theme.ts` with light/dark palettes (TDD)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/theme.ts`
|
||||
- Test: `src/admin/theme.test.ts`
|
||||
|
||||
Token values are taken from `src/admin/variables.css` (`[data-theme="light"]` / `[data-theme="dark"]`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/admin/theme.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { theme } from "./theme";
|
||||
|
||||
describe("MUI theme", () => {
|
||||
it("uses the brand fonts", () => {
|
||||
expect(theme.typography.fontFamily).toContain("Plus Jakarta Sans");
|
||||
expect(String(theme.typography.h1.fontFamily)).toContain("Urbanist");
|
||||
});
|
||||
|
||||
it("maps the brand red per color scheme", () => {
|
||||
expect(theme.colorSchemes.light.palette.primary.main).toBe("#c73030");
|
||||
expect(theme.colorSchemes.dark.palette.primary.main).toBe("#d63031");
|
||||
});
|
||||
|
||||
it("maps the refreshed light canvas + paper", () => {
|
||||
expect(theme.colorSchemes.light.palette.background.default).toBe("#f4f3f1");
|
||||
expect(theme.colorSchemes.light.palette.background.paper).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("maps semantic colors per scheme", () => {
|
||||
expect(theme.colorSchemes.light.palette.error.main).toBe("#b91c1c");
|
||||
expect(theme.colorSchemes.dark.palette.success.main).toBe("#22c55e");
|
||||
});
|
||||
|
||||
it("uses the base radius of 10", () => {
|
||||
expect(theme.shape.borderRadius).toBe(10);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL — `Cannot find module './theme'` (or `theme` is undefined).
|
||||
|
||||
- [ ] **Step 3: Implement `theme.ts` (palettes/typography/shape only — overrides come in Task 3)**
|
||||
|
||||
Create `src/admin/theme.ts`:
|
||||
|
||||
```ts
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
||||
const FONT_HEADING = "'Urbanist', sans-serif";
|
||||
const FONT_MONO = "'DM Mono', Menlo, monospace";
|
||||
|
||||
export const theme = createTheme({
|
||||
cssVariables: {
|
||||
// Read the SAME attribute ThemeContext already writes (`data-theme`).
|
||||
// The "%s" placeholder is required; value must start with "." or "[".
|
||||
colorSchemeSelector: "[data-theme='%s']",
|
||||
},
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
mode: "light",
|
||||
primary: { main: "#c73030" },
|
||||
success: { main: "#15803d" },
|
||||
warning: { main: "#b45309" },
|
||||
error: { main: "#b91c1c" },
|
||||
info: { main: "#1d4ed8" },
|
||||
background: { default: "#f4f3f1", paper: "#ffffff" },
|
||||
text: { primary: "#1a1a1a", secondary: "#555555" },
|
||||
divider: "rgba(0,0,0,0.1)",
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
mode: "dark",
|
||||
primary: { main: "#d63031" },
|
||||
success: { main: "#22c55e" },
|
||||
warning: { main: "#f59e0b" },
|
||||
error: { main: "#ef4444" },
|
||||
info: { main: "#3b82f6" },
|
||||
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
||||
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
||||
divider: "rgba(255,255,255,0.08)",
|
||||
},
|
||||
},
|
||||
},
|
||||
shape: { borderRadius: 10 },
|
||||
typography: {
|
||||
fontFamily: FONT_BODY,
|
||||
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||
button: { textTransform: "none", fontWeight: 600 },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-exported for tasks/components that need the mono stack.
|
||||
export const fonts = {
|
||||
body: FONT_BODY,
|
||||
heading: FONT_HEADING,
|
||||
mono: FONT_MONO,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||
git commit -m "feat(mui): theme.ts with light/dark palettes, brand fonts, data-theme selector"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add component overrides (the "Soft-SaaS" defaults)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/admin/theme.ts`
|
||||
- Test: `src/admin/theme.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add a failing assertion for the overrides**
|
||||
|
||||
Append to `src/admin/theme.test.ts` (inside the `describe`):
|
||||
|
||||
```ts
|
||||
it("makes buttons pill-shaped and cards 16px by default", () => {
|
||||
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
||||
expect(btn?.borderRadius).toBe(999);
|
||||
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
|
||||
expect(card?.borderRadius).toBe(16);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL — `theme.components` is undefined / `borderRadius` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add `components` to the `createTheme` call**
|
||||
|
||||
In `src/admin/theme.ts`, add a `components` key to the `createTheme({...})` object (after `typography`):
|
||||
|
||||
```ts
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999, paddingInline: "0.95rem" },
|
||||
containedPrimary: {
|
||||
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
|
||||
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
boxShadow:
|
||||
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: { root: { borderRadius: 999, fontWeight: 700 } },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||
git commit -m "feat(mui): soft-SaaS component defaults (pill buttons, 16px cards, pill chips)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: MUI provider + color-scheme sync
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/ui/MuiColorSchemeSync.tsx`
|
||||
- Create: `src/admin/ui/MuiProvider.tsx`
|
||||
- Modify: `src/admin/AdminApp.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the sync component**
|
||||
|
||||
Create `src/admin/ui/MuiColorSchemeSync.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from "react";
|
||||
import { useColorScheme } from "@mui/material/styles";
|
||||
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||
|
||||
/**
|
||||
* ThemeContext is the single owner of the `data-theme` attribute. This mirrors
|
||||
* its value into MUI's JS color-scheme state so theme.applyStyles('dark') and
|
||||
* useColorScheme().mode stay in sync with the attribute the CSS selector reads.
|
||||
*/
|
||||
export default function MuiColorSchemeSync() {
|
||||
const { theme } = useAppTheme();
|
||||
const { mode, setMode } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== theme) setMode(theme as "light" | "dark");
|
||||
}, [theme, mode, setMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the provider wrapper**
|
||||
|
||||
Create `src/admin/ui/MuiProvider.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { theme } from "../theme";
|
||||
import MuiColorSchemeSync from "./MuiColorSchemeSync";
|
||||
|
||||
export default function MuiProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider theme={theme} defaultMode="dark">
|
||||
<MuiColorSchemeSync />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Mount the provider at the top of `AdminApp`**
|
||||
|
||||
In `src/admin/AdminApp.tsx`, add the import after the other component imports (e.g. after line 10):
|
||||
|
||||
```tsx
|
||||
import MuiProvider from "./ui/MuiProvider";
|
||||
```
|
||||
|
||||
Then wrap the existing returned tree. Change:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<AuthProvider>
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<MuiProvider>
|
||||
<AuthProvider>
|
||||
```
|
||||
|
||||
and change the matching closing tag at the end of the JSX:
|
||||
|
||||
```tsx
|
||||
</AuthProvider>
|
||||
);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
</AuthProvider>
|
||||
</MuiProvider>
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the app builds and renders with no console errors**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: both succeed. (The user runs the dev server separately — do not start it. Verification of live render happens via the `/ui-kit` route in Task 6.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/ui/MuiProvider.tsx src/admin/ui/MuiColorSchemeSync.tsx src/admin/AdminApp.tsx
|
||||
git commit -m "feat(mui): mount MUI provider + sync color scheme to data-theme"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: First UI-kit wrappers + barrel
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/ui/Button.tsx`, `src/admin/ui/Card.tsx`, `src/admin/ui/TextField.tsx`, `src/admin/ui/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create the wrappers**
|
||||
|
||||
Create `src/admin/ui/Button.tsx`:
|
||||
|
||||
```tsx
|
||||
import MuiButton, { type ButtonProps } from "@mui/material/Button";
|
||||
|
||||
/** App Button: defaults to the brand primary contained style. */
|
||||
export default function Button(props: ButtonProps) {
|
||||
return <MuiButton variant="contained" color="primary" {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/admin/ui/Card.tsx`:
|
||||
|
||||
```tsx
|
||||
import MuiCard, { type CardProps } from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
|
||||
export default function Card({ children, ...props }: CardProps) {
|
||||
return (
|
||||
<MuiCard {...props}>
|
||||
<CardContent>{children}</CardContent>
|
||||
</MuiCard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/admin/ui/TextField.tsx`:
|
||||
|
||||
```tsx
|
||||
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||
|
||||
/** App TextField: small + outlined + full width by default. */
|
||||
export default function TextField(props: TextFieldProps) {
|
||||
return <MuiTextField size="small" variant="outlined" fullWidth {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the barrel**
|
||||
|
||||
Create `src/admin/ui/index.ts`:
|
||||
|
||||
```ts
|
||||
export { default as MuiProvider } from "./MuiProvider";
|
||||
export { default as Button } from "./Button";
|
||||
export { default as Card } from "./Card";
|
||||
export { default as TextField } from "./TextField";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/ui/Button.tsx src/admin/ui/Card.tsx src/admin/ui/TextField.tsx src/admin/ui/index.ts
|
||||
git commit -m "feat(mui): first ui-kit wrappers (Button, Card, TextField) + barrel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Dev-only `/ui-kit` showcase route
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/pages/UiKit.tsx`
|
||||
- Modify: `src/admin/AdminApp.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the showcase page**
|
||||
|
||||
Create `src/admin/pages/UiKit.tsx`:
|
||||
|
||||
```tsx
|
||||
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { Button, Card, TextField } from "../ui";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
export default function UiKit() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<ScopedCssBaseline>
|
||||
<Box sx={{ p: 4, minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
|
||||
<Typography variant="h4">UI Kit</Typography>
|
||||
<Button color="inherit" variant="outlined" onClick={toggleTheme}>
|
||||
Theme: {theme}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3} sx={{ maxWidth: 520 }}>
|
||||
<Card>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Faktury
|
||||
</Typography>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
12 vystavených tento měsíc
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} mb={2}>
|
||||
<Chip label="Zaplaceno" color="success" size="small" />
|
||||
<Chip label="Po splatnosti" color="error" size="small" />
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Hledat fakturu"
|
||||
placeholder="Číslo nebo klient…"
|
||||
/>
|
||||
<Box mt={2}>
|
||||
<Button>Nová faktura</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Box>
|
||||
</ScopedCssBaseline>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the route, DEV-only**
|
||||
|
||||
In `src/admin/AdminApp.tsx`, add the lazy import alongside the other lazy pages (after line 11's eager imports / with the `lazy(...)` block):
|
||||
|
||||
```tsx
|
||||
const UiKit = lazy(() => import("./pages/UiKit"));
|
||||
```
|
||||
|
||||
Then, inside `<Routes>`, add a sibling route to `login` (so the showcase renders without the shell), guarded so it is dropped from production builds:
|
||||
|
||||
```tsx
|
||||
{
|
||||
import.meta.env.DEV && <Route path="ui-kit" element={<UiKit />} />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build (prod) excludes it and type-check passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: both succeed. `import.meta.env.DEV` is `false` in the production build, so the route is tree-shaken.
|
||||
|
||||
- [ ] **Step 4: Manual + Playwright verification**
|
||||
|
||||
The user runs the dev server. Once it's up, verify at `/ui-kit`:
|
||||
|
||||
- Card, button (red gradient, pill), chips (tinted), and text field render in the Soft-SaaS style.
|
||||
- Clicking **Theme: dark/light** flips `data-theme` and **both MUI components AND the page background** swap — proving the `colorSchemeSelector` bridge works.
|
||||
- No new console errors.
|
||||
|
||||
Capture baseline screenshots with the available Playwright tooling (light + dark) and save under `docs/superpowers/visual-baselines/phase-0-ui-kit-{light,dark}.png`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/pages/UiKit.tsx src/admin/AdminApp.tsx
|
||||
git commit -m "feat(mui): dev-only /ui-kit showcase proving the theme + data-theme bridge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Token-level palette refresh (app-wide)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/admin/variables.css`
|
||||
|
||||
Per spec §7, the token-level palette refresh is applied app-wide so migrated and legacy pages share the refreshed palette. The only token change for Phase 0 is the light canvas (the new look's `#f4f3f1` vs current `#f5f4f2`); chip/structure changes are component-level (MUI) and need no token edit.
|
||||
|
||||
- [ ] **Step 1: Update the light canvas token**
|
||||
|
||||
In `src/admin/variables.css`, inside the `[data-theme="light"]` block, change:
|
||||
|
||||
```css
|
||||
--bg-primary: #f5f4f2;
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
--bg-primary: #f4f3f1;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build:client
|
||||
```
|
||||
|
||||
Expected: succeeds.
|
||||
|
||||
- [ ] **Step 3: Manual check**
|
||||
|
||||
With the dev server (user-run), confirm in light mode the app canvas is the refreshed `#f4f3f1` and nothing else shifted structurally. (This is the deliberate, minor app-wide color change noted in the spec — not a no-op.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/variables.css
|
||||
git commit -m "style(tokens): refresh light canvas to #f4f3f1 (two-layer refresh, token level)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Phase-0 release verification
|
||||
|
||||
**Files:** none (verification + tag)
|
||||
|
||||
- [ ] **Step 1: Full type-check, unit tests, and build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsc -b --noEmit
|
||||
npx vitest run src/admin/theme.test.ts
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `tsc` clean; 6 theme tests pass; server + client build succeed.
|
||||
|
||||
- [ ] **Step 2: Backend test suite still green (server untouched)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: existing `auth` + `numbering` suites pass (Phase 0 changed no server code).
|
||||
|
||||
- [ ] **Step 3: Definition-of-done checklist (manual, dev server)**
|
||||
|
||||
- `/ui-kit` renders Button/Card/TextField/Chip in the Soft-SaaS style in **both** themes.
|
||||
- Theme toggle flips `data-theme` and swaps MUI + legacy colors together; MUI's `useColorScheme().mode` matches (no console warnings).
|
||||
- No new console errors on any existing page (the provider mount + token refresh are non-breaking).
|
||||
- Light canvas is `#f4f3f1`.
|
||||
- Production build excludes the `/ui-kit` route.
|
||||
|
||||
- [ ] **Step 4: Tag/release per the project release process**
|
||||
|
||||
Follow `CLAUDE.md` "Release Process" (bump `package.json`, build, tag, deploy) when shipping Phase 0. No Prisma migration is involved (frontend-only).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the controller)
|
||||
|
||||
- **Spec coverage:** Phase 0 items from spec §8 are all covered — deps + `@types/react` (Task 1), `theme.ts` light/dark (Tasks 2–3), provider + `ScopedCssBaseline` + `ThemeContext`↔`setMode` sync (Tasks 4, 6), dev-only `/ui-kit` (Task 6), token-level refresh (Task 7). The full `ui/` kit, AppShell, and Vozidla are explicitly deferred to later plans (scope note).
|
||||
- **Type consistency:** `theme` (named export) is used consistently in `theme.test.ts`, `MuiProvider.tsx`. `useTheme` refers to the app's `ThemeContext` (aliased `useAppTheme` in the sync component to avoid colliding with MUI's `useTheme`). Wrappers are default exports re-exported via the barrel.
|
||||
- **Open risk to verify in Task 1/6:** exact MUI v7 API for `cssVariables.colorSchemeSelector` + `ThemeProvider` `defaultMode` — confirm against the installed version's docs; if `defaultMode` belongs on a different provider in the installed version, adjust in Task 4.
|
||||
@@ -0,0 +1,357 @@
|
||||
# MUI Migration — Phase 1: AppShell — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Replace the admin chrome (`AdminLayout` + `Sidebar`) with a MUI `<AppShell>` in the Soft-SaaS look — a `Drawer`-based sidebar (permanent on desktop, temporary on mobile) with a white active "pill", a minimal topbar (hamburger + theme toggle), reusing the existing permission-filtered nav data and preserving all behavior (auth/2FA guards, logout scale+blur animation, ShortcutsHelp).
|
||||
|
||||
**Architecture:** The valuable, well-tested parts are **reused, not rewritten**: the `menuSections` nav data (icons, permissions, active-match rules) and the active/permission logic move into `navData.tsx`. `SidebarNav` renders that data with MUI `List`. `AppShell` provides the responsive `Drawer` + topbar + auth guards + the framer-motion logout wrapper, and renders `<Outlet/>`. `AdminLayout`/`Sidebar`/`layout.css` are deleted when `AppShell` lands.
|
||||
|
||||
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (kept for logout), react-router v6, TypeScript strict.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 AppShell, §7 aesthetic, §8 Phase 1, §9 animation).
|
||||
|
||||
**Authoritative gates:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file), `npm run build:client`, `npx vitest run`. Live visual verification (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||
|
||||
**Behavior parity checklist (must all be preserved from `AdminLayout.tsx` + `Sidebar.tsx`):**
|
||||
|
||||
- Auth: `loading` → spinner; `!isAuthenticated` → `<Navigate to="/login" replace/>`; 2FA-setup redirect (`user.require2FA && !user.totpEnabled && pathname !== "/"` → `/`).
|
||||
- Logout: `handleLogout` sets a logout alert (`setLogoutAlert()`), closes the mobile drawer, and calls `logout()` after 400ms; the layout plays the framer-motion `{scale:1.5, opacity:0, filter:"blur(12px)"}` exit over 0.4s.
|
||||
- Sidebar: permission-filtered sections/items (empty sections hidden); custom active matching (`matchPrefix`/`matchAlso`/`matchExclude`/`end`); company logo with light/dark variant + `onError` fallback to `/images/logo-{dark,light}.png`; user chip (avatar initial + name + `roleDisplay`); logout button; mobile open/close.
|
||||
- `ShortcutsHelp` still rendered; `<Outlet/>` for page content.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
|
||||
| `src/admin/ui/navData.tsx` | `MenuItem`/`MenuSection` types, the `menuSections` array (moved verbatim from Sidebar), pure helpers `isItemActive(item, pathname)` + `hasItemPermission(item, hasPermission)` | Create (move) |
|
||||
| `src/admin/ui/SidebarNav.tsx` | Renders `navData` with MUI `List`: logo header, sections, white-pill active items, user+logout footer | Create |
|
||||
| `src/admin/ui/AppShell.tsx` | Responsive `Drawer` + topbar + auth guards + logout animation + `<Outlet/>` + `ShortcutsHelp` | Create |
|
||||
| `src/admin/components/Sidebar.tsx` | (interim) import `menuSections`/helpers from `navData` instead of defining inline | Modify, then Delete |
|
||||
| `src/admin/components/AdminLayout.tsx` | Replaced by `AppShell` | Delete |
|
||||
| `src/admin/AdminApp.tsx` | Route uses `<AppShell/>` instead of `<AdminLayout/>`; remove `import "./layout.css"` | Modify |
|
||||
| `src/admin/layout.css` | Chrome styling now in MUI | Delete |
|
||||
|
||||
Import paths from `src/admin/ui/` (same depth as `components/`): `../context/AuthContext`, `../../context/ThemeContext`, `../utils/api`, `../hooks/...`, `../theme`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract nav data into `navData.tsx`
|
||||
|
||||
**Files:** Create `src/admin/ui/navData.tsx`; Modify `src/admin/components/Sidebar.tsx`.
|
||||
|
||||
- [ ] **Step 1: Create `src/admin/ui/navData.tsx`.** MOVE (cut) the following from `src/admin/components/Sidebar.tsx` **verbatim**: the `MenuItem` and `MenuSection` interfaces and the entire `menuSections` array (all 6 sections with their inline SVG icons — do not retype, move them unchanged). Then add the two pure helpers (extracted from Sidebar's inline closures), and export everything:
|
||||
|
||||
```tsx
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
permission?: string | string[];
|
||||
matchPrefix?: string;
|
||||
matchAlso?: string[];
|
||||
matchExclude?: string[];
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
label: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export const menuSections: MenuSection[] = [
|
||||
/* ... the 6 sections, MOVED verbatim from Sidebar.tsx ... */
|
||||
];
|
||||
|
||||
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
|
||||
export function isItemActive(item: MenuItem, pathname: string): boolean {
|
||||
if (item.matchPrefix) {
|
||||
let active = pathname.startsWith(item.matchPrefix);
|
||||
if (active && item.matchExclude) {
|
||||
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
|
||||
}
|
||||
if (!active && item.matchAlso) {
|
||||
active = item.matchAlso.some((p) => pathname.startsWith(p));
|
||||
}
|
||||
return active;
|
||||
}
|
||||
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
|
||||
return true;
|
||||
}
|
||||
if (item.end) return pathname === item.path;
|
||||
return pathname.startsWith(item.path);
|
||||
}
|
||||
|
||||
/** Permission check: true if no permission, or any listed permission is held. */
|
||||
export function hasItemPermission(
|
||||
item: MenuItem,
|
||||
hasPermission: (p: string) => boolean,
|
||||
): boolean {
|
||||
if (!item.permission) return true;
|
||||
if (Array.isArray(item.permission)) {
|
||||
return item.permission.some((p) => hasPermission(p));
|
||||
}
|
||||
return hasPermission(item.permission);
|
||||
}
|
||||
```
|
||||
|
||||
> Note: the original Sidebar computed `matchAlso` separately in the `NavLink` className callback. The consolidated `isItemActive` above folds `matchAlso` in so a single function is the source of truth. Preserve every section/item exactly.
|
||||
|
||||
- [ ] **Step 2: Update `Sidebar.tsx` to import from `navData`** (interim — Sidebar is deleted in Task 4). Remove the inline `MenuItem`/`MenuSection`/`menuSections` definitions and the inline `isItemActive`/`hasItemPermission`; import them from `../ui/navData`. In the `NavLink` `className` callback, replace the inline active+matchAlso logic with `isItemActive(item, location.pathname)`.
|
||||
|
||||
- [ ] **Step 3: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (147 pass — no behavior change).
|
||||
|
||||
- [ ] **Step 4: Commit** `src/admin/ui/navData.tsx` + `src/admin/components/Sidebar.tsx` (msg: `refactor(mui): extract sidebar nav data + active/permission helpers to navData`; end with the `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SidebarNav.tsx` (MUI drawer content)
|
||||
|
||||
**Files:** Create `src/admin/ui/SidebarNav.tsx`.
|
||||
|
||||
Renders the permission-filtered nav with MUI. Props: `{ onNavigate?: () => void; onLogout: () => void }` (`onNavigate` closes the mobile drawer; `onLogout` is owned by `AppShell`).
|
||||
|
||||
- [ ] **Step 1: Create the component.** Requirements (write complete MUI code):
|
||||
- `const { user, hasPermission } = useAuth();` (`../context/AuthContext`), `const { theme } = useTheme();` aliased from `../../context/ThemeContext`, `const location = useLocation();`.
|
||||
- `visibleSections` = `menuSections` mapped to filter items by `hasItemPermission(item, hasPermission)`, dropping empty sections.
|
||||
- Root: a flex column `Box`, `height: "100%"`, padding ~`1.5`.
|
||||
- **Logo header:** `Box component="img"` with `src` = `/api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"}` and `onError` falling back to `/images/logo-${theme}.png`; `sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}`.
|
||||
- **Nav:** scrollable `Box` (`flex: 1, overflowY: "auto"`). For each section: a `Typography variant="caption"` subheader (uppercase, `letterSpacing: ".06em"`, `fontWeight: 700`, `color: "text.secondary"`, `fontSize: ".64rem"`, `px: 1.5`), then a `List disablePadding` of items. Each item:
|
||||
```tsx
|
||||
<ListItemButton
|
||||
component={NavLink}
|
||||
to={item.path}
|
||||
end={item.end}
|
||||
onClick={onNavigate}
|
||||
selected={isItemActive(item, location.pathname)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.25,
|
||||
py: 0.6,
|
||||
color: "text.secondary",
|
||||
"& svg": { width: 18, height: 18 },
|
||||
"&.Mui-selected, &.Mui-selected:hover": {
|
||||
bgcolor: "background.paper",
|
||||
color: "primary.main",
|
||||
fontWeight: 700,
|
||||
boxShadow: "0 2px 8px rgba(20,20,40,0.07)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{ fontSize: ".82rem", fontWeight: "inherit" }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
```
|
||||
(Use MUI's `selected` prop for the active "pill" — do NOT rely on NavLink's default active class, because the active rule is custom.)
|
||||
- **Footer:** top-bordered `Box` (`borderColor: "divider"`) with a user chip (MUI `Avatar` `bgcolor: "primary.main"` showing `user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"`, plus name + `roleDisplay` `Typography` with `noWrap`), and a logout `Button` (`color="inherit"`, `fullWidth`, the existing logout SVG as `startIcon`, label "Odhlásit se", `onClick={onLogout}`).
|
||||
|
||||
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds. (Component not yet mounted; visual check is later.)
|
||||
|
||||
- [ ] **Step 3: Commit** `src/admin/ui/SidebarNav.tsx` (msg: `feat(mui): SidebarNav — MUI List nav with white-pill active state`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `AppShell.tsx` (responsive layout + guards + animation)
|
||||
|
||||
**Files:** Create `src/admin/ui/AppShell.tsx`.
|
||||
|
||||
- [ ] **Step 1: Create the component** with this exact structure (fill the guard bodies from the parity checklist):
|
||||
|
||||
```tsx
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||
import { setLogoutAlert } from "../utils/api";
|
||||
import SidebarNav from "./SidebarNav";
|
||||
import ShortcutsHelp from "../components/ShortcutsHelp";
|
||||
|
||||
const DRAWER_WIDTH = 248;
|
||||
|
||||
export default function AppShell() {
|
||||
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useAppTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setLoggingOut(true);
|
||||
setMobileOpen(false);
|
||||
setLogoutAlert();
|
||||
setTimeout(() => logout(), 400);
|
||||
}, [logout]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScopedCssBaseline>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<div className="admin-spinner" />
|
||||
</Box>
|
||||
</ScopedCssBaseline>
|
||||
);
|
||||
}
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScopedCssBaseline>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={
|
||||
loggingOut
|
||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||
: { scale: 1, opacity: 1, filter: "none" }
|
||||
}
|
||||
transition={{
|
||||
duration: loggingOut ? 0.4 : 0.25,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
{/* Desktop permanent drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: "border-box",
|
||||
border: 0,
|
||||
bgcolor: "background.default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SidebarNav onLogout={handleLogout} />
|
||||
</Drawer>
|
||||
|
||||
{/* Mobile temporary drawer */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: "block", md: "none" },
|
||||
"& .MuiDrawer-paper": {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SidebarNav
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{/* Main column */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Otevřít menu"
|
||||
sx={{ display: { md: "none" } }}
|
||||
>
|
||||
{/* hamburger SVG (3 lines), 24x24, stroke currentColor */}
|
||||
</IconButton>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
>
|
||||
{/* sun SVG when theme==='dark' is off / moon — keep the two SVGs from AdminLayout, render the one for the current theme */}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<ShortcutsHelp />
|
||||
</motion.div>
|
||||
</ScopedCssBaseline>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Fill the two `IconButton` SVGs from the existing `AdminLayout.tsx` (the hamburger 3-line icon; the sun/moon — render the moon when `theme === "dark"`, the sun when light, matching the current toggle's meaning). Keep the `admin-spinner` class for the loading state (it's defined in a CSS file that still exists in Phase 1).
|
||||
|
||||
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit** `src/admin/ui/AppShell.tsx` (msg: `feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire `AppShell`, delete legacy chrome
|
||||
|
||||
**Files:** Modify `src/admin/AdminApp.tsx`; Delete `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, `src/admin/layout.css`.
|
||||
|
||||
- [ ] **Step 1: Swap the route.** In `src/admin/AdminApp.tsx`: change the import `import AdminLayout from "./components/AdminLayout";` → `import AppShell from "./ui/AppShell";`, and the route `<Route element={<AdminLayout />}>` → `<Route element={<AppShell />}>`. Remove the line `import "./layout.css";`.
|
||||
|
||||
- [ ] **Step 2: Delete** `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, and `src/admin/layout.css`. Then grep the repo for any remaining import of those modules and fix/remove (there should be none beyond AdminApp).
|
||||
|
||||
- [ ] **Step 3: Verify (full gates)** — `npx tsc -b --noEmit` clean; `npm run build` (server+client) succeeds and the prod bundle has no broken refs; `npx vitest run` (147 pass). Confirm `layout.css` is no longer imported anywhere.
|
||||
|
||||
- [ ] **Step 4: Commit** (msg: `feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css`; + trailer).
|
||||
|
||||
- [ ] **Step 5: MANUAL verification (USER, dev server) — deferred:** sidebar shows permission-filtered sections; active item is the white pill; navigation works + closes the mobile drawer; theme toggle works; logout plays the scale+blur animation; logo light/dark + fallback; responsive (permanent drawer ≥ md, hamburger + temporary drawer < md); no console errors; dark + light both correct.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the controller)
|
||||
|
||||
- **Spec coverage:** Phase 1 (spec §8) — AppShell chrome via MUI Drawer + topbar (Tasks 2–4), reusing nav data/permissions (Task 1), preserving the logout animation + guards (Task 3), deleting `layout.css` (Task 4). Login stays OUT of this (it's a sibling route — Phase 3), untouched.
|
||||
- **Type consistency:** `navData` exports `MenuItem`/`MenuSection`/`menuSections`/`isItemActive`/`hasItemPermission`; `SidebarNav` and (interim) `Sidebar` both consume them. `AppShell` props to `SidebarNav`: `onLogout` (required), `onNavigate` (optional).
|
||||
- **Parity risks to watch in review:** the consolidated `isItemActive` must reproduce the original `matchAlso` behavior (the original applied `matchAlso` only in the NavLink callback) — the spec reviewer must diff active states for `/attendance/admin` (matchAlso `/attendance/create`,`/attendance/location`) and `/offers` (matchExclude). The `selected` prop must not double-apply with NavLink's own active class. `ScopedCssBaseline` wraps the shell so MUI reset applies; unmigrated page content inside `<Outlet/>` still uses its own CSS.
|
||||
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||
@@ -0,0 +1,395 @@
|
||||
# MUI Migration — Phase 2: Data/Form Kit + Vozidla Pilot — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Build the data + form foundation components (`DataTable`, `Modal`, `ConfirmDialog`, field controls) and migrate the **Vozidla (Vehicles)** page fully onto them — the pilot that battle-tests the kit. Behavior stays identical; styling becomes MUI (Soft-SaaS shell + dense table).
|
||||
|
||||
**Architecture:** New wrappers in `src/admin/ui/` expose stable, app-friendly APIs (Czech labels) over MUI `Dialog`/`Table`/form controls, so pages import from `ui/` not `@mui`. `Modal`/`ConfirmDialog` preserve the existing `FormModal`/`ConfirmModal` prop shapes so page churn is minimal. `DataTable` is a small dense table that takes a `columns` config + `rows` + optional row actions. Vozidla is then rewritten to use them; its data hooks (`useQuery(vehicleListOptions)`, `useApiMutation`) are unchanged.
|
||||
|
||||
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (page-entry animations kept), TanStack Query v5, TypeScript strict.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 kit, §5.1 DataTable, §7 dense-table aesthetic, §8 Phase 2).
|
||||
|
||||
**Authoritative gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (expect 148). Live visual check (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||
|
||||
**Reference (current Vozidla behavior to preserve):** `src/admin/pages/Vehicles.tsx` — list table (SPZ, Název, Značka/Model, Počáteční km, Aktuální km, Počet jízd, Stav, Akce) with `formatKm`; inactive rows dimmed; status badge toggles active/inactive via `toggleVehicle`; row actions edit/delete; add/edit `FormModal` with SPZ (uppercased)/Název (required) + Značka/Model/Počáteční km/aktivní checkbox + validation (`Zadejte SPZ`/`Zadejte název`); delete `ConfirmModal`; permission gate `vehicles.manage` → `<Forbidden/>`; empty state.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
|
||||
| `src/admin/ui/Modal.tsx` | MUI `Dialog` form modal; preserves FormModal API (`isOpen`/`onClose`/`onSubmit`/`title`/`subtitle`/`loading`/`submitDisabled`/`submitText`/`children`) | Create |
|
||||
| `src/admin/ui/ConfirmDialog.tsx` | MUI `Dialog` confirm; preserves ConfirmModal API (`isOpen`/`onClose`/`onConfirm`/`title`/`message`/`confirmText`/`confirmVariant`/`loading`) | Create |
|
||||
| `src/admin/ui/DataTable.tsx` | Dense MUI `Table` from a `columns` config + `rows`; right-aligned/mono numeric cells; row hover; optional `rowInactive` + actions column | Create |
|
||||
| `src/admin/ui/Field.tsx` | Labeled field wrapper (label + required + error helper) for composing inputs; `SwitchField` for booleans | Create |
|
||||
| `src/admin/ui/index.ts` | Export the new components | Modify |
|
||||
| `src/admin/pages/Vehicles.tsx` | Rewrite to use the kit | Modify |
|
||||
|
||||
Import paths from `src/admin/ui/`: `../context/AlertContext`, `../theme`, etc. MUI via deep imports.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Modal` + `ConfirmDialog` (MUI Dialog wrappers)
|
||||
|
||||
**Files:** Create `src/admin/ui/Modal.tsx`, `src/admin/ui/ConfirmDialog.tsx`; Modify `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 1: `Modal.tsx`** — MUI `Dialog` preserving the existing `FormModal` API:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import MuiButton from "@mui/material/Button";
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
submitDisabled?: boolean;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
maxWidth?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
loading = false,
|
||||
submitDisabled = false,
|
||||
submitText = "Uložit",
|
||||
cancelText = "Zrušit",
|
||||
maxWidth = "sm",
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={loading ? undefined : onClose}
|
||||
fullWidth
|
||||
maxWidth={maxWidth}
|
||||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||
>
|
||||
<DialogTitle sx={{ pb: subtitle ? 0.5 : 1.5 }}>
|
||||
{title}
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent>{children}</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||
{cancelText}
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={onSubmit}
|
||||
variant="contained"
|
||||
disabled={loading || submitDisabled}
|
||||
>
|
||||
{loading ? "Ukládám…" : submitText}
|
||||
</MuiButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `ConfirmDialog.tsx`** — MUI `Dialog` preserving the `ConfirmModal` API:
|
||||
|
||||
```tsx
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import MuiButton from "@mui/material/Button";
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmVariant?: "primary" | "danger";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Potvrdit",
|
||||
cancelText = "Zrušit",
|
||||
confirmVariant = "primary",
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={loading ? undefined : onClose}
|
||||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||
>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{message}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||
{cancelText}
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={onConfirm}
|
||||
variant="contained"
|
||||
color={confirmVariant === "danger" ? "error" : "primary"}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Pracuji…" : confirmText}
|
||||
</MuiButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** add `export { default as Modal } from "./Modal";` and `export { default as ConfirmDialog } from "./ConfirmDialog";` to `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 4: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
- [ ] **Step 5: Commit** (msg `feat(mui): Modal + ConfirmDialog (MUI Dialog wrappers)`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `DataTable` (dense MUI Table)
|
||||
|
||||
**Files:** Create `src/admin/ui/DataTable.tsx`; Modify `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 1: Create `DataTable.tsx`** — a generic dense table:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
export interface DataColumn<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
align?: "left" | "right";
|
||||
mono?: boolean; // DM Mono numeric cell
|
||||
render: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: DataColumn<T>[];
|
||||
rows: T[];
|
||||
rowKey: (row: T) => string | number;
|
||||
rowInactive?: (row: T) => boolean;
|
||||
empty?: ReactNode;
|
||||
}
|
||||
|
||||
export default function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
rowInactive,
|
||||
empty,
|
||||
}: DataTableProps<T>) {
|
||||
if (rows.length === 0 && empty) return <>{empty}</>;
|
||||
return (
|
||||
<TableContainer sx={{ borderRadius: 2 }}>
|
||||
<Table size="small" sx={{ "& td, & th": { borderColor: "divider" } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((c) => (
|
||||
<TableCell
|
||||
key={c.key}
|
||||
align={c.align}
|
||||
sx={{
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: ".05em",
|
||||
fontSize: ".64rem",
|
||||
fontWeight: 700,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{c.header}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
hover
|
||||
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<TableCell
|
||||
key={c.key}
|
||||
align={c.align}
|
||||
sx={{
|
||||
fontSize: ".8rem",
|
||||
...(c.mono ? { fontFamily: "'DM Mono', monospace" } : {}),
|
||||
}}
|
||||
>
|
||||
{c.render(row)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
(Numeric cells: pass `align="right" mono` and render via the existing `formatKm`/`formatCurrency` — the table only styles, never re-formats.)
|
||||
|
||||
- [ ] **Step 2:** add `export { default as DataTable } from "./DataTable"; export type { DataColumn } from "./DataTable";` to `index.ts`.
|
||||
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
- [ ] **Step 4: Commit** (msg `feat(mui): DataTable — dense MUI table with columns config`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Field controls (`Field` + `SwitchField`)
|
||||
|
||||
**Files:** Create `src/admin/ui/Field.tsx`; Modify `src/admin/ui/index.ts`.
|
||||
|
||||
- [ ] **Step 1: Create `Field.tsx`** with a labeled wrapper and a boolean switch:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Switch from "@mui/material/Switch";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
required,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "block",
|
||||
mb: 0.5,
|
||||
fontWeight: 600,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<Box component="span" sx={{ color: "error.main", ml: 0.25 }}>
|
||||
*
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{children}
|
||||
{error ? (
|
||||
<Typography variant="caption" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
) : hint ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** add `export { Field, SwitchField } from "./Field";` to `index.ts`. (`Button`, `Card`, `TextField` are already exported.)
|
||||
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||
- [ ] **Step 4: Commit** (msg `feat(mui): Field + SwitchField form controls`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate `Vehicles.tsx` onto the kit
|
||||
|
||||
**Files:** Modify `src/admin/pages/Vehicles.tsx`.
|
||||
|
||||
- [ ] **Step 1: Rewrite the page** keeping ALL existing data logic (the `useQuery(vehicleListOptions)`, `saveVehicle`/`deleteVehicle`/`toggleVehicle` `useApiMutation`s, validation, handlers, permission gate) UNCHANGED, and swapping only the presentation:
|
||||
- Permission gate + loading + empty state: keep behavior; render loading with the existing `admin-spinner` (still in base.css), or a MUI `CircularProgress` — keep `admin-spinner` for now.
|
||||
- Page header: an MUI `Box` (flex, title `Typography variant="h4"` "Správa vozidel" + the kit `Button` "Přidat vozidlo" with the plus SVG). Keep the framer-motion entry animation if desired (optional).
|
||||
- List: kit `Card` wrapping a `DataTable<Vehicle>` with columns: SPZ (`mono`), Název, Značka/Model (the existing `brand/model` join with `—` fallback), Počáteční km (`align right`, `mono`, `formatKm(...) + " km"`), Aktuální km (right/mono), Počet jízd (right/mono), Stav (a clickable MUI `Chip` colored `success`/`default` toggling `toggleActive`), Akce (edit + delete `IconButton`s with the existing SVGs). `rowInactive: (v) => !v.is_active`. `empty` = the existing empty-state block (can keep markup or render a simple MUI message + Button).
|
||||
- Add/Edit modal: kit `Modal` (`isOpen={showModal}`, `onClose`, `onSubmit={handleSubmit}`, `title`, `loading={saving}`) containing `Field`-wrapped kit `TextField`s for SPZ (uppercase onChange)/Název/Značka/Model, a number `TextField` (type="number") for Počáteční km with the hint, and a `SwitchField` for `is_active`. Wire `errors`/`setErrors` exactly as today.
|
||||
- Delete: kit `ConfirmDialog` (`isOpen={deleteConfirm.show}`, `onConfirm={handleDelete}`, `confirmVariant="danger"`, the existing message).
|
||||
- Remove imports of the old `FormModal`/`ConfirmModal`/`FormField`; import from `../ui`.
|
||||
|
||||
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (148 pass — no test touches this page, but confirm nothing broke).
|
||||
- [ ] **Step 3: Commit** (msg `feat(mui): migrate Vozidla (Vehicles) page onto MUI kit`; + trailer).
|
||||
- [ ] **Step 4: MANUAL (USER, dev server) — deferred:** list renders (dense, mono numerics, inactive dimmed); status chip toggles active/inactive; add/edit modal validates (SPZ/Název required) + saves; SPZ uppercases; delete confirm works; empty state; permission gate; dark + light; no console errors.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Phase-2 verification + final review
|
||||
|
||||
- [ ] **Step 1:** `npx tsc -b --noEmit` clean; `npm run build` (server+client); `npx vitest run` (148).
|
||||
- [ ] **Step 2:** Final whole-Phase-2 code review (kit components + Vozidla migration; behavior parity vs the original Vehicles.tsx).
|
||||
- [ ] **Step 3:** finishing-a-development-branch (merge to master after the user's visual check).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the controller)
|
||||
|
||||
- **Spec coverage:** §5 kit (Modal, ConfirmDialog, DataTable, field controls — Tasks 1–3), §5.1 dense DataTable (Task 2), §8 Phase 2 Vozidla pilot (Task 4). Pages still import only from `ui/`.
|
||||
- **Parity risks:** Vozidla validation (`Zadejte SPZ`/`Zadejte název`) + SPZ uppercasing + the three mutations' invalidate keys (`["vehicles","trips"]`) must be preserved verbatim. The status-toggle was a button styled as a badge → now a clickable `Chip`; behavior (toggle active) identical. `Modal`'s `onClose` is disabled while `loading` (matches FormModal's guard).
|
||||
- **No CSS file to delete:** Vozidla uses shared classes (forms/tables/buttons/components.css), not a `vehicles.css`; those shared files remain for not-yet-migrated pages. This page simply stops using them.
|
||||
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||
@@ -0,0 +1,362 @@
|
||||
# MUI Migration — Phase 3: Kit Expansion — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (or direct execution with gates). Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Build the reusable `src/admin/ui/` components the page migrations keep needing, so later phases are fast and consistent. No page is migrated in this phase; the dev-only `/ui-kit` route is extended to showcase + visually verify each new component.
|
||||
|
||||
**Why these (scout reuse counts):** `Select` ~34 pages, `Tabs` ~41 files, `StatusChip` ~34, plus paginated/sortable lists, dates, checkboxes, inline alerts.
|
||||
|
||||
**Tech Stack:** MUI v7 (`@mui/material`) + `@mui/x-date-pickers` v8 + `date-fns` (cs locale) + Emotion. TypeScript strict.
|
||||
|
||||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (`/ui-kit`) is the user's. No dev server started by implementers.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Component(s) | API |
|
||||
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------- | --------- | ---------------------------------------- |
|
||||
| `src/admin/ui/Select.tsx` | `Select` | `{ value, onChange(v), options?: {value,label}[], children?, ...TextFieldProps }` over `<TextField select>` — composes inside `Field` |
|
||||
| `src/admin/ui/StatusChip.tsx` | `StatusChip` | `{ label, color?: "default" | "success" | "error" | "warning" | "info", onClick?, size?, ...ChipProps }` |
|
||||
| `src/admin/ui/Tabs.tsx` | `Tabs`, `TabPanel` | `Tabs: { value, onChange(v), tabs: {value,label}[] }`; `TabPanel: { value, current, children }` |
|
||||
| `src/admin/ui/Pagination.tsx` | `Pagination` | `{ page, pageCount, onChange(p) }` (renders nothing if `pageCount<=1`) |
|
||||
| `src/admin/ui/Checkbox.tsx` | `CheckboxField` | `{ label, checked, onChange(b) }` (FormControlLabel + Checkbox) |
|
||||
| `src/admin/ui/Alert.tsx` | `Alert` | `{ severity, children, onClose? }` (inline MUI Alert) |
|
||||
| `src/admin/ui/DataTable.tsx` | extend | add `DataColumn.sortKey?`; `DataTableProps.{ sortBy?, sortDir?, onSort?(key) }`; render `TableSortLabel` in sortable headers |
|
||||
| `src/admin/ui/DateField.tsx` | `DateField` | `{ value: string("yyyy-MM-dd" | ""), onChange(v: string), ...}`over MUI X`DatePicker`, format `dd.MM.yyyy` |
|
||||
| `src/admin/ui/MuiProvider.tsx` | extend | wrap children in `LocalizationProvider` (`AdapterDateFns`, cs locale) so date pickers work app-wide |
|
||||
| `src/admin/ui/index.ts` | extend | export all new components |
|
||||
| `src/admin/pages/UiKit.tsx` | extend | showcase each new component in both themes |
|
||||
|
||||
Deferred (build when their page is migrated): `Autocomplete` (warehouse pickers), `FilterBar` layout helper.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Select`, `StatusChip`, `CheckboxField`, `Alert` (simple wrappers)
|
||||
|
||||
**Files:** Create the four files; modify `index.ts`.
|
||||
|
||||
- [ ] **Step 1: `Select.tsx`**
|
||||
|
||||
```tsx
|
||||
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
type Props = Omit<TextFieldProps, "onChange" | "select" | "value"> & {
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
options?: SelectOption[];
|
||||
};
|
||||
|
||||
/** Dropdown styled like the kit TextField; composes inside <Field>. */
|
||||
export default function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
children,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<MuiTextField
|
||||
select
|
||||
size="small"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map((o) => (
|
||||
<MenuItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</MenuItem>
|
||||
))
|
||||
: children}
|
||||
</MuiTextField>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `StatusChip.tsx`**
|
||||
|
||||
```tsx
|
||||
import Chip, { type ChipProps } from "@mui/material/Chip";
|
||||
|
||||
type Props = Omit<ChipProps, "color"> & {
|
||||
color?: "default" | "success" | "error" | "warning" | "info";
|
||||
};
|
||||
|
||||
/** Semantic status chip. Pass onClick to make it a toggle. */
|
||||
export default function StatusChip({
|
||||
color = "default",
|
||||
size = "small",
|
||||
onClick,
|
||||
sx,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<Chip
|
||||
color={color}
|
||||
size={size}
|
||||
onClick={onClick}
|
||||
sx={{ fontWeight: 600, ...(onClick ? { cursor: "pointer" } : {}), ...sx }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `Checkbox.tsx`**
|
||||
|
||||
```tsx
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MuiCheckbox from "@mui/material/Checkbox";
|
||||
|
||||
export function CheckboxField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<MuiCheckbox
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
(Import `React` type or use `import type { ReactNode } from "react"` and type `label: ReactNode`.)
|
||||
|
||||
- [ ] **Step 4: `Alert.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import MuiAlert from "@mui/material/Alert";
|
||||
|
||||
export default function Alert({
|
||||
severity = "info",
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
severity?: "success" | "error" | "warning" | "info";
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
|
||||
{children}
|
||||
</MuiAlert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5:** export from `index.ts`: `Select` (+ `type SelectOption`), `StatusChip`, `{ CheckboxField }`, `Alert`.
|
||||
- [ ] **Step 6: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Select, StatusChip, CheckboxField, Alert`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `Tabs` + `TabPanel`, `Pagination`
|
||||
|
||||
**Files:** Create `Tabs.tsx`, `Pagination.tsx`; modify `index.ts`.
|
||||
|
||||
- [ ] **Step 1: `Tabs.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import MuiTabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
export interface TabDef {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
value,
|
||||
onChange,
|
||||
tabs,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
tabs: TabDef[];
|
||||
}) {
|
||||
return (
|
||||
<MuiTabs
|
||||
value={value}
|
||||
onChange={(_, v) => onChange(v)}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
minHeight: 44,
|
||||
"& .MuiTab-root": { textTransform: "none", fontWeight: 600 },
|
||||
}}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<Tab key={t.value} value={t.value} label={t.label} />
|
||||
))}
|
||||
</MuiTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabPanel({
|
||||
value,
|
||||
current,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
current: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (value !== current) return null;
|
||||
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `Pagination.tsx`**
|
||||
|
||||
```tsx
|
||||
import Box from "@mui/material/Box";
|
||||
import MuiPagination from "@mui/material/Pagination";
|
||||
|
||||
export default function Pagination({
|
||||
page,
|
||||
pageCount,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
if (pageCount <= 1) return null;
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<MuiPagination
|
||||
page={page}
|
||||
count={pageCount}
|
||||
onChange={(_, p) => onChange(p)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** export `{ Tabs, TabPanel }` (+ `type TabDef`) and `Pagination` from `index.ts`.
|
||||
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Tabs/TabPanel + Pagination`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Sortable `DataTable`
|
||||
|
||||
**Files:** Modify `src/admin/ui/DataTable.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Extend the types:
|
||||
- `DataColumn<T>`: add `sortKey?: string;`
|
||||
- `DataTableProps<T>`: add `sortBy?: string; sortDir?: "asc" | "desc"; onSort?: (key: string) => void;`
|
||||
- [ ] **Step 2:** In the header cell render, if `c.sortKey && onSort` is set, wrap the header in `TableSortLabel`:
|
||||
|
||||
```tsx
|
||||
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||
// ...header cell:
|
||||
{
|
||||
c.sortKey && onSort ? (
|
||||
<TableSortLabel
|
||||
active={sortBy === c.sortKey}
|
||||
direction={sortBy === c.sortKey ? (sortDir ?? "asc") : "asc"}
|
||||
onClick={() => onSort(c.sortKey!)}
|
||||
>
|
||||
{c.header}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
c.header
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Non-sortable columns (no `sortKey`) render the plain header (unchanged). The existing `bold`/`mono`/`align`/`rowInactive`/`empty` behavior is untouched.
|
||||
|
||||
- [ ] **Step 3: Verify** `tsc -b` clean, `build:client` OK, `vitest run` (148). **Commit** (`feat(mui): kit — sortable DataTable headers (TableSortLabel)`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `DateField` + LocalizationProvider
|
||||
|
||||
**Files:** Create `src/admin/ui/DateField.tsx`; modify `MuiProvider.tsx`, `index.ts`.
|
||||
|
||||
- [ ] **Step 1: Add `LocalizationProvider` to `MuiProvider.tsx`** wrapping its children, so date pickers work everywhere:
|
||||
|
||||
```tsx
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
|
||||
import { cs } from "date-fns/locale";
|
||||
// inside MuiProvider, wrap {children} (and the sync) :
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
||||
<MuiColorSchemeSync />
|
||||
{children}
|
||||
</LocalizationProvider>;
|
||||
```
|
||||
|
||||
> Verify the exact adapter import path for the installed `@mui/x-date-pickers@8` against its docs (it may be `@mui/x-date-pickers/AdapterDateFns` rather than `...V3`). Pick the one that resolves; report which.
|
||||
|
||||
- [ ] **Step 2: `DateField.tsx`** — string-in / string-out (`yyyy-MM-dd`) to match existing form state:
|
||||
|
||||
```tsx
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { parseISO, format, isValid } from "date-fns";
|
||||
|
||||
export default function DateField({
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: { value: string; onChange: (v: string) => void } & Record<string, unknown>) {
|
||||
const dateValue = value ? parseISO(value) : null;
|
||||
return (
|
||||
<DatePicker
|
||||
value={dateValue && isValid(dateValue) ? dateValue : null}
|
||||
onChange={(d) => onChange(d && isValid(d) ? format(d, "yyyy-MM-dd") : "")}
|
||||
format="dd.MM.yyyy"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** export `DateField` from `index.ts`.
|
||||
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — DateField over MUI X + cs LocalizationProvider`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Extend `/ui-kit` showcase + final review
|
||||
|
||||
**Files:** Modify `src/admin/pages/UiKit.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Add a section rendering each new component with sample data, in the existing `ScopedCssBaseline` page: a `Select` (3 options), `StatusChip`s (each color), `Tabs`+`TabPanel` (2 tabs), `Pagination` (page 2 of 5), `CheckboxField`, `Alert` (one of each severity), a sortable `DataTable` (3 columns, one `sortKey`, local sort state), and a `DateField`.
|
||||
- [ ] **Step 2: Verify** `tsc -b` clean, `npm run build` (server+client) OK, `npx vitest run` (148). **Commit** (`feat(mui): showcase new kit components in /ui-kit`; + trailer).
|
||||
- [ ] **Step 3:** Final review of all new components (parallel/adversarial), then finishing-a-development-branch after the user's `/ui-kit` visual check.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- All new components compose with the existing `Field` (Select/DateField are inputs; Field provides the label) and follow the size=small/outlined/fullWidth defaults.
|
||||
- `DataTable` sort is **additive** — existing callers (Vehicles) are unaffected (no `sortKey`/`onSort` → plain headers).
|
||||
- `DateField` is string-in/string-out so it drops into existing form state (`start_date: ""`), and uses cs locale + `dd.MM.yyyy` (matching the date convention in CLAUDE.md).
|
||||
- Deferred: `Autocomplete`, `FilterBar` — built when warehouse/list pages are migrated.
|
||||
@@ -0,0 +1,67 @@
|
||||
# MUI Migration — Phase 4: Users (Uživatelé) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Migrate `src/admin/pages/Users.tsx` onto the MUI kit, mirroring the already-migrated `Vehicles.tsx`, preserving ALL data logic. Presentation only changes.
|
||||
|
||||
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button`, `Card`, `DataTable`, `Modal`, `ConfirmDialog`, `Field`, `TextField`, `SwitchField`, **`Select`** (new), **`StatusChip`** (new). MUI `Avatar`/`IconButton` used directly (as Vehicles uses `IconButton`).
|
||||
|
||||
**Reference:** `src/admin/pages/Vehicles.tsx` is the canonical migrated page — copy its structure (Box + motion.div header, `Button startIcon`, `Card` + `DataTable`, `Modal` with `Field`/`TextField`, `ConfirmDialog`). The Users data layer already exists; do not change it.
|
||||
|
||||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (dev server) is the user's.
|
||||
|
||||
---
|
||||
|
||||
## Behavior to preserve VERBATIM (from the scout)
|
||||
|
||||
- **Three mutations + invalidation:** `USER_INVALIDATE = ["users","trips","attendance","leave-requests","leave","projects"]`. `saveUser` (PUT if `editingUser` else POST), `deleteUser` (DELETE), `toggleUser` (PUT `{id,is_active}`). Keep all `onSuccess` messages.
|
||||
- **Self-edit AuthContext sync (critical):** in `saveUser.onSuccess`, when `editingUser && currentUser && Number(editingUser.id)===Number(currentUser.id)`, call `updateUser({ username, email, fullName: \`${first_name} ${last_name}\`.trim() })`. Then `setShowModal(false); setEditingUser(null);`then`await new Promise(r=>setTimeout(r,300))` **(intentional delay — keep it)** before the success toast.
|
||||
- **Auth guard:** `const { user: currentUser, updateUser, hasPermission } = useAuth();` → `if (!hasPermission("users.view")) return <Forbidden/>;`
|
||||
- **Self-protection:** the delete action is **hidden** and the status toggle is **disabled** when `user.id === currentUser?.id`. These reference the live `currentUser` — keep them in the column render closures.
|
||||
- **Password:** required only on create (`!editingUser`); empty string on edit means "keep existing".
|
||||
- **Role dropdown:** options from `roleListOptions()` query; default to `roles[0]?.id` on create. `role_id` is `number|string` in form state.
|
||||
- **`FormData` shape:** `{ username, email, password, first_name, last_name, role_id, is_active }`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Migrate `Users.tsx`
|
||||
|
||||
**Files:** Modify `src/admin/pages/Users.tsx` (read it first; it's the source of truth for the logic above).
|
||||
|
||||
- [ ] **Step 1: Read** `src/admin/pages/Users.tsx` fully and `src/admin/pages/Vehicles.tsx` (the template). Keep ALL hooks, mutations, state, handlers, and the guard exactly; change only the rendered JSX.
|
||||
|
||||
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, SwitchField, Select, StatusChip, type DataColumn`. From `@mui/material`: `Box, Typography, Avatar, IconButton`. Keep `motion` (framer-motion), `useQuery`, `roleListOptions`, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. **Remove** old imports: `FormModal`, `ConfirmModal`, `FormField`.
|
||||
|
||||
- [ ] **Step 3: Page shell** — mirror Vehicles: outer `Box`; `motion.div` header (`Typography variant="h4"` "Správa uživatelů" + `Button startIcon={PlusIcon}` "Přidat uživatele"); `motion.div` + `Card` wrapping the `DataTable`. Loading state keeps the `admin-spinner` markup.
|
||||
|
||||
- [ ] **Step 4: DataTable columns** (`DataColumn<User>[]`, defined inside the component so they close over `currentUser`/handlers):
|
||||
- **Uživatel:** composite render — MUI `Avatar` (initial of `full_name`/`username`, `bgcolor:"primary.main"`) + name (`Typography`) + `@username` (`Typography variant="caption" color="text.secondary"`).
|
||||
- **Email:** `render: (u) => u.email`.
|
||||
- **Role:** `StatusChip` colored by role — map `"admin"` → `color="warning"`, else `"default"`; label = `roleDisplay`/role name.
|
||||
- **Stav:** clickable `StatusChip` (`color={u.is_active?"success":"default"}`, `onClick` → `toggleActive(u)`), but **non-clickable / no onClick when `u.id===currentUser?.id`** (self-protection).
|
||||
- **Akce:** `IconButton` edit (always); `IconButton` delete (`color="error"`) **only when `u.id !== currentUser?.id`**. Use the same edit/delete SVGs as Vehicles.
|
||||
|
||||
- [ ] **Step 5: Add/Edit `Modal`** — `Field` + kit `TextField` for username/email/first_name/last_name; password `TextField type="password"` (label notes optional on edit); **`Select`** (inside a `Field` label="Role") for `role_id` with `options` from the roles query (`value: String(r.id), label: r.display_name||r.name`) — note `Select` is string-based, so store/compare `role_id` as string or `String(...)` at the boundary; `SwitchField` for `is_active`. Add a local `errors` state + validation in `handleSubmit` (required: username, email, first_name, last_name; password required when `!editingUser`) mirroring Vehicles, before `saveUser.mutateAsync`.
|
||||
|
||||
- [ ] **Step 6: Delete `ConfirmDialog`** — drop-in replacement for `ConfirmModal` (title "Smazat uživatele", message with the user's name, `confirmVariant="danger"`).
|
||||
|
||||
- [ ] **Step 7: Verify** `npx tsc -b --noEmit` clean; `npm run build` OK; `npx vitest run` (148). Drop all `admin-*` class names.
|
||||
|
||||
- [ ] **Step 8: Commit** (`feat(mui): migrate Users (Uživatelé) page onto MUI kit`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||
|
||||
- [ ] **Step 9: MANUAL (USER, dev server) — deferred:** list renders (avatar/role/status); status toggle works + is disabled for self; delete hidden for self; add (password required) + edit (password optional, role Select) save; self-edit updates the sidebar name; validation; dark+light; no console errors.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Review + finish
|
||||
|
||||
- [ ] Spec-compliance review (esp. the self-edit AuthContext sync, self-protection, password-on-create-only, role Select string boundary, USER_INVALIDATE) then code-quality review.
|
||||
- [ ] finishing-a-development-branch after the user's visual check.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- The role `Select` is string-based (per the Phase 3 review fix); convert `role_id` at the boundary (`String(r.id)` for options/value; the server accepts the string or coerce on submit if the API needs a number — check how the existing code sends it and match).
|
||||
- Self-protection guards and the self-edit `updateUser` sync are the highest-risk items — the spec reviewer must confirm both survive the column-render refactor.
|
||||
- No `users.css` exists; Users uses shared classes — nothing to delete, it just stops using them.
|
||||
@@ -0,0 +1,85 @@
|
||||
# MUI Migration — Phase 5: Projects (Projekty) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Migrate `src/admin/pages/Projects.tsx` onto the MUI kit. It's a **paginated, server-sorted list** with a create modal (customer/user `Select`s, `DateField`s, multiline notes) and a delete confirm that has an extra "delete files from disk" checkbox. Mirror `Vehicles.tsx`/`Users.tsx`; preserve ALL data logic.
|
||||
|
||||
**Prereq kit tweak (Task 1):** `ConfirmDialog` needs to accept optional extra content below the message (for the delete-files checkbox), since `message` is a string.
|
||||
|
||||
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button, Card, DataTable` (now sortable), `Pagination`, `Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box/Typography/IconButton` direct.
|
||||
|
||||
**Reference:** `Vehicles.tsx` + `Users.tsx` (migrated). The Projects data layer is authoritative — keep it.
|
||||
|
||||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check is the user's.
|
||||
|
||||
---
|
||||
|
||||
## Behavior to preserve VERBATIM (from the scout)
|
||||
|
||||
- **List state:** `const { sort, order, handleSort, activeSort } = useTableSort("project_number")`; `search`/`page` state; **reset `setPage(1)` when search changes**. `usePaginatedQuery<Project>(projectListOptions({ search, sort, order, page }))` → `{ items, pagination, isPending, isFetching }`.
|
||||
- **isFetching dimming:** the list Card gets `sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}`.
|
||||
- **Create-only** (edit navigates to the `/projects/:id` detail route — NOT a modal). `createForm = { name, customer_id, responsible_user_id, status: "aktivni", start_date, end_date, notes }`, `createErrors`, `creating`. Dependent queries enabled only while the modal is open: `offerCustomersOptions()`, `userListOptions("projects.view")` (filter to `is_active`), `projectNextNumberOptions()`. `createMutation`: POST `/projects`, `invalidate: ["projects","orders","offers","invoices","attendance"]`, onSuccess close + "Projekt byl vytvořen". **Validation: only `name` required ("Zadejte název projektu").**
|
||||
- **Delete:** `deleteTarget`/`deleteFiles` state. `deleteMutation`: DELETE `/projects/:id`, `invalidate: ["projects","warehouse","orders","offers","invoices","attendance"]`, called as `mutateAsync({ id, delete_files: deleteFiles })`, onSuccess toast `data?.message || "Projekt byl smazán"` + reset.
|
||||
- **Permissions:** `projects.view` → `<Forbidden/>`; `projects.create` hides the add button; `projects.delete` hides the per-row delete; delete also hidden when `p.order_id` (order-linked projects can't be manually deleted).
|
||||
- **Status constants:** `STATUS_LABELS = { aktivni:"Aktivní", dokonceny:"Dokončený", zruseny:"Zrušený" }`. Map to `StatusChip` colors: `aktivni`→`success`, `dokonceny`→`info`, `zruseny`→`default`.
|
||||
- **Next number:** `nextNumberQuery.data?.number ?? nextNumberQuery.data?.next_number ?? "…"`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend `ConfirmDialog` with optional extra content
|
||||
|
||||
**Files:** Modify `src/admin/ui/ConfirmDialog.tsx`.
|
||||
|
||||
- [ ] **Step 1:** Add `children?: ReactNode` to `ConfirmDialogProps` (import `type { ReactNode }`). Render it in `DialogContent` BELOW the message (live — not frozen, so the checkbox stays interactive):
|
||||
|
||||
```tsx
|
||||
<DialogContent>
|
||||
<DialogContentText>{shown.message}</DialogContentText>
|
||||
{children}
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Verify `tsc -b` clean; `build:client` OK; existing callers (Vozidla, Users) unaffected (no `children` → unchanged). **Commit** (`feat(mui): ConfirmDialog optional children (extra content below message)`; + trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate `Projects.tsx`
|
||||
|
||||
**Files:** Modify `src/admin/pages/Projects.tsx` (read it first — authoritative for the logic above).
|
||||
|
||||
- [ ] **Step 1: Read** `src/admin/pages/Projects.tsx` fully + `Users.tsx`/`Vehicles.tsx`. Keep ALL hooks/queries/mutations/handlers/permission-guards/state. Change only presentation.
|
||||
|
||||
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box, Typography, IconButton` direct. Keep `motion`, `useNavigate`, the query options, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. Remove `FormModal`/`ConfirmModal`/`FormField`/`AdminDatePicker`/legacy `Pagination` imports.
|
||||
|
||||
- [ ] **Step 3: Page shell** — Box + motion.div header (`Typography variant="h4"` "Projekty" + `Button startIcon={PlusIcon}` "Nový projekt", hidden without `projects.create`). A search `TextField` (no `Field` wrapper) that sets search + `setPage(1)`.
|
||||
|
||||
- [ ] **Step 4: List** — `Card` (with the `isFetching` opacity sx) wrapping a sortable `DataTable<Project>`:
|
||||
- Wire sort: `sortBy={sort} sortDir={order} onSort={handleSort}`. Give sortable columns a `sortKey` (e.g. `project_number`, `name` — match the server's accepted sort keys from the original `SortIcon` usage).
|
||||
- Columns: project number (`mono`, `sortKey`), name (`sortKey`), customer, responsible user, status (`StatusChip` per the color map), linked order number (if `order_id`), start/end dates (`mono`, via the existing date formatter), actions — a detail link/`IconButton` that `navigate(\`/projects/${p.id}\`)`for edit, and a delete`IconButton color="error"`shown only when`hasPermission("projects.delete") && !p.order_id`.
|
||||
- Below the table: `<Pagination page={page} pageCount={pagination?.totalPages ?? 1} onChange={setPage} />` (use the actual page-count field from `pagination` — check the shape).
|
||||
|
||||
- [ ] **Step 5: Create `Modal`** (`isOpen={showCreate}`, `loading={creating}`, title "Nový projekt", subtitle/hint with the next number): `Field`+`TextField` for name (required, error from `createErrors`); `Select` (in a `Field`) for customer (`offerCustomersOptions`) and responsible user (active users) — options `{ value: String(id), label }`, convert at the boundary; `Select` for status (the STATUS_LABELS options); two `DateField`s for start/end (string in/out — matches `createForm.start_date`); `TextField multiline minRows={3}` for notes. Validation: only name required.
|
||||
|
||||
- [ ] **Step 6: Delete `ConfirmDialog`** — title "Smazat projekt", message with the project name, `confirmVariant="danger"`, and as **children** a `CheckboxField` "Smazat i soubory z disku" bound to `deleteFiles`. `onConfirm` calls the existing delete handler (`mutateAsync({ id, delete_files: deleteFiles })`).
|
||||
|
||||
- [ ] **Step 7: Verify** `tsc -b` clean; `npm run build` OK; `npx vitest run` (148). Drop `admin-*` classes. (Projects has no own CSS file; it uses shared classes.)
|
||||
|
||||
- [ ] **Step 8: Commit** (`feat(mui): migrate Projects (Projekty) page onto MUI kit`; + trailer).
|
||||
|
||||
- [ ] **Step 9: MANUAL (USER) — deferred:** list renders + sorts (click headers) + paginates + dims while fetching; create modal (customer/user/status selects, date pickers, notes; name-required validation; next number shown) saves; delete confirm with the files checkbox works; order-linked projects show no delete; permission gating; dark+light.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Review + finish
|
||||
|
||||
- [ ] Spec-compliance review (server sort/pagination wiring, the create `invalidate` arrays, name-only validation, delete `{id, delete_files}` body + the checkbox, permission/order-link gating, status color map, the Select string boundaries) → code-quality review.
|
||||
- [ ] finishing-a-development-branch after the user's visual check.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- This is the first page to exercise the **sortable DataTable** + **Pagination** + **DateField** + **Select** together — confirm the sort keys match what the server accepts and the pagination page-count field name is correct (read the query options / a working paginated page).
|
||||
- `Select` is string-based; convert customer/user/status ids at the form boundary (`String(...)` for options/value; send what the server expects).
|
||||
- The delete-files checkbox rides in `ConfirmDialog` `children` (Task 1) — it's live (not frozen), so it toggles while open; it resets via the caller on close.
|
||||
- Edit is a route navigation (`/projects/:id`), NOT a modal — don't add an edit modal.
|
||||
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",
|
||||
"version": "1.9.6",
|
||||
"version": "2.0.1",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
@@ -32,11 +32,15 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@mui/material": "^7.3.11",
|
||||
"@mui/x-date-pickers": "^8.29.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
@@ -74,8 +78,8 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -2,7 +2,13 @@ import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AdminApp from "./admin/AdminApp";
|
||||
|
||||
// Bootstrap loader: renders before MuiProvider (and its global styles) mount,
|
||||
// so it must be fully self-contained — no theme vars, no global CSS classes.
|
||||
// Background follows the persisted theme attribute set by ThemeContext.
|
||||
function AdminLoader() {
|
||||
const light =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.getAttribute("data-theme") === "light";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -10,10 +16,20 @@ function AdminLoader() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--bg-primary)",
|
||||
background: light ? "#f4f3f1" : "#0f0f0f",
|
||||
}}
|
||||
>
|
||||
<div className="admin-spinner" />
|
||||
<style>{"@keyframes boha-boot-spin{to{transform:rotate(360deg)}}"}</style>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "2px solid #d63031",
|
||||
borderTopColor: "transparent",
|
||||
borderRadius: "50%",
|
||||
animation: "boha-boot-spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
generateOfferNumber,
|
||||
previewSharedNumber,
|
||||
isSharedNumberTaken,
|
||||
previewOfferNumber,
|
||||
isOfferNumberTaken,
|
||||
} from "../services/numbering.service";
|
||||
import prisma from "../config/database";
|
||||
|
||||
@@ -48,3 +52,83 @@ describe("generateOfferNumber", () => {
|
||||
expect(num1).not.toBe(num2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewSharedNumber", () => {
|
||||
let createdProjectId: number | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdProjectId !== null) {
|
||||
await prisma.projects
|
||||
.delete({ where: { id: createdProjectId } })
|
||||
.catch(() => {});
|
||||
createdProjectId = null;
|
||||
}
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
it("returns a number not already used by an order or project", async () => {
|
||||
const preview = await previewSharedNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(await isSharedNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number. With the
|
||||
// skip-taken logic this is guaranteed free, so we can safely claim it.
|
||||
const first = await previewSharedNumber();
|
||||
const project = await prisma.projects.create({
|
||||
data: { project_number: first, name: "test-preview-skip-taken" },
|
||||
});
|
||||
createdProjectId = project.id;
|
||||
|
||||
// The counter still lags behind (no row was consumed), but `first` is now
|
||||
// taken — the preview must advance past it instead of re-offering it.
|
||||
const second = await previewSharedNumber();
|
||||
expect(second).not.toBe(first);
|
||||
expect(await isSharedNumberTaken(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewOfferNumber", () => {
|
||||
let createdQuotationId: number | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdQuotationId !== null) {
|
||||
await prisma.quotations
|
||||
.delete({ where: { id: createdQuotationId } })
|
||||
.catch(() => {});
|
||||
createdQuotationId = null;
|
||||
}
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
it("returns a number not already used by a quotation", async () => {
|
||||
const preview = await previewOfferNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(await isOfferNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number; with the
|
||||
// skip-taken logic it's guaranteed free, so we can claim it.
|
||||
const first = await previewOfferNumber();
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: { quotation_number: first },
|
||||
});
|
||||
createdQuotationId = quotation.id;
|
||||
|
||||
// The counter still lags, but `first` is now taken — the preview must
|
||||
// advance past it instead of re-offering it.
|
||||
const second = await previewOfferNumber();
|
||||
expect(second).not.toBe(first);
|
||||
expect(await isOfferNumberTaken(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,29 +5,12 @@ import { AuthProvider } from "./context/AuthContext";
|
||||
import { AlertProvider } from "./context/AlertContext";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import AppShell from "./ui/AppShell";
|
||||
import AlertContainer from "./components/AlertContainer";
|
||||
import MuiProvider from "./ui/MuiProvider";
|
||||
import { LoadingState } from "./ui";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import "./variables.css";
|
||||
import "./base.css";
|
||||
import "./forms.css";
|
||||
import "./buttons.css";
|
||||
import "./layout.css";
|
||||
import "./components.css";
|
||||
import "./tables.css";
|
||||
import "./datepicker.css";
|
||||
import "./filemanager.css";
|
||||
import "./pagination.css";
|
||||
import "./responsive.css";
|
||||
import "./login.css";
|
||||
import "./dashboard.css";
|
||||
import "./attendance.css";
|
||||
import "./plan.css";
|
||||
import "./settings.css";
|
||||
import "./offers.css";
|
||||
import "./invoices.css";
|
||||
import "./warehouse.css";
|
||||
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
@@ -81,24 +64,23 @@ const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||
|
||||
export default function AdminApp() {
|
||||
return (
|
||||
<MuiProvider>
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
{import.meta.env.DEV && UiKit && (
|
||||
<Route path="ui-kit" element={<UiKit />} />
|
||||
)}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
@@ -156,7 +138,10 @@ export default function AdminApp() {
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route path="warehouse/items" element={<WarehouseItems />} />
|
||||
<Route
|
||||
path="warehouse/items"
|
||||
element={<WarehouseItems />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
@@ -233,5 +218,6 @@ export default function AdminApp() {
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
</MuiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
162
src/admin/GlobalStyles.tsx
Normal file
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,455 +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;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Mobile (responsive)
|
||||
============================================================================ */
|
||||
|
||||
/* The Leaflet map at a fixed 400px dominates a phone viewport — let it scale
|
||||
with the viewport height (with sensible min/max) on small screens. */
|
||||
@media (max-width: 640px) {
|
||||
.attendance-location-map {
|
||||
height: clamp(220px, 45vh, 400px);
|
||||
}
|
||||
}
|
||||
|
||||
/* The clock card's 2rem padding crushes content at ~360px — tighten it. */
|
||||
@media (max-width: 480px) {
|
||||
.attendance-clock-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
/* ============================================================================
|
||||
Reset & Base
|
||||
============================================================================ */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
min-height: 100dvh;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior-x: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-sidebar,
|
||||
.admin-header,
|
||||
.admin-card,
|
||||
.admin-modal {
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
#root {
|
||||
overflow-x: hidden;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
h3 {
|
||||
font-size: clamp(1.25rem, 2vw, 1.5rem);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Base / Utilities
|
||||
============================================================================ */
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success) !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
color: var(--text-tertiary) !important;
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
.fw-600 {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.link-accent {
|
||||
color: var(--accent-color);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-accent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Layout utilities */
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-row-gap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Spacing utilities */
|
||||
.mb-2 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.gap-2 {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.gap-5 {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* Typography utilities */
|
||||
.fw-500 {
|
||||
font-weight: 500;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Spinner variant */
|
||||
.admin-spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Monospace for data values (times, dates, numbers, IDs) */
|
||||
.admin-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Drag handle */
|
||||
.admin-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.admin-drag-handle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.admin-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Error stack (DEV only) */
|
||||
.admin-error-stack {
|
||||
max-width: 600px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--danger);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Keyboard shortcut badge */
|
||||
.admin-kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Loading & Animations */
|
||||
.admin-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--accent-color);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 256px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(30px, -30px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Additional Utilities ─────────────────────────────────────────── */
|
||||
|
||||
/* Font sizes */
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.text-md {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Width utilities */
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-xs {
|
||||
max-width: 120px;
|
||||
}
|
||||
.max-w-sm {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* Whitespace */
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Additional gaps */
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Additional margins */
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.mt-3 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Display */
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Prefers reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.admin-spinner {
|
||||
animation-duration: 0.8s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/* ============================================================================
|
||||
Buttons
|
||||
============================================================================ */
|
||||
|
||||
.admin-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 8px 14px;
|
||||
/* 1px transparent border on ALL buttons so switching between primary
|
||||
and secondary doesn't cause a 1px layout shift (the border is always
|
||||
1px — only the colour changes). */
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Prevent buttons from growing when text changes to spinner + loading text */
|
||||
.admin-modal-footer .admin-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
.admin-modal-footer .admin-btn .admin-spinner {
|
||||
margin: -2px 0;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
padding: 6px 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
|
||||
}
|
||||
|
||||
.admin-btn .admin-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.admin-btn-primary .admin-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-secondary .admin-spinner {
|
||||
border-color: rgba(var(--text-secondary-rgb, 107, 114, 128), 0.3);
|
||||
border-top-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-btn-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
border-color: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.admin-btn-danger .admin-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.admin-btn-icon:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-btn-icon.accent {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-btn-icon.accent:hover {
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-btn-icon.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-btn-icon.danger:hover {
|
||||
background: var(--danger-light);
|
||||
}
|
||||
|
||||
/* Touch targets - min 44px on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.admin-btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,223 +0,0 @@
|
||||
import { forwardRef } from "react";
|
||||
import DatePicker, { registerLocale } from "react-datepicker";
|
||||
import { cs } from "date-fns/locale";
|
||||
import { parse, format } from "date-fns";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
registerLocale("cs", cs);
|
||||
|
||||
// Ensure portal root exists
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
!document.getElementById("datepicker-portal")
|
||||
) {
|
||||
const el = document.createElement("div");
|
||||
el.id = "datepicker-portal";
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
const isTouchDevice = () =>
|
||||
typeof window !== "undefined" &&
|
||||
("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||
|
||||
interface CustomInputProps {
|
||||
value?: string;
|
||||
onClick?: () => void;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
(
|
||||
{ value, onClick, onChange, placeholder, required, readOnly, disabled },
|
||||
ref,
|
||||
) => (
|
||||
<input
|
||||
className="admin-form-input"
|
||||
onClick={onClick}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
interface NativeInputProps {
|
||||
mode: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const modeToInputType: Record<string, string> = {
|
||||
month: "month",
|
||||
time: "time",
|
||||
};
|
||||
|
||||
function NativeInput({
|
||||
mode,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabled,
|
||||
}: NativeInputProps) {
|
||||
const type = modeToInputType[mode] || "date";
|
||||
// For time inputs, min/max must be in HH:mm format, not date format
|
||||
const formatTimeMinMax = (val: string | undefined): string | undefined => {
|
||||
if (!val) return undefined;
|
||||
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
|
||||
// otherwise it's not a valid time min/max — return undefined
|
||||
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
|
||||
if (val.includes(":")) return val.substring(0, 5);
|
||||
return undefined;
|
||||
};
|
||||
const minProp =
|
||||
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
|
||||
const maxProp =
|
||||
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
lang="cs"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="admin-form-input"
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
min={minProp}
|
||||
max={maxProp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminDatePickerProps {
|
||||
mode?: "date" | "month" | "time";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminDatePicker({
|
||||
mode = "date",
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: AdminDatePickerProps) {
|
||||
const useNative = isTouchDevice();
|
||||
|
||||
if (useNative) {
|
||||
return (
|
||||
<NativeInput
|
||||
mode={mode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const toDate = (val: string | null | undefined): Date | null => {
|
||||
if (!val) return null;
|
||||
try {
|
||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
||||
if (mode === "time") {
|
||||
const [h, m] = val.split(":");
|
||||
const d = new Date();
|
||||
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
|
||||
return d;
|
||||
}
|
||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleChange = (date: Date | null) => {
|
||||
if (!date) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
if (mode === "date") onChange(format(date, "yyyy-MM-dd"));
|
||||
else if (mode === "time") onChange(format(date, "HH:mm"));
|
||||
else if (mode === "month") onChange(format(date, "yyyy-MM"));
|
||||
};
|
||||
|
||||
const parseMinMax = (val: string | undefined): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const customInput = (
|
||||
<CustomInput
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const commonProps = {
|
||||
selected: toDate(value),
|
||||
onChange: handleChange,
|
||||
locale: "cs",
|
||||
customInput,
|
||||
placeholderText: placeholder,
|
||||
minDate: parseMinMax(minDate),
|
||||
maxDate: parseMinMax(maxDate),
|
||||
popperPlacement: "bottom-start" as const,
|
||||
portalId: "datepicker-portal",
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (mode === "time") {
|
||||
return (
|
||||
<DatePicker
|
||||
{...commonProps}
|
||||
showTimeSelect
|
||||
showTimeSelectOnly
|
||||
timeIntervals={5}
|
||||
timeCaption="Čas"
|
||||
dateFormat="HH:mm"
|
||||
timeFormat="HH:mm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "month") {
|
||||
return (
|
||||
<DatePicker {...commonProps} showMonthYearPicker dateFormat="MM/yyyy" />
|
||||
);
|
||||
}
|
||||
|
||||
return <DatePicker {...commonProps} dateFormat="dd.MM.yyyy" />;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { setLogoutAlert } from "../utils/api";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ShortcutsHelp from "./ShortcutsHelp";
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Session is managed by AuthProvider (initial check + proactive refresh via setTimeout).
|
||||
// Do not call checkSession on route changes — concurrent refresh calls with token rotation
|
||||
// would invalidate each other and kick the user out.
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setLoggingOut(true);
|
||||
setSidebarOpen(false);
|
||||
setLogoutAlert();
|
||||
setTimeout(() => logout(), 400);
|
||||
}, [logout]);
|
||||
|
||||
useModalLock(sidebarOpen);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<div className="admin-loading" style={{ width: "100%" }}>
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// If 2FA is required but user hasn't enabled it, redirect to dashboard (where setup lives)
|
||||
const needs2FASetup = user?.require2FA && !user?.totpEnabled;
|
||||
if (needs2FASetup && location.pathname !== "/") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="admin-layout"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={
|
||||
loggingOut
|
||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||
: { scale: 1, opacity: 1, filter: "none" }
|
||||
}
|
||||
transition={{ duration: loggingOut ? 0.4 : 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<div className="admin-main">
|
||||
<header className="admin-header">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="admin-menu-btn"
|
||||
aria-label="Otevřít menu"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="admin-header-theme-btn"
|
||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
>
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="admin-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<ShortcutsHelp />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +1,29 @@
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import MuiAlert from "@mui/material/Alert";
|
||||
import { useAlertState } from "../context/AlertContext";
|
||||
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
success: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AlertContainer() {
|
||||
const { alerts, removeAlert } = useAlertState();
|
||||
|
||||
return (
|
||||
<div className="admin-alert-container" role="status" aria-live="polite">
|
||||
<AnimatePresence>
|
||||
{alerts.map((alert) => (
|
||||
<motion.div
|
||||
<>
|
||||
{alerts.map((alert, index) => (
|
||||
<Snackbar
|
||||
key={alert.id}
|
||||
className={`admin-toast admin-toast-${alert.type}`}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
open
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
sx={{ bottom: { xs: 16 + index * 72 } }}
|
||||
>
|
||||
<span className="admin-toast-icon">{icons[alert.type]}</span>
|
||||
<span className="admin-toast-message">{alert.message}</span>
|
||||
<button
|
||||
className="admin-toast-close"
|
||||
onClick={() => removeAlert(alert.id)}
|
||||
aria-label="Zavřít"
|
||||
<MuiAlert
|
||||
severity={alert.type}
|
||||
onClose={() => removeAlert(alert.id)}
|
||||
variant="filled"
|
||||
sx={{ width: "100%", minWidth: 280 }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
{alert.message}
|
||||
</MuiAlert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
formatDate,
|
||||
formatDatetime,
|
||||
@@ -6,8 +8,8 @@ import {
|
||||
calculateWorkMinutes,
|
||||
formatMinutes,
|
||||
getLeaveTypeName,
|
||||
getLeaveTypeBadgeClass,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
|
||||
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
||||
|
||||
interface AttendanceRecord extends HookAttendanceRecord {
|
||||
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
|
||||
onDelete: (record: AttendanceRecord) => void;
|
||||
}
|
||||
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||
* sick→error(red), unpaid→default(gray). */
|
||||
function leaveTypeChipColor(
|
||||
type: string,
|
||||
): "default" | "success" | "error" | "warning" | "info" {
|
||||
switch (type) {
|
||||
case "vacation":
|
||||
return "info";
|
||||
case "sick":
|
||||
return "error";
|
||||
case "unpaid":
|
||||
return "default";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
function formatBreak(record: AttendanceRecord): string {
|
||||
if (record.break_start && record.break_end) {
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||
@@ -36,9 +88,7 @@ function formatBreak(record: AttendanceRecord): string {
|
||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
if (record.project_logs && record.project_logs.length > 0) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||
{record.project_logs.map((log, i) => {
|
||||
let h: number,
|
||||
m: number,
|
||||
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span
|
||||
<StatusChip
|
||||
key={log.id ?? i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`}{" "}
|
||||
{durationValid
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} ${
|
||||
durationValid
|
||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||
: "—"}
|
||||
</span>
|
||||
: "—"
|
||||
}`}
|
||||
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return (
|
||||
<span
|
||||
className="admin-badge admin-badge-wrap"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{record.project_name}
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={record.project_name}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
height: "auto",
|
||||
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: AttendanceShiftTableProps) {
|
||||
if (records.length === 0) {
|
||||
const columns: DataColumn<AttendanceRecord>[] = [
|
||||
{
|
||||
key: "date",
|
||||
header: "Datum",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => formatDate(record.shift_date),
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
header: "Zaměstnanec",
|
||||
width: "14%",
|
||||
render: (record) => record.user_name,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "10%",
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
return (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
<StatusChip
|
||||
color={leaveTypeChipColor(leaveType)}
|
||||
label={getLeaveTypeName(leaveType)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekt</th>
|
||||
<th>GPS</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "arrival",
|
||||
header: "Příchod",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "break",
|
||||
header: "Pauza",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatBreak(record);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "departure",
|
||||
header: "Odchod",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.departure_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "hours",
|
||||
header: "Hodiny",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
||||
60
|
||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
|
||||
: calculateWorkMinutes({
|
||||
...record,
|
||||
notes: record.notes ?? undefined,
|
||||
});
|
||||
return workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "project",
|
||||
header: "Projekt",
|
||||
width: "13%",
|
||||
render: (record) => renderProjectCell(record),
|
||||
},
|
||||
{
|
||||
key: "gps",
|
||||
header: "GPS",
|
||||
width: "5%",
|
||||
render: (record) => {
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
||||
<td>{record.user_name}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreak(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td>
|
||||
{hasLocation ? (
|
||||
<Link
|
||||
return hasLocation ? (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/attendance/location/${record.id}`}
|
||||
className="attendance-gps-link"
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
sx={{ textDecoration: "none" }}
|
||||
>
|
||||
<span aria-hidden="true">{"📍"}</span>
|
||||
</Link>
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "9%",
|
||||
render: (record) => (
|
||||
<Box
|
||||
component="span"
|
||||
title={record.notes || ""}
|
||||
sx={{
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={record.notes || ""}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "8%",
|
||||
align: "right",
|
||||
render: (record) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEdit(record)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => onDelete(record)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable<AttendanceRecord>
|
||||
columns={columns}
|
||||
rows={records}
|
||||
rowKey={(record) => record.id}
|
||||
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import FormModal from "./FormModal";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
|
||||
|
||||
interface BulkAttendanceForm {
|
||||
month: string;
|
||||
@@ -39,127 +40,136 @@ export default function BulkAttendanceModal({
|
||||
toggleAllUsers,
|
||||
}: BulkAttendanceModalProps) {
|
||||
return (
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Vyplnit docházku za měsíc"
|
||||
size="lg"
|
||||
maxWidth="lg"
|
||||
loading={submitting}
|
||||
onSubmit={onSubmit}
|
||||
submitLabel="Vyplnit měsíc"
|
||||
submitText="Vyplnit měsíc"
|
||||
submitDisabled={form.user_ids.length === 0}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
<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čí.
|
||||
</p>
|
||||
</Typography>
|
||||
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Měsíc</label>
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
<Field label="Měsíc">
|
||||
<MonthField
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Zaměstnanci
|
||||
<button
|
||||
</Typography>
|
||||
<Typography
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
variant="caption"
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent-color)",
|
||||
p: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem",
|
||||
maxHeight: "200px",
|
||||
gap: 0.25,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius-sm)",
|
||||
border: "1px solid var(--border-color)",
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<label key={user.id} className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
<CheckboxField
|
||||
key={user.id}
|
||||
label={user.name}
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<small className="admin-form-hint">
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</small>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Odchod">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Konec pauzy">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import useReducedMotion from "../hooks/useReducedMotion";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
|
||||
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();
|
||||
// Lock body scroll while open (ref-counted, nesting-safe) — matches FormModal.
|
||||
useModalLock(isOpen);
|
||||
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={() => !loading && onClose()}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-confirm-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration, ease }}
|
||||
>
|
||||
<div className="admin-modal-body admin-confirm-content">
|
||||
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
||||
<ConfirmIcon type={type} />
|
||||
</div>
|
||||
<h2 id="confirm-modal-title" className="admin-confirm-title">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="admin-confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Zpracování..." : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Button } from "../ui";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -22,19 +25,25 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
>
|
||||
<h2>Něco se pokazilo</h2>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">Něco se pokazilo</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{this.state.error?.message}
|
||||
</Typography>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Obnovit stránku
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import { EmptyState, Button } from "../ui";
|
||||
|
||||
export default function Forbidden() {
|
||||
return (
|
||||
<div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<h2>403</h2>
|
||||
<p>Nemáte oprávnění pro přístup k této stránce.</p>
|
||||
<Link to="/" className="admin-btn admin-btn-primary">
|
||||
<EmptyState
|
||||
title="403"
|
||||
description="Nemáte oprávnění pro přístup k této stránce."
|
||||
action={
|
||||
<Button component={Link} to="/">
|
||||
Zpět na Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,201 +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;
|
||||
/** Optional muted line shown under the title in the header. */
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode; // modal body (form fields)
|
||||
submitLabel?: string; // primary button text
|
||||
cancelLabel?: string; // cancel button text
|
||||
loading?: boolean;
|
||||
/** Disable the primary (submit) button — e.g. until the form is valid.
|
||||
* Keeps the button visibly greyed out instead of clickable-but-inert. */
|
||||
submitDisabled?: 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,
|
||||
subtitle,
|
||||
children,
|
||||
submitLabel = "Uložit",
|
||||
cancelLabel = "Zrušit",
|
||||
loading = false,
|
||||
submitDisabled = 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">
|
||||
<div className="admin-modal-heading">
|
||||
<h2 id={titleId} className="admin-modal-title">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && <p className="admin-modal-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
{!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 || submitDisabled}
|
||||
>
|
||||
{loading ? "Zpracování..." : submitLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import FormModal from "./FormModal";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Modal, Button, TextField, Field } from "../ui";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
interface ConfirmationItem {
|
||||
@@ -35,6 +40,8 @@ export default function OrderConfirmationModal({
|
||||
applyVat,
|
||||
}: OrderConfirmationModalProps) {
|
||||
const alert = useAlert();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||
const [lang, setLang] = useState<string>("cs");
|
||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
||||
@@ -103,119 +110,205 @@ export default function OrderConfirmationModal({
|
||||
}, [defaultVatRate]);
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Potvrzení objednávky ${orderNumber}`}
|
||||
size={step === "edit" ? "lg" : "md"}
|
||||
maxWidth={step === "edit" ? "lg" : "md"}
|
||||
loading={loading}
|
||||
hideFooter
|
||||
submitText={
|
||||
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
|
||||
}
|
||||
submitDisabled={step === "edit" && items.length === 0}
|
||||
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
|
||||
>
|
||||
{step === "choose" ? (
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Box>
|
||||
<Field label="Jazyk dokumentu">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={lang === "cs" ? "contained" : "outlined"}
|
||||
color={lang === "cs" ? "primary" : "inherit"}
|
||||
onClick={() => setLang("cs")}
|
||||
className={
|
||||
lang === "cs"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Čeština
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={lang === "en" ? "contained" : "outlined"}
|
||||
color={lang === "en" ? "primary" : "inherit"}
|
||||
onClick={() => setLang("en")}
|
||||
className={
|
||||
lang === "en"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">DPH</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Field label="DPH">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={applyVatState ? "contained" : "outlined"}
|
||||
color={applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(true)}
|
||||
className={
|
||||
applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
S DPH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={!applyVatState ? "contained" : "outlined"}
|
||||
color={!applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(false)}
|
||||
className={
|
||||
!applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Bez DPH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Obsah potvrzení</label>
|
||||
<p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
|
||||
<Field label="Obsah potvrzení">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUseExisting}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary w-full"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Použít položky z objednávky"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setItems(initialItems.length > 0 ? initialItems : []);
|
||||
setStep("edit");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-secondary w-full"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Upravit položky
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
</Button>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => setStep("choose")}
|
||||
disabled={loading}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Zpět
|
||||
</Button>
|
||||
</Box>
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||
>
|
||||
Položka {i + 1}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(i)}
|
||||
title="Odstranit"
|
||||
aria-label="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Popis"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(i, "unit", e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Cena"
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit_price", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="%DPH"
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<div className="admin-form">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<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>
|
||||
@@ -230,39 +323,40 @@ export default function OrderConfirmationModal({
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
<TextField
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ minWidth: "200px" }}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "80px" }}
|
||||
step="0.001"
|
||||
sx={{ width: 90 }}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
<TextField
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(i, "unit", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px" }}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit", e.target.value)
|
||||
}
|
||||
sx={{ width: 70 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
@@ -272,28 +366,32 @@ export default function OrderConfirmationModal({
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "100px" }}
|
||||
step="0.01"
|
||||
sx={{ width: 110 }}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "70px" }}
|
||||
step="1"
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(i)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Odstranit"
|
||||
aria-label="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
@@ -306,47 +404,26 @@ export default function OrderConfirmationModal({
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={addItem}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat položku
|
||||
</button>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
interface PaginationProps {
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
} | null;
|
||||
onPageChange: (page: number) => void;
|
||||
onPerPageChange?: (perPage: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
pagination,
|
||||
onPageChange,
|
||||
onPerPageChange,
|
||||
}: PaginationProps) {
|
||||
if (!pagination || pagination.total_pages <= 1) return null;
|
||||
|
||||
const { page, total_pages, total } = pagination;
|
||||
|
||||
const getPages = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const delta = 2;
|
||||
for (let i = 1; i <= total_pages; i++) {
|
||||
if (
|
||||
i === 1 ||
|
||||
i === total_pages ||
|
||||
(i >= page - delta && i <= page + delta)
|
||||
) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== "...") {
|
||||
pages.push("...");
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-pagination"
|
||||
role="navigation"
|
||||
aria-label="Stránkování"
|
||||
>
|
||||
<div className="admin-pagination-info">{total} záznamů</div>
|
||||
<div className="admin-pagination-controls">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="admin-pagination-page"
|
||||
aria-label="Předchozí stránka"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{getPages().map((p, i) =>
|
||||
typeof p === "string" ? (
|
||||
<span key={`dots-${i}`} className="admin-pagination-ellipsis">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`admin-pagination-page ${p === page ? "active" : ""}`}
|
||||
aria-label={`Stránka ${p}`}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
disabled={page >= total_pages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="admin-pagination-page"
|
||||
aria-label="Další stránka"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{onPerPageChange && (
|
||||
<select
|
||||
value={pagination.per_page}
|
||||
onChange={(e) => onPerPageChange(Number(e.target.value))}
|
||||
className="admin-pagination-select"
|
||||
>
|
||||
{[10, 25, 50, 100].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} / stránka
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import FormModal from "./FormModal";
|
||||
import { useState, type ChangeEvent, type FocusEvent } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Modal, TextField, Button } from "../ui";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { PlanCategory } from "../lib/queries/plan";
|
||||
@@ -91,23 +92,44 @@ export default function PlanCategoriesModal({
|
||||
const busy =
|
||||
createCat.isPending || updateCat.isPending || deleteCat.isPending;
|
||||
|
||||
// Shared layout for a category row: color swatch | text input | actions.
|
||||
const rowSx = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
py: 1,
|
||||
} as const;
|
||||
|
||||
// Native color picker, styled to match the row (was the .plan-cat-color rule).
|
||||
const colorInputSx = {
|
||||
width: 34,
|
||||
height: 34,
|
||||
p: 0,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: "6px",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
flex: "0 0 auto",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
title="Správa kategorií"
|
||||
hideFooter
|
||||
size="md"
|
||||
submitText="Zavřít"
|
||||
hideCancel
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="plan-cat-manager">
|
||||
<Box>
|
||||
{categories.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
|
||||
>
|
||||
<input
|
||||
<Box key={c.id} sx={{ ...rowSx, opacity: c.is_active ? 1 : 0.55 }}>
|
||||
<Box
|
||||
component="input"
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
sx={colorInputSx}
|
||||
// Uncontrolled + patch on blur (like the rename input): the
|
||||
// native color picker fires onChange on every drag tick, which
|
||||
// would spam PATCH requests and the audit log. onBlur commits
|
||||
@@ -115,96 +137,102 @@ export default function PlanCategoriesModal({
|
||||
defaultValue={c.color}
|
||||
key={c.color}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
if (e.target.value !== c.color) {
|
||||
patch(c.id, { color: e.target.value });
|
||||
}
|
||||
}}
|
||||
aria-label={`Barva – ${c.label}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
<TextField
|
||||
defaultValue={c.label}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== c.label) patch(c.id, { label: v });
|
||||
}}
|
||||
aria-label={`Název – ${c.label}`}
|
||||
inputProps={{ "aria-label": `Název – ${c.label}` }}
|
||||
/>
|
||||
{confirmDeleteId === c.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => handleDelete(c.id)}
|
||||
>
|
||||
Opravdu smazat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
||||
>
|
||||
{c.is_active ? "Skrýt" : "Obnovit"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(c.id)}
|
||||
aria-label={`Smazat – ${c.label}`}
|
||||
title="Smazat kategorii"
|
||||
>
|
||||
Smazat
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<div className="plan-cat-row plan-cat-row--new">
|
||||
<input
|
||||
<Box
|
||||
sx={{ ...rowSx, mt: 1, pt: 2, borderTop: 1, borderColor: "divider" }}
|
||||
>
|
||||
<Box
|
||||
component="input"
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
sx={colorInputSx}
|
||||
value={newColor}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setNewColor(e.target.value)
|
||||
}
|
||||
aria-label="Barva nové kategorie"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
<TextField
|
||||
placeholder="Nová kategorie…"
|
||||
value={newLabel}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Přidat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import FormModal from "./FormModal";
|
||||
import FormField from "./FormField";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
Select,
|
||||
DateField,
|
||||
CheckboxField,
|
||||
Button,
|
||||
Card,
|
||||
} from "../ui";
|
||||
import useReducedMotion from "../hooks/useReducedMotion";
|
||||
import {
|
||||
ResolvedCell,
|
||||
@@ -77,6 +86,26 @@ interface Props {
|
||||
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) {
|
||||
const { open, mode, onClose } = props;
|
||||
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 (
|
||||
<>
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||
loading={submitting}
|
||||
footerLeft={
|
||||
mode.kind !== "create" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
{mode.kind !== "create" && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={TrashIcon}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
Smazat
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<FormField label="Datum od">
|
||||
<AdminDatePicker
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Field label="Datum od">
|
||||
<DateField
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
disabled={isOverride}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
{!isOverride && (
|
||||
<FormField label="Rozsah dnů">
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Field label="Rozsah dnů">
|
||||
<CheckboxField
|
||||
label="Více dní"
|
||||
checked={isRange}
|
||||
onChange={(e) => setIsRange(e.target.checked)}
|
||||
onChange={setIsRange}
|
||||
/>
|
||||
<span>Více dní</span>
|
||||
</label>
|
||||
</FormField>
|
||||
</Field>
|
||||
)}
|
||||
{!isOverride && isRange && (
|
||||
<FormField label="Datum do">
|
||||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
||||
</FormField>
|
||||
<Field label="Datum do">
|
||||
<DateField value={dateTo} onChange={setDateTo} />
|
||||
</Field>
|
||||
)}
|
||||
<FormField label="Projekt">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={projectId ?? ""}
|
||||
onChange={(e) =>
|
||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
>
|
||||
<option value="">— bez projektu —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Kategorie">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
<Field label="Projekt">
|
||||
<Select
|
||||
value={projectId === null ? "" : String(projectId)}
|
||||
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kategorie">
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{/* If the row's current category was retired/hidden, still show it
|
||||
(marked) so the select reflects reality instead of silently
|
||||
snapping to the first active option. The server only re-checks
|
||||
the category if it changes, so keeping it saves fine. */}
|
||||
{category && !activeCategories.some((c) => c.key === category) && (
|
||||
<option value={category}>
|
||||
{planCategoryLabel(
|
||||
onChange={(val) => setCategory(val)}
|
||||
options={[
|
||||
...(showRetiredCategory
|
||||
? [
|
||||
{
|
||||
value: category,
|
||||
label: `${planCategoryLabel(
|
||||
category,
|
||||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
||||
)}{" "}
|
||||
(skrytá)
|
||||
</option>
|
||||
)}
|
||||
{activeCategories.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Text poznámky (volitelný)">
|
||||
<textarea
|
||||
className="admin-form-textarea"
|
||||
Object.fromEntries(
|
||||
props.categories.map((x) => [x.key, x]),
|
||||
),
|
||||
)} (skrytá)`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...activeCategories.map((c) => ({
|
||||
value: c.key,
|
||||
label: c.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Text poznámky (volitelný)">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
inputProps={{ maxLength: 500 }}
|
||||
/>
|
||||
</FormField>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDelete}
|
||||
title="Smazat záznam plánu?"
|
||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||
confirmText="Smazat"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={submitting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
@@ -376,26 +388,26 @@ function DayInRangeModal(
|
||||
];
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen
|
||||
title="Den je součástí rozsahu"
|
||||
onClose={onClose}
|
||||
hideFooter
|
||||
size="md"
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít — ponechat rozsah beze změny"
|
||||
maxWidth="md"
|
||||
>
|
||||
<p className="plan-modal-intro">
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||
<strong>
|
||||
{mode.range.date_from} – {mode.range.date_to}
|
||||
</strong>
|
||||
. Vyberte, co chcete udělat:
|
||||
</p>
|
||||
<ul className="plan-choices">
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<AnimatePresence>
|
||||
{choices.map((c, i) => (
|
||||
<motion.li
|
||||
<motion.div
|
||||
key={c.key}
|
||||
className={`plan-choice plan-choice-${c.tone}`}
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||
@@ -405,35 +417,42 @@ function DayInRangeModal(
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="plan-choice-body">
|
||||
<div className="plan-choice-title">{c.title}</div>
|
||||
<div className="plan-choice-desc">{c.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
c.tone === "primary"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor:
|
||||
c.tone === "primary" ? "primary.main" : "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography sx={{ fontWeight: 600 }}>{c.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{c.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant={c.tone === "primary" ? "contained" : "outlined"}
|
||||
color={c.tone === "primary" ? "primary" : "inherit"}
|
||||
onClick={c.onClick}
|
||||
>
|
||||
{c.cta}
|
||||
</button>
|
||||
</motion.li>
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zavřít — ponechat rozsah beze změny
|
||||
</button>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -454,29 +473,36 @@ function ViewModal(props: Props) {
|
||||
: fallback.name
|
||||
: null);
|
||||
return (
|
||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
||||
<p>
|
||||
<Modal
|
||||
isOpen
|
||||
title="Detail plánu"
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít"
|
||||
hideCancel
|
||||
>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||
</p>
|
||||
<p>
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Kategorie:</strong>{" "}
|
||||
{planCategoryLabel(
|
||||
c.category,
|
||||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||
</p>
|
||||
<p>
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Text:</strong> {c.note || "—"}
|
||||
</p>
|
||||
</Typography>
|
||||
{c.rangeFrom && c.rangeTo && (
|
||||
<p>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||
{formatDate(c.rangeTo)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
</FormModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import Box from "@mui/material/Box";
|
||||
import {
|
||||
GridData,
|
||||
ResolvedCell,
|
||||
@@ -9,6 +11,357 @@ import {
|
||||
} from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
import PlanRangeChips from "./PlanRangeChips";
|
||||
import { LoadingState } from "../ui";
|
||||
import { fonts } from "../theme";
|
||||
|
||||
/**
|
||||
* The planner surface, fully MUI/theme-driven (replaces the bespoke plan.css
|
||||
* grid styling). All colors come from the theme palette via `theme.vars`, so it
|
||||
* adapts to light/dark automatically; category colors come from the DB and are
|
||||
* injected per cell as the `--cat-color` CSS variable. Class names from the JSX
|
||||
* below are styled here as scoped nested selectors.
|
||||
*/
|
||||
const PlanGridRoot = styled(Box)(({ theme }) => ({
|
||||
position: "relative",
|
||||
overflow: "auto",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
overscrollBehavior: "contain",
|
||||
background: theme.vars!.palette.background.default,
|
||||
border: `1px solid ${theme.vars!.palette.divider}`,
|
||||
borderRadius: 14,
|
||||
boxShadow: theme.shadows[2],
|
||||
maxHeight: "calc(100dvh - 240px)",
|
||||
isolation: "isolate",
|
||||
|
||||
"& .plan-grid": {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
borderCollapse: "separate",
|
||||
borderSpacing: 0,
|
||||
width: "100%",
|
||||
fontSize: "12.5px",
|
||||
color: theme.vars!.palette.text.primary,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
"& col.plan-grid-date-col": { width: 88 },
|
||||
"& tbody tr td + td": {
|
||||
borderLeft: `1px solid ${theme.vars!.palette.divider}`,
|
||||
},
|
||||
|
||||
// Sticky header
|
||||
"& thead th": {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 3,
|
||||
padding: "14px 12px 12px",
|
||||
background: theme.vars!.palette.background.paper,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& thead th.plan-grid-date-col": {
|
||||
left: 0,
|
||||
zIndex: 4,
|
||||
minWidth: 88,
|
||||
padding: "12px 10px 10px 14px",
|
||||
fontSize: "10.5px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: theme.vars!.palette.text.secondary,
|
||||
},
|
||||
"& .plan-person-head": {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
minWidth: 0,
|
||||
},
|
||||
"& .plan-person-dot": {
|
||||
flexShrink: 0,
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
background: theme.vars!.palette.action.disabled,
|
||||
},
|
||||
"& .plan-person-name": {
|
||||
display: "inline-flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0,
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
"& .plan-person-name strong": {
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 180,
|
||||
},
|
||||
"& .plan-person-name small": {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
fontFamily: fonts.mono,
|
||||
marginTop: 1,
|
||||
},
|
||||
|
||||
// Sticky date column
|
||||
"& tbody td.plan-grid-date-col": {
|
||||
position: "sticky",
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
background: theme.vars!.palette.background.paper,
|
||||
fontWeight: 500,
|
||||
color: theme.vars!.palette.text.secondary,
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 88,
|
||||
padding: "9px 10px 9px 14px",
|
||||
borderRight: `1px solid ${theme.vars!.palette.divider}`,
|
||||
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||
},
|
||||
"& .plan-date-stamp": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
lineHeight: 1.1,
|
||||
gap: 1,
|
||||
},
|
||||
"& .plan-date-daynum": {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
lineHeight: 1.05,
|
||||
},
|
||||
"& .plan-date-dow": {
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
|
||||
// Today
|
||||
"& tbody tr.is-today td.plan-grid-date-col": {
|
||||
background: theme.vars!.palette.background.paper,
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
width: 3,
|
||||
borderRadius: "0 2px 2px 0",
|
||||
background: theme.vars!.palette.primary.main,
|
||||
boxShadow: `0 0 14px rgba(${theme.vars!.palette.primary.mainChannel} / 0.3)`,
|
||||
},
|
||||
"& .plan-date-daynum": { color: theme.vars!.palette.primary.main },
|
||||
},
|
||||
"& tbody tr.is-today td:not(.plan-grid-date-col)": {
|
||||
background: `rgba(${theme.vars!.palette.primary.mainChannel} / 0.06)`,
|
||||
},
|
||||
|
||||
// Body cells
|
||||
"& tbody td": {
|
||||
padding: 0,
|
||||
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||
verticalAlign: "stretch",
|
||||
minWidth: 168,
|
||||
background: theme.vars!.palette.background.paper,
|
||||
},
|
||||
|
||||
// Weekend
|
||||
"& tr.plan-grid-weekend td": {
|
||||
background: `rgba(${theme.vars!.palette.text.primaryChannel} / 0.03)`,
|
||||
},
|
||||
"& tr.plan-grid-weekend td.plan-grid-date-col": {
|
||||
background: theme.vars!.palette.background.paper,
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
"& .plan-date-dow": { color: theme.vars!.palette.warning.main },
|
||||
"& .plan-date-daynum": { color: theme.vars!.palette.text.secondary },
|
||||
},
|
||||
|
||||
// The cell button + marker tape
|
||||
"& .plan-cell": {
|
||||
position: "relative",
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 60,
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
borderRadius: 0,
|
||||
padding: "9px 11px 10px 16px",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
transition: "background 150ms ease, transform 150ms ease",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 3,
|
||||
borderRadius: 2,
|
||||
background: `var(--cat-color, ${theme.vars!.palette.divider})`,
|
||||
transition:
|
||||
"background 150ms ease, box-shadow 150ms ease, transform 150ms ease",
|
||||
},
|
||||
"&:hover": { background: theme.vars!.palette.action.hover },
|
||||
"&:hover::before": {
|
||||
transform: "scaleX(1.4)",
|
||||
boxShadow: "0 0 12px currentColor",
|
||||
},
|
||||
"&:active": { transform: "translateY(0.5px)" },
|
||||
"&:focus-visible": {
|
||||
outline: "none",
|
||||
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, inset 0 0 0 4px ${theme.vars!.palette.background.paper}`,
|
||||
},
|
||||
},
|
||||
"& .plan-cell--readonly": {
|
||||
cursor: "default",
|
||||
"&:hover": { background: "transparent" },
|
||||
"&:hover::before": { transform: "none", boxShadow: "none" },
|
||||
},
|
||||
// A read-only cell that HAS data is still clickable (view mode).
|
||||
"& .plan-cell--readonly:not(.plan-cell--empty):not(.plan-cell--past)": {
|
||||
cursor: "pointer",
|
||||
"&:hover": { background: theme.vars!.palette.action.hover },
|
||||
"&:hover::before": {
|
||||
transform: "scaleX(1.4)",
|
||||
boxShadow: "0 0 12px currentColor",
|
||||
},
|
||||
},
|
||||
"& .plan-cell--past": {
|
||||
cursor: "default",
|
||||
opacity: 0.55,
|
||||
"&:hover": { background: "transparent" },
|
||||
"&:hover::before": { transform: "none", boxShadow: "none" },
|
||||
},
|
||||
// Empty cells: dashed tape + a "+" hover hint.
|
||||
"& .plan-cell--empty::before": {
|
||||
background: "transparent",
|
||||
border: `1px dashed ${theme.vars!.palette.divider}`,
|
||||
width: 2,
|
||||
left: 5,
|
||||
},
|
||||
"& .plan-cell--empty:hover::before": {
|
||||
background: theme.vars!.palette.primary.main,
|
||||
borderColor: theme.vars!.palette.primary.main,
|
||||
transform: "scaleX(1.6)",
|
||||
},
|
||||
"& .plan-cell--empty::after": {
|
||||
content: '"+"',
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
opacity: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 300,
|
||||
pointerEvents: "none",
|
||||
transition: "opacity 150ms ease",
|
||||
},
|
||||
"& .plan-cell--empty:hover::after": {
|
||||
opacity: 0.7,
|
||||
color: theme.vars!.palette.primary.main,
|
||||
},
|
||||
"& .plan-cell--empty.plan-cell--readonly::after": { content: "none" },
|
||||
|
||||
// One-shot mutation pulse
|
||||
"@keyframes plan-cell-pulse": {
|
||||
"0%": {
|
||||
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 0 rgba(${theme.vars!.palette.primary.mainChannel} / 0.4)`,
|
||||
},
|
||||
"60%": {
|
||||
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 6px transparent`,
|
||||
},
|
||||
"100%": { boxShadow: "inset 0 0 0 2px transparent, 0 0 0 0 transparent" },
|
||||
},
|
||||
"& .plan-cell--pulse": { animation: "plan-cell-pulse 600ms ease-out" },
|
||||
|
||||
// Chips inside a cell
|
||||
"& .plan-chip-block": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
},
|
||||
"& .plan-chip-line": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
flexWrap: "wrap",
|
||||
minWidth: 0,
|
||||
},
|
||||
"& .plan-chip": {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
borderRadius: 4,
|
||||
padding: "2px 7px",
|
||||
fontSize: "10.5px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: `var(--cat-color, ${theme.vars!.palette.text.primary})`,
|
||||
background: `color-mix(in srgb, var(--cat-color, transparent) 12%, ${theme.vars!.palette.background.paper})`,
|
||||
border: `1px solid color-mix(in srgb, var(--cat-color, ${theme.vars!.palette.divider}) 35%, transparent)`,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& .plan-chip-project": {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: "12.5px",
|
||||
fontWeight: 600,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "100%",
|
||||
},
|
||||
"& .plan-cell-note": {
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
marginTop: 1,
|
||||
fontSize: "11.5px",
|
||||
color: theme.vars!.palette.text.secondary,
|
||||
lineHeight: 1.35,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
|
||||
"@media (max-width: 480px)": {
|
||||
"& col.plan-grid-date-col": { width: 72 },
|
||||
"& tbody td": { minWidth: 132 },
|
||||
"& .plan-cell": { minHeight: 56 },
|
||||
},
|
||||
// Touch: show a faint persistent "+" on empty editable cells.
|
||||
"@media (hover: none)": {
|
||||
"& .plan-cell--empty:not(.plan-cell--readonly)::after": {
|
||||
opacity: 0.26,
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
},
|
||||
},
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
"& .plan-cell, & .plan-cell::before, & .plan-cell::after": {
|
||||
transition: "none",
|
||||
},
|
||||
"& .plan-cell--pulse": { animation: "none" },
|
||||
},
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
data: GridData | undefined;
|
||||
@@ -107,12 +460,7 @@ export default function PlanGrid({
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
if (!data) return <LoadingState />;
|
||||
const days = eachDay(data.date_from, data.date_to);
|
||||
const users = data.users;
|
||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||
@@ -124,7 +472,7 @@ export default function PlanGrid({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
|
||||
<PlanGridRoot data-pulse={pulseAttr}>
|
||||
<table className="plan-grid">
|
||||
{/* The colgroup is what actually controls column width in a
|
||||
table — `min-width` on `<th>`/`<td>` is just a floor that
|
||||
@@ -255,6 +603,6 @@ export default function PlanGrid({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PlanGridRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { projectFilesOptions } from "../lib/queries/projects";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
DataTable,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
@@ -122,7 +135,7 @@ function getFileIcon(type: string, extension?: string) {
|
||||
};
|
||||
|
||||
const cfg = iconMap[ext] || {
|
||||
color: "var(--text-muted)",
|
||||
color: "var(--mui-palette-text-secondary)",
|
||||
path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z",
|
||||
};
|
||||
|
||||
@@ -143,7 +156,16 @@ function getFileIcon(type: string, extension?: string) {
|
||||
|
||||
function SymlinkBadge({ target }: { target?: string }) {
|
||||
return (
|
||||
<span className="fm-symlink-badge" title={target || "Odkaz"}>
|
||||
<Box
|
||||
component="span"
|
||||
title={target || "Odkaz"}
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
ml: 0.5,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
@@ -155,7 +177,7 @@ function SymlinkBadge({ target }: { target?: string }) {
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,26 +190,53 @@ function FileNameCell({
|
||||
}) {
|
||||
if (item.type === "folder") {
|
||||
return (
|
||||
<span className="fm-name-cell">
|
||||
<button
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
className="fm-folder-link"
|
||||
onClick={() => onFolderClick(item.name)}
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
font: "inherit",
|
||||
color: "primary.main",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</Box>
|
||||
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||
{item.item_count !== undefined && (
|
||||
<span className="fm-item-count">{item.item_count}</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: ".7rem",
|
||||
color: "text.secondary",
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 1,
|
||||
px: 0.75,
|
||||
}}
|
||||
>
|
||||
{item.item_count}
|
||||
</Box>
|
||||
)}
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="fm-name-cell">
|
||||
<span className="fm-file-name">{item.name}</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<Box component="span">{item.name}</Box>
|
||||
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -469,257 +518,58 @@ export default function ProjectFileManager({
|
||||
};
|
||||
|
||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
<div className="fm-empty">
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Soubory
|
||||
</Typography>
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--text-tertiary)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={errorMessage}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="fm-toolbar">
|
||||
<div className="fm-breadcrumb">
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<span key={i} className="fm-breadcrumb-segment">
|
||||
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? "active" : ""}`}
|
||||
onClick={() => handleBreadcrumbClick(i)}
|
||||
>
|
||||
{i === 0 ? projectNumber : segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{fullPath && (
|
||||
<span className="fm-full-path" title={fullPath}>
|
||||
{fullPath}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="fm-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => {
|
||||
setNewFolderMode(!newFolderMode);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
Složka
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Nahrávání...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Nahrát
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New folder input */}
|
||||
{newFolderMode && (
|
||||
<div className="fm-new-folder">
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Název složky..."
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateFolder();
|
||||
if (e.key === "Escape") {
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: "12px", padding: "6px 10px" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={creatingFolder || !newFolderName.trim()}
|
||||
>
|
||||
{creatingFolder ? (
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
) : (
|
||||
"Vytvořit"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => {
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone + table */}
|
||||
<div
|
||||
className={`fm-content ${dragOver ? "fm-drag-over" : ""}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="fm-dropzone-overlay">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span>Přetáhněte soubory sem</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && !filesLoading ? (
|
||||
<div className="fm-empty">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--text-tertiary)"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>
|
||||
{hasNasFolder
|
||||
? "Složka je prázdná"
|
||||
: "Složka projektu zatím neexistuje"}
|
||||
</span>
|
||||
{canManage && !hasNasFolder && (
|
||||
<span style={{ fontSize: "11px" }}>
|
||||
Nahrání souboru ji automaticky vytvoří
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "30px" }}></th>
|
||||
<th>Název</th>
|
||||
<th style={{ width: "90px" }}>Velikost</th>
|
||||
<th style={{ width: "120px" }}>Změněno</th>
|
||||
{canManage && (
|
||||
<th style={{ width: "100px", textAlign: "right" }}>
|
||||
Akce
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td style={{ textAlign: "center" }}>
|
||||
const columns: DataColumn<FileItem>[] = [
|
||||
{
|
||||
key: "icon",
|
||||
header: "",
|
||||
width: 30,
|
||||
align: "left",
|
||||
render: (item) => (
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
{getFileIcon(item.type, item.extension)}
|
||||
</td>
|
||||
<td>
|
||||
{renamingItem === item.name ? (
|
||||
<input
|
||||
type="text"
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
render: (item) =>
|
||||
renamingItem === item.name ? (
|
||||
<TextField
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "3px 8px",
|
||||
maxWidth: "300px",
|
||||
}}
|
||||
autoFocus
|
||||
sx={{ maxWidth: 300 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -743,24 +593,49 @@ export default function ProjectFileManager({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FileNameCell
|
||||
item={item}
|
||||
onFolderClick={handleFolderClick}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="fm-meta">
|
||||
{item.type === "file" ? item.size_formatted : "\u2014"}
|
||||
</td>
|
||||
<td className="fm-meta">{item.modified || "\u2014"}</td>
|
||||
{canManage && (
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<div className="fm-actions">
|
||||
<FileNameCell item={item} onFolderClick={handleFolderClick} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "Velikost",
|
||||
width: 90,
|
||||
render: (item) => (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.type === "file" ? item.size_formatted : "—"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "modified",
|
||||
header: "Změněno",
|
||||
width: 120,
|
||||
render: (item) => (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.modified || "—"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: 100,
|
||||
align: "right" as const,
|
||||
render: (item: FileItem) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{item.type === "file" && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
<IconButton
|
||||
size="small"
|
||||
title="Stáhnout"
|
||||
aria-label="Stáhnout"
|
||||
onClick={() => handleDownload(item)}
|
||||
>
|
||||
<svg
|
||||
@@ -775,12 +650,12 @@ export default function ProjectFileManager({
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
<IconButton
|
||||
size="small"
|
||||
title="Přejmenovat"
|
||||
aria-label="Přejmenovat"
|
||||
onClick={() => startRename(item)}
|
||||
>
|
||||
<svg
|
||||
@@ -794,11 +669,12 @@ export default function ProjectFileManager({
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
<svg
|
||||
@@ -813,20 +689,286 @@ export default function ProjectFileManager({
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
<ConfirmModal
|
||||
return (
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Soubory
|
||||
</Typography>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<Box
|
||||
component="span"
|
||||
key={i}
|
||||
sx={{ display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
{i > 0 && (
|
||||
<Box component="span" sx={{ mx: 0.25, color: "text.disabled" }}>
|
||||
/
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => handleBreadcrumbClick(i)}
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
font: "inherit",
|
||||
cursor: "pointer",
|
||||
color:
|
||||
i === breadcrumb.length - 1
|
||||
? "text.primary"
|
||||
: "primary.main",
|
||||
fontWeight: i === breadcrumb.length - 1 ? 600 : 400,
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{i === 0 ? projectNumber : segment}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{fullPath && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
title={fullPath}
|
||||
sx={{
|
||||
maxWidth: 320,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{fullPath}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<Box sx={{ display: "flex", gap: 1, ml: "auto" }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setNewFolderMode(!newFolderMode);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
startIcon={
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Složka
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
startIcon={
|
||||
uploading ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
>
|
||||
{uploading ? "Nahrávání..." : "Nahrát"}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* New folder input */}
|
||||
{newFolderMode && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Název složky..."
|
||||
autoFocus
|
||||
sx={{ maxWidth: 320 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateFolder();
|
||||
if (e.key === "Escape") {
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={creatingFolder || !newFolderName.trim()}
|
||||
>
|
||||
{creatingFolder ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
"Vytvořit"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Drop zone + table */}
|
||||
<Box
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
sx={{
|
||||
position: "relative",
|
||||
borderRadius: 2,
|
||||
...(dragOver
|
||||
? {
|
||||
outline: "2px dashed",
|
||||
outlineColor: "primary.main",
|
||||
outlineOffset: -4,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{dragOver && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 1,
|
||||
bgcolor: "action.hover",
|
||||
color: "primary.main",
|
||||
borderRadius: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span>Přetáhněte soubory sem</span>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{items.length === 0 && !filesLoading ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
}
|
||||
title={
|
||||
hasNasFolder
|
||||
? "Složka je prázdná"
|
||||
: "Složka projektu zatím neexistuje"
|
||||
}
|
||||
description={
|
||||
canManage && !hasNasFolder
|
||||
? "Nahrání souboru ji automaticky vytvoří"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={items}
|
||||
rowKey={(item) => item.name}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
@@ -836,9 +978,9 @@ export default function ProjectFileManager({
|
||||
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import { RichEditorRoot } from "../ui/RichText";
|
||||
|
||||
const COLORS = [
|
||||
"#000000",
|
||||
@@ -104,8 +105,7 @@ export default function RichEditor({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-rich-editor"
|
||||
<RichEditorRoot
|
||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||
>
|
||||
<ReactQuill
|
||||
@@ -118,6 +118,6 @@ export default function RichEditor({
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</RichEditorRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import FormModal from "./FormModal";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Select,
|
||||
TextField,
|
||||
DateField,
|
||||
TimeField,
|
||||
Field,
|
||||
Alert,
|
||||
} from "../ui";
|
||||
import {
|
||||
calcFormWorkMinutes,
|
||||
calcProjectMinutesTotal,
|
||||
@@ -80,6 +91,19 @@ export interface ShiftFormModalProps {
|
||||
editingRecord: EditingRecord | null;
|
||||
}
|
||||
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ---------- ProjectTimeStatus ----------
|
||||
|
||||
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||
@@ -100,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||
|
||||
const isMatch = remaining === 0;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
marginBottom: "0.5rem",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.8rem",
|
||||
background: isMatch
|
||||
? "var(--success-bg, rgba(34,197,94,0.1))"
|
||||
: "var(--danger-bg, rgba(239,68,68,0.1))",
|
||||
color: isMatch
|
||||
? "var(--success-color, #16a34a)"
|
||||
: "var(--danger-color, #dc2626)",
|
||||
border: `1px solid ${
|
||||
isMatch
|
||||
? "var(--success-border, rgba(34,197,94,0.3))"
|
||||
: "var(--danger-border, rgba(239,68,68,0.3))"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
|
||||
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
|
||||
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
|
||||
{remaining < 0 ? "(překročeno)" : ""}
|
||||
</div>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Alert severity={isMatch ? "success" : "error"}>
|
||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
|
||||
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
|
||||
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
|
||||
{Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,65 +145,65 @@ function ProjectLogRow({
|
||||
onRemove,
|
||||
}: ProjectLogRowProps) {
|
||||
return (
|
||||
<div className="flex-row gap-2 mb-2">
|
||||
<select
|
||||
value={log.project_id}
|
||||
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
|
||||
className="admin-form-select"
|
||||
style={{ flex: 3, marginBottom: 0 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<option value="">— Projekt —</option>
|
||||
{projectList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.project_number} – {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
<Box sx={{ flex: 3, minWidth: 0 }}>
|
||||
<Select
|
||||
value={String(log.project_id)}
|
||||
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "— Projekt —" },
|
||||
...projectList.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
value={log.hours}
|
||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="h"
|
||||
slotProps={{
|
||||
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
h
|
||||
</span>
|
||||
<input
|
||||
</Typography>
|
||||
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={log.minutes}
|
||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="m"
|
||||
slotProps={{
|
||||
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
m
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemove(index)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
style={{ padding: "0.375rem", flexShrink: 0 }}
|
||||
aria-label="Odebrat"
|
||||
title="Odebrat"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +253,7 @@ export default function ShiftFormModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
@@ -254,169 +262,172 @@ export default function ShiftFormModal({
|
||||
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
||||
: undefined
|
||||
}
|
||||
size="lg"
|
||||
maxWidth="lg"
|
||||
onSubmit={onSubmit}
|
||||
submitLabel="Uložit"
|
||||
cancelLabel="Zrušit"
|
||||
submitText="Uložit"
|
||||
cancelText="Zrušit"
|
||||
>
|
||||
<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"
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
<Field label="Zaměstnanec" required>
|
||||
<Select
|
||||
value={form.user_id}
|
||||
onChange={(val) => updateField("user_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: user.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Datum směny" required>
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
<Field label="Datum směny">
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ záznamu</label>
|
||||
<select
|
||||
<Field label="Typ záznamu">
|
||||
<Select
|
||||
value={form.leave_type}
|
||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</div>
|
||||
onChange={(val) => updateField("leave_type", val)}
|
||||
options={[
|
||||
{ value: "work", label: "Práce" },
|
||||
{ value: "vacation", label: "Dovolená" },
|
||||
{ value: "sick", label: "Nemoc" },
|
||||
{ value: "unpaid", label: "Neplacené volno" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isWorkType && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Počet hodin</label>
|
||||
<input
|
||||
<Field
|
||||
label="Počet hodin"
|
||||
hint={isCreate ? "8 hodin = celý den" : undefined}
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
{isCreate && (
|
||||
<small className="admin-form-hint">8 hodin = celý den</small>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod - datum">
|
||||
<DateField
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Příchod - čas">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Break start */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_start_date}
|
||||
onChange={(val) => updateField("break_start_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Začátek pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => updateField("break_start_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Break end */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Konec pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Konec pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Departure */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Odchod - datum">
|
||||
<DateField
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Odchod - čas">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Projekty</label>
|
||||
<Field label="Projekty">
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
@@ -428,26 +439,25 @@ export default function ShiftFormModal({
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={addProjectLog}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat projekt
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámka</label>
|
||||
<textarea
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
interface SortIconProps {
|
||||
column: string;
|
||||
sort: string | null;
|
||||
order: string;
|
||||
}
|
||||
|
||||
export default function SortIcon({ column, sort, order }: SortIconProps) {
|
||||
if (sort !== column) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ opacity: 0.3, marginLeft: 4 }}
|
||||
>
|
||||
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
{order === "asc" ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Card, Button, EmptyState } from "../../ui";
|
||||
import { iconBadgeSx, type IconBadgeColor } from "../../theme";
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
getActivityIconClass,
|
||||
@@ -18,6 +22,16 @@ interface DashActivityFeedProps {
|
||||
activities: Activity[] | null;
|
||||
}
|
||||
|
||||
// Maps the legacy activity-icon class to the MUI palette key used to tint the
|
||||
// circular icon badge (danger → error, accent → primary, muted → default).
|
||||
const ACTIVITY_BADGE_COLOR: Record<string, string> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
danger: "error",
|
||||
accent: "primary",
|
||||
muted: "default",
|
||||
};
|
||||
|
||||
function getActivityIcon(action: string) {
|
||||
switch (action) {
|
||||
case "create":
|
||||
@@ -103,37 +117,95 @@ export default function DashActivityFeed({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-activity-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Audit log</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Audit log</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/audit-log"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{activities.map((act) => (
|
||||
<div key={act.id} className="dash-activity-row">
|
||||
<div
|
||||
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<EmptyState title="Žádná aktivita" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{activities.map((act) => {
|
||||
const badgeColor =
|
||||
ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ??
|
||||
"default";
|
||||
const isDefault = badgeColor === "default";
|
||||
return (
|
||||
<Box
|
||||
key={act.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconBadgeSx(
|
||||
isDefault ? "default" : (badgeColor as IconBadgeColor),
|
||||
),
|
||||
]}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</div>
|
||||
<div className="dash-activity-main">
|
||||
<div className="dash-activity-text">{act.description}</div>
|
||||
<div className="dash-activity-sub">
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500 }}
|
||||
noWrap
|
||||
title={act.description}
|
||||
>
|
||||
{act.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{act.username || "Systém"} ·{" "}
|
||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-activity-time admin-mono">
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatActivityTime(act.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LEAVE_TYPE_LABELS,
|
||||
STATUS_DOT_CLASS,
|
||||
STATUS_LABELS,
|
||||
} from "../../utils/dashboardHelpers";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import { Card, Button, StatusChip, EmptyState } from "../../ui";
|
||||
import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
|
||||
|
||||
interface AttendanceUser {
|
||||
user_id: number | string;
|
||||
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
|
||||
attendance: AttendanceData | null;
|
||||
}
|
||||
|
||||
// Maps the attendance status to the StatusChip / avatar tint color.
|
||||
// Mirrors the legacy dash-status-* dot classes: in→success, away→warning,
|
||||
// out→default, leave→error.
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"default" | "success" | "warning" | "error"
|
||||
> = {
|
||||
in: "success",
|
||||
away: "warning",
|
||||
out: "default",
|
||||
leave: "error",
|
||||
};
|
||||
|
||||
export default function DashAttendanceToday({
|
||||
attendance,
|
||||
}: DashAttendanceTodayProps) {
|
||||
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-attendance-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Docházka dnes</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Docházka dnes</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{attendance.users.map((u, i) => (
|
||||
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
|
||||
<div
|
||||
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{attendance.users.length === 0 ? (
|
||||
<EmptyState title="Žádná docházka" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{attendance.users.map((u, i) => {
|
||||
const color = STATUS_COLOR[u.status] ?? "default";
|
||||
const isDefault = color === "default";
|
||||
return (
|
||||
<Box
|
||||
key={`${u.user_id}-${i}`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
bgcolor: isDefault ? "grey.600" : `${color}.main`,
|
||||
color: "common.white",
|
||||
}}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</div>
|
||||
<div className="dash-presence-name">{u.name}</div>
|
||||
<div className="dash-presence-end">
|
||||
<span
|
||||
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
|
||||
</Avatar>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500, flex: 1, minWidth: 0 }}
|
||||
noWrap
|
||||
>
|
||||
{u.status === "leave"
|
||||
{u.name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<StatusChip
|
||||
color={color}
|
||||
label={
|
||||
u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]}
|
||||
</span>
|
||||
: STATUS_LABELS[u.status]
|
||||
}
|
||||
/>
|
||||
{u.arrived_at && (
|
||||
<span className="admin-mono dash-presence-time">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{u.arrived_at}
|
||||
</span>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { StatCard, type StatCardColor } from "../../ui";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
interface KpiCard {
|
||||
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
||||
};
|
||||
}
|
||||
|
||||
const KPI_CLASS_MAP: Record<number, string> = {
|
||||
4: "admin-kpi-4",
|
||||
3: "admin-kpi-3",
|
||||
2: "admin-kpi-2",
|
||||
1: "admin-kpi-1",
|
||||
// Maps the legacy KPI color tokens to the StatCard color palette
|
||||
// (danger → error; the rest are 1:1).
|
||||
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
danger: "error",
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`admin-kpi-grid ${kpiClass}`}
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{kpiCards.map((kpi) => (
|
||||
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
|
||||
<div className="admin-stat-label">{kpi.label}</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
<StatCard
|
||||
key={kpi.label}
|
||||
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
|
||||
label={kpi.label}
|
||||
value={
|
||||
<>
|
||||
{kpi.value}
|
||||
{kpi.sub && (
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{
|
||||
<Typography
|
||||
component="span"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
marginLeft: "0.25rem",
|
||||
ml: 0.5,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</small>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={kpi.footer ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import apiFetch from "../../utils/api";
|
||||
import FormModal from "../FormModal";
|
||||
import { iconBadgeSx } from "../../theme";
|
||||
import { Card, Button, Modal, Field, TextField } from "../../ui";
|
||||
import useDialogScrollLock from "../../ui/useDialogScrollLock";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -37,6 +46,34 @@ interface ProfileFormData {
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CopyIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function DashProfile({
|
||||
totpEnabled,
|
||||
totpLoading,
|
||||
@@ -61,6 +98,10 @@ export default function DashProfile({
|
||||
const alert = useAlert();
|
||||
const totpSetupRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
|
||||
// locks <html> scroll like the kit dialogs.
|
||||
useDialogScrollLock(show2FASetup);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [formData, setFormData] = useState<ProfileFormData>({
|
||||
username: "",
|
||||
@@ -133,81 +174,96 @@ export default function DashProfile({
|
||||
return totpEnabled ? "Aktivní" : "Neaktivní";
|
||||
}
|
||||
|
||||
const profileItems: Array<{ label: string; value: string }> = [
|
||||
{ label: "Uživatel", value: user?.username || "" },
|
||||
{ label: "E-mail", value: user?.email || "" },
|
||||
{ label: "Jméno", value: user?.fullName || "" },
|
||||
{
|
||||
label: "Role",
|
||||
value: user?.roleDisplay || String(user?.role || ""),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Váš účet</h2>
|
||||
<button
|
||||
onClick={openEditModal}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Upravit
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<div className="dash-profile-grid">
|
||||
<div className="dash-profile-item">
|
||||
<span className="dash-profile-label">Uživatel</span>
|
||||
<span className="dash-profile-value">{user?.username}</span>
|
||||
</div>
|
||||
<div className="dash-profile-item">
|
||||
<span className="dash-profile-label">E-mail</span>
|
||||
<span className="dash-profile-value">{user?.email}</span>
|
||||
</div>
|
||||
<div className="dash-profile-item">
|
||||
<span className="dash-profile-label">Jméno</span>
|
||||
<span className="dash-profile-value">{user?.fullName}</span>
|
||||
</div>
|
||||
<div className="dash-profile-item">
|
||||
<span className="dash-profile-label">Role</span>
|
||||
<span className="dash-profile-value">
|
||||
{user?.roleDisplay || String(user?.role || "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2FA Section */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
marginTop: "1rem",
|
||||
paddingTop: "1rem",
|
||||
<Card>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<div className="flex-between">
|
||||
<div className="flex-row-gap">
|
||||
<div
|
||||
style={{
|
||||
<Typography variant="h6">Váš účet</Typography>
|
||||
<Button
|
||||
onClick={openEditModal}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
startIcon={EditIcon}
|
||||
>
|
||||
Upravit
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{profileItems.map((item) => (
|
||||
<Box key={item.label}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: ".05em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 2FA Section */}
|
||||
<Box sx={{ borderTop: 1, borderColor: "divider", mt: 2, pt: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: totpEnabled
|
||||
? "var(--success-light)"
|
||||
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)",
|
||||
color: totpEnabled
|
||||
? "var(--success)"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconBadgeSx(totpEnabled ? "success" : "default"),
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
@@ -220,179 +276,164 @@ export default function DashProfile({
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
Dvoufaktorové ověření (2FA)
|
||||
</div>
|
||||
<div
|
||||
className={totpEnabled ? "text-success" : "text-secondary"}
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: totpEnabled ? "success.main" : "text.secondary",
|
||||
}}
|
||||
>
|
||||
{getTotpStatusText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{!totpLoading &&
|
||||
(totpEnabled ? (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDisableCode("");
|
||||
setShow2FADisable(true);
|
||||
}}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Deaktivovat
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
onClick={onStart2FASetup}
|
||||
disabled={totpSubmitting}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{totpSubmitting ? "Generuji..." : "Aktivovat"}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Upravit profil"
|
||||
size="md"
|
||||
maxWidth="sm"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Uložit změny"
|
||||
submitText="Uložit změny"
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jméno</label>
|
||||
<input
|
||||
type="text"
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Jméno">
|
||||
<TextField
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
first_name: e.target.value,
|
||||
})
|
||||
setFormData({ ...formData, first_name: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příjmení</label>
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="Příjmení">
|
||||
<TextField
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
last_name: e.target.value,
|
||||
})
|
||||
setFormData({ ...formData, last_name: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Uživatelské jméno</label>
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
</Box>
|
||||
<Field label="Uživatelské jméno">
|
||||
<TextField
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">E-mail</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="E-mail">
|
||||
<TextField
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Aktuální heslo</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="Aktuální heslo">
|
||||
<TextField
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
setFormData({ ...formData, current_password: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Nové heslo</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="Nové heslo">
|
||||
<TextField
|
||||
type="password"
|
||||
value={formData.new_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
new_password: e.target.value,
|
||||
})
|
||||
setFormData({ ...formData, new_password: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
{/* 2FA Setup Modal — multi-step: setup → backup codes */}
|
||||
<FormModal
|
||||
isOpen={show2FASetup}
|
||||
onClose={() => {
|
||||
// Only reachable on the setup step (backdrop/ESC/× are locked while
|
||||
// backupCodes is set — that branch is unreachable so removed).
|
||||
{/* 2FA Setup Dialog — bespoke, multi-step: setup → backup codes.
|
||||
On the backup-codes step the footer + close are hidden and
|
||||
backdrop/ESC are locked; the single deliberate exit lives in the body
|
||||
and also clears the codes (so unsaved codes can't be silently lost). */}
|
||||
<Dialog
|
||||
open={show2FASetup}
|
||||
onClose={
|
||||
backupCodes
|
||||
? undefined
|
||||
: () => {
|
||||
setShow2FASetup(false);
|
||||
setTotpCode("");
|
||||
}}
|
||||
title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||||
size="md"
|
||||
loading={backupCodes ? false : totpSubmitting}
|
||||
onSubmit={
|
||||
backupCodes
|
||||
? () => {
|
||||
setShow2FASetup(false);
|
||||
setBackupCodes(null);
|
||||
}
|
||||
: onConfirm2FA
|
||||
}
|
||||
submitLabel={
|
||||
backupCodes ? "Rozumím, uložil jsem si kódy" : "Aktivovat 2FA"
|
||||
}
|
||||
submitDisabled={backupCodes ? false : totpCode.length !== 6}
|
||||
cancelLabel="Zrušit"
|
||||
// On the backup-codes step the cancel/× would silently dismiss unsaved
|
||||
// codes, so hide the footer + × and lock backdrop/ESC; the explicit
|
||||
// in-body primary is the single deliberate exit.
|
||||
hideFooter={!!backupCodes}
|
||||
hideCloseButton={!!backupCodes}
|
||||
closeOnBackdrop={!backupCodes}
|
||||
closeOnEsc={!backupCodes}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
disableScrollLock
|
||||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||
>
|
||||
<DialogTitle>
|
||||
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{backupCodes ? (
|
||||
<div>
|
||||
<div className="admin-role-locked-notice mb-4">
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 1,
|
||||
p: 1.5,
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor:
|
||||
"rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
|
||||
color: "warning.main",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexShrink: 0, mt: "2px" }}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
@@ -405,161 +446,134 @@ export default function DashProfile({
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze
|
||||
jednou. Po zavření tohoto okna je již neuvidíte.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
</Box>
|
||||
<span>
|
||||
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít
|
||||
pouze jednou. Po zavření tohoto okna je již neuvidíte.
|
||||
</span>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "0.5rem",
|
||||
padding: "1rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
gap: 1,
|
||||
p: 2,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{backupCodes.map((code) => (
|
||||
<div
|
||||
<Box
|
||||
key={code}
|
||||
style={{
|
||||
padding: "0.25rem 0.5rem",
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
textAlign: "center",
|
||||
color: "var(--text-primary)",
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(backupCodes.join("\n"));
|
||||
alert.success("Kódy zkopírovány");
|
||||
}}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
startIcon={CopyIcon}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Kopírovat kódy
|
||||
</button>
|
||||
</div>
|
||||
{/* Footer is hidden on this step; the single deliberate exit lives
|
||||
in the body and also clears the codes. */}
|
||||
<div style={{ marginTop: "1.25rem", textAlign: "right" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShow2FASetup(false);
|
||||
setBackupCodes(null);
|
||||
}}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Rozumím, uložil jsem si kódy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<div>
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||||
Authy, Microsoft Authenticator apod.)
|
||||
</p>
|
||||
</Typography>
|
||||
{totpQrUri && (
|
||||
<div style={{ textAlign: "center", marginBottom: "1rem" }}>
|
||||
<img
|
||||
<Box sx={{ textAlign: "center", mb: 2 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
||||
alt="TOTP QR Code"
|
||||
style={{
|
||||
sx={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{totpSecret && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
component="label"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 0.5 }}
|
||||
>
|
||||
Nebo zadejte klíč ručně:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.375rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.875rem",
|
||||
wordBreak: "break-all",
|
||||
color: "var(--text-primary)",
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 1.5,
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "0.875rem",
|
||||
wordBreak: "break-all",
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
<span>{totpSecret}</span>
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(totpSecret);
|
||||
alert.success("Klíč zkopírován");
|
||||
}}
|
||||
className="admin-btn-icon"
|
||||
size="small"
|
||||
title="Kopírovat"
|
||||
aria-label="Kopírovat"
|
||||
style={{ flexShrink: 0 }}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{CopyIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Ověřovací kód z aplikace
|
||||
</label>
|
||||
<input
|
||||
ref={totpSetupRef}
|
||||
type="text"
|
||||
<Field label="Ověřovací kód z aplikace">
|
||||
<TextField
|
||||
inputRef={totpSetupRef}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
|
||||
onChange={(e) =>
|
||||
setTotpCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder="000000"
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||||
sx={{
|
||||
"& input": {
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "monospace",
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
},
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
@@ -567,59 +581,86 @@ export default function DashProfile({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
)}
|
||||
</FormModal>
|
||||
</DialogContent>
|
||||
{/* Footer is hidden on the backup-codes step; the single deliberate exit
|
||||
lives in the body actions there. */}
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
{backupCodes ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShow2FASetup(false);
|
||||
setBackupCodes(null);
|
||||
}}
|
||||
>
|
||||
Rozumím, uložil jsem si kódy
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShow2FASetup(false);
|
||||
setTotpCode("");
|
||||
}}
|
||||
variant="text"
|
||||
color="inherit"
|
||||
disabled={totpSubmitting}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm2FA}
|
||||
disabled={totpSubmitting || totpCode.length !== 6}
|
||||
>
|
||||
{totpSubmitting ? "Ukládám…" : "Aktivovat 2FA"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 2FA Disable Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={show2FADisable}
|
||||
onClose={() => setShow2FADisable(false)}
|
||||
title="Deaktivovat 2FA"
|
||||
size="md"
|
||||
maxWidth="sm"
|
||||
loading={totpSubmitting}
|
||||
onSubmit={onDisable2FA}
|
||||
submitDisabled={disableCode.length !== 6}
|
||||
submitLabel="Deaktivovat 2FA"
|
||||
cancelLabel="Zrušit"
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
submitText="Deaktivovat 2FA"
|
||||
cancelText="Zrušit"
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
||||
autentizační aplikace.
|
||||
</p>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Ověřovací kód</label>
|
||||
<input
|
||||
type="text"
|
||||
</Typography>
|
||||
<Field label="Ověřovací kód">
|
||||
<TextField
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="000000"
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
autoFocus
|
||||
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||||
sx={{
|
||||
"& input": {
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "monospace",
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
},
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && disableCode.length === 6) {
|
||||
onDisable2FA();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
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 { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
||||
import AdminDatePicker from "../AdminDatePicker";
|
||||
import { Button, Modal, Field, Select, DateField, TextField } from "../../ui";
|
||||
import apiFetch from "../../utils/api";
|
||||
import FormModal from "../FormModal";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -46,6 +46,14 @@ interface DashQuickActionsProps {
|
||||
onPunch: () => void;
|
||||
}
|
||||
|
||||
// Maps the legacy quick-action color tokens to the MUI Button palette color.
|
||||
const ACTION_COLOR: Record<string, "success" | "error" | "info" | "warning"> = {
|
||||
success: "success",
|
||||
danger: "error",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
};
|
||||
|
||||
export default function DashQuickActions({
|
||||
dashData,
|
||||
punching,
|
||||
@@ -284,252 +292,192 @@ export default function DashQuickActions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="dash-quick-actions"
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
md: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{quickActions.map((action) =>
|
||||
action.onClick ? (
|
||||
<button
|
||||
{quickActions.map((action) => {
|
||||
const color = ACTION_COLOR[action.color] ?? "primary";
|
||||
const common = {
|
||||
variant: "contained" as const,
|
||||
color,
|
||||
startIcon: action.icon,
|
||||
sx: { py: 1.5, justifyContent: "flex-start" },
|
||||
};
|
||||
return action.onClick ? (
|
||||
<Button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
{...common}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Link
|
||||
<Button
|
||||
key={action.label}
|
||||
component={RouterLink}
|
||||
to={action.path!}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
{...common}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</motion.div>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showTripModal}
|
||||
onClose={() => setShowTripModal(false)}
|
||||
title="Přidat jízdu"
|
||||
size="lg"
|
||||
maxWidth="md"
|
||||
onSubmit={handleTripSubmit}
|
||||
submitLabel="Uložit"
|
||||
submitText="Uložit"
|
||||
loading={tripSubmitting}
|
||||
>
|
||||
<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,
|
||||
}));
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
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"
|
||||
<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}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
|
||||
<DateField
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val: string) => {
|
||||
onChange={(val) => {
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
trip_date: undefined,
|
||||
}));
|
||||
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
|
||||
}}
|
||||
/>
|
||||
{tripErrors.trip_date && (
|
||||
<span className="admin-form-error">{tripErrors.trip_date}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Počáteční stav km
|
||||
</label>
|
||||
<input
|
||||
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
start_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
start_km: undefined,
|
||||
}));
|
||||
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
{tripErrors.start_km && (
|
||||
<span className="admin-form-error">{tripErrors.start_km}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Konečný stav km
|
||||
</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="Konečný stav km" required error={tripErrors.end_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
end_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
end_km: undefined,
|
||||
}));
|
||||
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
{tripErrors.end_km && (
|
||||
<span className="admin-form-error">{tripErrors.end_km}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Vzdálenost</label>
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="Vzdálenost">
|
||||
<TextField
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
InputProps={{ readOnly: true }}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<label className="admin-form-label required">Místo odjezdu</label>
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
|
||||
<TextField
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_from: undefined,
|
||||
}));
|
||||
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
{tripErrors.route_from && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_from}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo příjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
|
||||
<TextField
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_to: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_to: undefined,
|
||||
}));
|
||||
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
{tripErrors.route_to && (
|
||||
<span className="admin-form-error">{tripErrors.route_to}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ jízdy</label>
|
||||
<select
|
||||
value={tripForm.is_business}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
is_business: parseInt(e.target.value),
|
||||
}))
|
||||
<Field label="Typ jízdy">
|
||||
<Select
|
||||
value={String(tripForm.is_business)}
|
||||
onChange={(val) =>
|
||||
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
<option value={0}>Soukromá</option>
|
||||
</select>
|
||||
</div>
|
||||
options={[
|
||||
{ value: "1", label: "Služební" },
|
||||
{ value: "0", label: "Soukromá" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámky</label>
|
||||
<textarea
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={tripForm.notes}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
notes: e.target.value,
|
||||
}))
|
||||
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||
}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import ConfirmModal from "../ConfirmModal";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
||||
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
||||
@@ -76,8 +79,6 @@ export default function DashSessions() {
|
||||
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useModalLock(deleteAllModal);
|
||||
|
||||
const handleDeleteSession = async () => {
|
||||
if (!deleteModal.session) {
|
||||
return;
|
||||
@@ -127,85 +128,114 @@ export default function DashSessions() {
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div
|
||||
className="admin-card-header"
|
||||
style={{
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<h2 className="admin-card-title">Přihlášená zařízení</h2>
|
||||
<Typography variant="h6">Přihlášená zařízení</Typography>
|
||||
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setDeleteAllModal(true)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Odhlásit ostatní
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
</Box>
|
||||
|
||||
{sessionsLoading ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sessions.length === 0 && (
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : sessions.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center", py: 3 }}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</div>
|
||||
)}
|
||||
{sessions.length > 0 && (
|
||||
<div className="dash-sessions-list">
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
<Box
|
||||
key={session.id}
|
||||
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
...(session.is_current
|
||||
? { bgcolor: "action.hover", borderRadius: 2, px: 1 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "text.secondary",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div className="dash-session-icon">
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</div>
|
||||
<div className="dash-session-info">
|
||||
<div className="dash-session-device">
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
</Typography>
|
||||
{session.is_current && (
|
||||
<span
|
||||
className="admin-badge admin-badge-success"
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Aktuální
|
||||
</span>
|
||||
<StatusChip color="success" label="Aktuální" />
|
||||
)}
|
||||
</div>
|
||||
<div className="dash-session-meta">
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<span>{session.ip_address}</span>
|
||||
<span className="dash-session-meta-separator">|</span>
|
||||
<span>|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-session-actions">
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
{!session.is_current && (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
color="error"
|
||||
size="small"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
@@ -221,19 +251,17 @@ export default function DashSessions() {
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
||||
onConfirm={handleDeleteSession}
|
||||
@@ -241,10 +269,10 @@ export default function DashSessions() {
|
||||
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
|
||||
confirmText="Ukončit"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteAllModal}
|
||||
onClose={() => setDeleteAllModal(false)}
|
||||
onConfirm={handleDeleteAllSessions}
|
||||
@@ -252,7 +280,7 @@ export default function DashSessions() {
|
||||
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
||||
confirmText="Odhlásit vše"
|
||||
cancelText="Zrušit"
|
||||
type="warning"
|
||||
confirmVariant="primary"
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../../lib/apiAdapter";
|
||||
|
||||
interface Batch {
|
||||
id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
received_at: string;
|
||||
is_consumed: boolean;
|
||||
}
|
||||
|
||||
interface BatchesResponse {
|
||||
batches: Batch[];
|
||||
total_stock: number;
|
||||
reserved_quantity: number;
|
||||
available_quantity: number;
|
||||
}
|
||||
|
||||
interface BatchPickerProps {
|
||||
itemId: number | null;
|
||||
value: number | null;
|
||||
onChange: (batchId: number, unitPrice: number) => void;
|
||||
}
|
||||
|
||||
export default function BatchPicker({
|
||||
itemId,
|
||||
value,
|
||||
onChange,
|
||||
}: BatchPickerProps) {
|
||||
const { data: response } = useQuery({
|
||||
queryKey: ["warehouse", "batches", itemId],
|
||||
queryFn: () =>
|
||||
jsonQuery<BatchesResponse>(
|
||||
`/api/admin/warehouse/items/${itemId}/batches`,
|
||||
),
|
||||
enabled: !!itemId,
|
||||
});
|
||||
|
||||
const batches = response?.batches ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{response && response.reserved_quantity > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-tertiary)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
|
||||
{response.reserved_quantity} ks)
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const batchId = Number(e.target.value);
|
||||
const batch = batches.find((b) => b.id === batchId);
|
||||
if (batch) onChange(batch.id, Number(batch.unit_price));
|
||||
}}
|
||||
>
|
||||
<option value="">-- auto FIFO --</option>
|
||||
{batches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
|
||||
ks | {Number(b.unit_price).toFixed(2)} Kč
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
warehouseItemListOptions,
|
||||
type WarehouseItem,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface ItemPickerProps {
|
||||
value: number | null;
|
||||
@@ -9,220 +16,88 @@ interface ItemPickerProps {
|
||||
itemName?: string;
|
||||
}
|
||||
|
||||
/** Minimal option shape used to display a selected value that isn't in the
|
||||
* current search results (edit/prefill via `itemName`). */
|
||||
type ItemOption = Pick<WarehouseItem, "id" | "name"> & Partial<WarehouseItem>;
|
||||
|
||||
export default function ItemPicker({
|
||||
value,
|
||||
onChange,
|
||||
itemName,
|
||||
}: ItemPickerProps) {
|
||||
const [search, setSearch] = useState(itemName ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
|
||||
const items = data?.data ?? [];
|
||||
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeIndexRef = useRef<number>(-1);
|
||||
const listboxId = useId();
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const { data, isFetching } = useQuery(
|
||||
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||
);
|
||||
const options: ItemOption[] = data?.data ?? [];
|
||||
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
||||
position: "fixed";
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
zIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 2,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 100,
|
||||
// Show the selected value even when it isn't in the current search results
|
||||
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
|
||||
const selectedOption: ItemOption | null =
|
||||
value == null
|
||||
? null
|
||||
: (options.find((o) => o.id === value) ?? {
|
||||
id: value,
|
||||
name: itemName ?? "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset active index when items change
|
||||
useEffect(() => {
|
||||
if (activeIndex >= items.length) {
|
||||
activeIndexRef.current = -1;
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [items, activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
updatePosition();
|
||||
const onScroll = () => updatePosition();
|
||||
const onClose = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
} else {
|
||||
setDropdownStyle(null);
|
||||
activeIndexRef.current = -1;
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [open, updatePosition]);
|
||||
|
||||
// Close on click-outside via mousedown on document
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (containerRef.current && target instanceof Node) {
|
||||
if (!containerRef.current.contains(target)) {
|
||||
// Don't close if click landed on an option in the portal
|
||||
const listEl = document.getElementById(listboxId);
|
||||
if (listEl && listEl.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
||||
}, [open, listboxId]);
|
||||
|
||||
const handleSelect = (itemId: number, name: string) => {
|
||||
onChange(itemId);
|
||||
setOpen(false);
|
||||
setSearch(name);
|
||||
};
|
||||
|
||||
const setActive = (index: number) => {
|
||||
activeIndexRef.current = index;
|
||||
setActiveIndex(index);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
if (items.length > 0) setActive(0);
|
||||
return;
|
||||
}
|
||||
const next =
|
||||
activeIndexRef.current < items.length - 1
|
||||
? activeIndexRef.current + 1
|
||||
: 0;
|
||||
setActive(next);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
if (items.length > 0) setActive(items.length - 1);
|
||||
return;
|
||||
}
|
||||
const prev =
|
||||
activeIndexRef.current > 0
|
||||
? activeIndexRef.current - 1
|
||||
: items.length - 1;
|
||||
setActive(prev);
|
||||
} else if (e.key === "Enter") {
|
||||
if (
|
||||
open &&
|
||||
activeIndexRef.current >= 0 &&
|
||||
activeIndexRef.current < items.length
|
||||
) {
|
||||
e.preventDefault();
|
||||
const item = items[activeIndexRef.current];
|
||||
handleSelect(item.id, item.name);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
} else if (e.key === "Home") {
|
||||
if (open && items.length > 0) {
|
||||
e.preventDefault();
|
||||
setActive(0);
|
||||
}
|
||||
} else if (e.key === "End") {
|
||||
if (open && items.length > 0) {
|
||||
e.preventDefault();
|
||||
setActive(items.length - 1);
|
||||
}
|
||||
} else if (e.key === "Tab") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeId =
|
||||
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "relative" }}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-owns={listboxId}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
<Autocomplete<ItemOption>
|
||||
value={selectedOption}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, newInput) => setInputValue(newInput)}
|
||||
onChange={(_, opt) => {
|
||||
if (opt) onChange(opt.id);
|
||||
}}
|
||||
options={options}
|
||||
loading={isFetching}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(o) => o.name}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
noOptionsText="Žádné položky"
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...rest } = props;
|
||||
return (
|
||||
<Box component="li" key={key} {...rest}>
|
||||
<Box>
|
||||
<Typography variant="body2">{option.name}</Typography>
|
||||
{(option.item_number ||
|
||||
option.available_quantity !== undefined) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.item_number}
|
||||
{option.item_number && option.available_quantity !== undefined
|
||||
? " · "
|
||||
: ""}
|
||||
{option.available_quantity !== undefined
|
||||
? `${option.available_quantity} ${option.unit ?? ""}`
|
||||
: ""}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
placeholder="Hledat položku..."
|
||||
value={search}
|
||||
role="searchbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeId}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOpen(true);
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<CircularProgress color="inherit" size={18} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
updatePosition();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{open &&
|
||||
items.length > 0 &&
|
||||
dropdownStyle &&
|
||||
createPortal(
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="admin-item-picker-list"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={value === item.id}
|
||||
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(item.id, item.name);
|
||||
}}
|
||||
>
|
||||
<span className="admin-item-picker-name">{item.name}</span>
|
||||
{item.item_number && (
|
||||
<span className="admin-item-picker-number">
|
||||
{item.item_number}
|
||||
</span>
|
||||
)}
|
||||
{item.available_quantity !== undefined && (
|
||||
<span className="admin-item-picker-qty">
|
||||
{item.available_quantity} {item.unit}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
warehouseLocationListOptions,
|
||||
type WarehouseLocation,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface LocationSelectProps {
|
||||
value: number | null;
|
||||
onChange: (locationId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function LocationSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: LocationSelectProps) {
|
||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- bez lokace --</option>
|
||||
{locations?.map((loc: WarehouseLocation) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{loc.code} - {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { Select } from "../../ui";
|
||||
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
||||
|
||||
interface ReservationPickerProps {
|
||||
@@ -26,11 +28,9 @@ export default function ReservationPicker({
|
||||
const reservations = result?.data ?? [];
|
||||
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
<Select
|
||||
value={value == null ? "" : String(value)}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
onChange(null, 0);
|
||||
return;
|
||||
@@ -42,13 +42,13 @@ export default function ReservationPicker({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">— žádná rezervace —</option>
|
||||
<MenuItem value="">— žádná rezervace —</MenuItem>
|
||||
{reservations.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
<MenuItem key={r.id} value={String(r.id)}>
|
||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||
</option>
|
||||
</MenuItem>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
warehouseSupplierListOptions,
|
||||
type WarehouseSupplier,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface SupplierSelectProps {
|
||||
value: number | null;
|
||||
onChange: (supplierId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function SupplierSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: SupplierSelectProps) {
|
||||
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
|
||||
const suppliers = data?.data ?? [];
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- bez dodavatele --</option>
|
||||
{suppliers.map((s: WarehouseSupplier) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
{s.ico ? ` (${s.ico})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { ReactNode, useId, useRef } from "react";
|
||||
import ItemPicker from "./ItemPicker";
|
||||
import LocationSelect from "./LocationSelect";
|
||||
import BatchPicker from "./BatchPicker";
|
||||
|
||||
export interface MovementItem {
|
||||
key: string;
|
||||
item_id: number | null;
|
||||
item_name?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
location_id: number | null;
|
||||
batch_id: number | null;
|
||||
reservation_id: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export type MovementRowRenderer<T> = (
|
||||
item: T,
|
||||
index: number,
|
||||
updateItem: (field: string, value: unknown) => void,
|
||||
removeItem: () => void,
|
||||
) => ReactNode;
|
||||
|
||||
interface WarehouseMovementTableProps<T extends { key: string }> {
|
||||
items: T[];
|
||||
onChange: (items: T[]) => void;
|
||||
mode: "receipt" | "issue" | "inventory";
|
||||
defaultItem?: () => Omit<T, "key">;
|
||||
renderRow?: MovementRowRenderer<T>;
|
||||
renderHeader?: () => ReactNode;
|
||||
}
|
||||
|
||||
function parseDecimal(raw: string): number {
|
||||
// Strip leading minus if you want negative support; here we want only positive
|
||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
|
||||
item_id: null,
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
location_id: null,
|
||||
batch_id: null,
|
||||
reservation_id: null,
|
||||
notes: null,
|
||||
});
|
||||
|
||||
export default function WarehouseMovementTable<T extends { key: string }>({
|
||||
items,
|
||||
onChange,
|
||||
mode,
|
||||
defaultItem,
|
||||
renderRow,
|
||||
renderHeader,
|
||||
}: WarehouseMovementTableProps<T>) {
|
||||
const baseId = useId();
|
||||
const counterRef = useRef(0);
|
||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
||||
|
||||
const addItem = () => {
|
||||
const factory =
|
||||
defaultItem ??
|
||||
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
|
||||
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
|
||||
};
|
||||
const removeItem = (index: number) => {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
const updateItem = (index: number, field: string, value: unknown) => {
|
||||
const updated = [...items];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
// If a custom row renderer is supplied, the caller is fully in charge of
|
||||
// the row layout (and typically the column headers too — see inventory mode).
|
||||
if (renderRow) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-warehouse-movement-table">
|
||||
<table className="admin-table">
|
||||
{renderHeader && (
|
||||
<thead>
|
||||
<tr>{renderHeader()}</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.key}>
|
||||
{renderRow(
|
||||
item,
|
||||
index,
|
||||
(field, value) => updateItem(index, field, value),
|
||||
() => removeItem(index),
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat řádek
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
|
||||
const movementItems = items as unknown as MovementItem[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-warehouse-movement-table">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="admin-warehouse-col-item">Položka</th>
|
||||
{mode === "issue" && (
|
||||
<th className="admin-warehouse-col-batch">Šarže</th>
|
||||
)}
|
||||
<th className="admin-warehouse-col-qty">Množství</th>
|
||||
<th className="admin-warehouse-col-price">Cena/ks</th>
|
||||
<th className="admin-warehouse-col-location">Lokace</th>
|
||||
<th className="admin-warehouse-col-notes">Poznámka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movementItems.map((item, index) => (
|
||||
<tr key={item.key}>
|
||||
<td className="admin-warehouse-col-item">
|
||||
<ItemPicker
|
||||
value={item.item_id}
|
||||
itemName={item.item_name}
|
||||
onChange={(id) => updateItem(index, "item_id", id)}
|
||||
/>
|
||||
</td>
|
||||
{mode === "issue" && (
|
||||
<td className="admin-warehouse-col-batch">
|
||||
<BatchPicker
|
||||
itemId={item.item_id}
|
||||
value={item.batch_id}
|
||||
onChange={(batchId, unitPrice) => {
|
||||
updateItem(index, "batch_id", batchId);
|
||||
updateItem(index, "unit_price", unitPrice);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="admin-warehouse-col-qty">
|
||||
<input
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.quantity || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
"quantity",
|
||||
parseDecimal(e.target.value),
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
step="0.001"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+([\.,][0-9]+)?"
|
||||
aria-label={`Množství, řádek ${index + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-price">
|
||||
<input
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.unit_price || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
"unit_price",
|
||||
parseDecimal(e.target.value),
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={mode === "issue"}
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+([\.,][0-9]+)?"
|
||||
aria-label={`Cena za kus, řádek ${index + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-location">
|
||||
<LocationSelect
|
||||
value={item.location_id}
|
||||
onChange={(id) => updateItem(index, "location_id", id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-notes">
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
value={item.notes ?? ""}
|
||||
onChange={(e) => updateItem(index, "notes", e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
onClick={() => removeItem(index)}
|
||||
title="Odebrat řádek"
|
||||
aria-label="Odebrat řádek"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat řádek
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
/* ============================================================================
|
||||
Dashboard
|
||||
============================================================================ */
|
||||
|
||||
/* Dashboard layout */
|
||||
.dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.dash .admin-page-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.dash-quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.dash-quick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-quick-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.dash-quick-btn-success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-quick-btn-info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.dash-quick-btn-warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-quick-btn-danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dash-quick-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
[data-theme="light"] .dash-quick-btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-info {
|
||||
background: var(--info);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-warning {
|
||||
background: var(--warning);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Main content 3-col grid */
|
||||
.dash-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Card link */
|
||||
.dash-card-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.dash-card-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Activity rows */
|
||||
.dash-activity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dash-activity-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-activity-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-activity-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-activity-icon.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-activity-icon.info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.dash-activity-icon.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-activity-icon.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.dash-activity-icon.accent {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.dash-activity-icon.muted {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dash-activity-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-activity-text {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-activity-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.dash-activity-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Presence rows */
|
||||
.dash-presence-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dash-presence-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-presence-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dash-presence-avatar.dash-status-in {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-away {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-out {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-leave {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.dash-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-status-dot.dash-status-in {
|
||||
background: var(--success);
|
||||
}
|
||||
.dash-status-dot.dash-status-away {
|
||||
background: var(--warning);
|
||||
}
|
||||
.dash-status-dot.dash-status-out {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.dash-status-dot.dash-status-leave {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.dash-presence-label.dash-status-in {
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-presence-label.dash-status-away {
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-presence-label.dash-status-out {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dash-presence-label.dash-status-leave {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.dash-presence-name {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dash-presence-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-presence-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dash-presence-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Right column */
|
||||
.dash-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Project rows */
|
||||
.dash-project-row {
|
||||
display: block;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-decoration: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dash-project-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-project-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-project-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dash-project-customer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Stat mini rows */
|
||||
.dash-stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dash-stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Empty row */
|
||||
.dash-empty-row {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Bottom stacked layout (profile + sessions) */
|
||||
.dash-bottom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dash .admin-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Profile grid inside account card */
|
||||
.dash-profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.dash-profile-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.dash-profile-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dash-profile-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Responsive
|
||||
============================================================================ */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dash-main-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.dash-right-col {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dash-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-right-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.dash-profile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Sessions / Devices
|
||||
============================================================================ */
|
||||
|
||||
.dash-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dash-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.dash-session-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-session-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-session-item-current {
|
||||
background: var(--row-current);
|
||||
}
|
||||
|
||||
.dash-session-item-current:hover {
|
||||
background: var(--row-current-hover);
|
||||
}
|
||||
|
||||
.dash-session-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-session-item-current .dash-session-icon {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.dash-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-session-device {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dash-session-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dash-session-meta-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
.dash-session-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-session-item {
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dash-session-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.dash-session-device {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dash-session-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -1,211 +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;
|
||||
}
|
||||
|
||||
/* Mobile safety net — keep the popper inside the viewport on small phones.
|
||||
(Native date input is used on touch; this guards the rare desktop-style popper.) */
|
||||
@media (max-width: 480px) {
|
||||
.react-datepicker-popper {
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.react-datepicker {
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
}
|
||||
@@ -1,199 +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;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
File Manager — mobile
|
||||
============================================================================ */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Toolbar wraps so actions drop below the path instead of overflowing */
|
||||
.fm-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* New-folder input goes full width (override the 250px desktop cap) */
|
||||
.fm-new-folder .admin-form-input {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Breadcrumb scrolls horizontally instead of overflowing the container */
|
||||
.fm-breadcrumb {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.fm-breadcrumb-segment {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,510 +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,
|
||||
.admin-form-row-3,
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-customer-dropdown {
|
||||
max-width: calc(100vw - 24px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.admin-customer-selected .admin-btn-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
margin-right: -10px;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +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) {
|
||||
/* Month-nav row may wrap so it never forces horizontal page overflow */
|
||||
.invoice-month-nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 44px touch target for the prev/next month buttons */
|
||||
.invoice-month-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-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;
|
||||
}
|
||||
}
|
||||
@@ -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,641 +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;
|
||||
}
|
||||
|
||||
/* Keep Quill picker dropdowns on-screen on narrow phones.
|
||||
The font picker hard-codes min-width: 11rem and the color picker a fixed
|
||||
176px width — both overflow a ~360px viewport. */
|
||||
@media (max-width: 480px) {
|
||||
.admin-rich-editor .ql-snow .ql-picker-options,
|
||||
.admin-rich-editor .ql-snow .ql-font .ql-picker-options,
|
||||
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
|
||||
min-width: 0;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.admin-rich-editor
|
||||
.ql-snow
|
||||
.ql-color-picker
|
||||
.ql-picker-options[aria-hidden="false"] {
|
||||
width: auto;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.offers-items-table .admin-table td .admin-form-input {
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
/* Give the description column more room by shrinking numeric columns */
|
||||
.offers-col-qty {
|
||||
width: 4rem !important;
|
||||
}
|
||||
.offers-col-unit {
|
||||
width: 4rem !important;
|
||||
}
|
||||
.offers-col-price {
|
||||
width: 5.5rem !important;
|
||||
}
|
||||
.offers-col-included {
|
||||
width: 3.5rem !important;
|
||||
}
|
||||
.offers-col-total {
|
||||
width: 5.5rem !important;
|
||||
}
|
||||
.offers-col-del {
|
||||
width: 2.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.offers-items-table {
|
||||
margin: 0 -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table td {
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table th {
|
||||
font-size: 10px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
/* Further reduce numeric columns on very small screens */
|
||||
.offers-col-qty {
|
||||
width: 3.5rem !important;
|
||||
}
|
||||
.offers-col-unit {
|
||||
width: 3.5rem !important;
|
||||
}
|
||||
.offers-col-price {
|
||||
width: 5rem !important;
|
||||
}
|
||||
.offers-col-total {
|
||||
width: 5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Offer draft row in table */
|
||||
.offers-draft-row {
|
||||
background: var(--row-draft);
|
||||
}
|
||||
|
||||
.offers-draft-row-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 14%, transparent);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
/* Expired offer without order */
|
||||
.offers-expired-row {
|
||||
background: var(--row-expired);
|
||||
}
|
||||
|
||||
.offers-expired-row td {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.offers-expired-row a {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* Invalidated offer */
|
||||
.offers-invalidated-row {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.offers-invalidated-row td {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.offers-invalidated-row a {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Completed offer */
|
||||
.offers-completed-row {
|
||||
background: var(
|
||||
--row-completed,
|
||||
color-mix(in srgb, var(--success) 8%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
/* Read-only form (invalidated offer detail) */
|
||||
.offers-readonly input[readonly],
|
||||
.offers-readonly select:disabled {
|
||||
background-color: var(--bg-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Offer draft indicator */
|
||||
/* Filters */
|
||||
.admin-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.admin-filter-group .admin-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-filter-group .admin-form-select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,29 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import BulkAttendanceModal from "../components/BulkAttendanceModal";
|
||||
import ShiftFormModal from "../components/ShiftFormModal";
|
||||
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
||||
import FormField from "../components/FormField";
|
||||
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
FilterBar,
|
||||
LoadingState,
|
||||
MonthField,
|
||||
PageEnter,
|
||||
Select,
|
||||
StatCard,
|
||||
StatusChip,
|
||||
ProgressBar,
|
||||
type ProgressColor,
|
||||
type StatCardColor,
|
||||
} from "../ui";
|
||||
|
||||
interface UserTotalData {
|
||||
name: string;
|
||||
@@ -35,23 +48,57 @@ interface UserTotalData {
|
||||
worked_holiday_hours?: number;
|
||||
}
|
||||
|
||||
function getFundBarBackground(data: UserTotalData) {
|
||||
// fondUsed mirrors the Fond "used" line in the IIFE below:
|
||||
// real_worked + vacation + worked_holiday + free_holiday
|
||||
// (delta is fondUsed - fund; 0 means exactly at the fund).
|
||||
/** Fond "used" total mirrors the Fond footer line below:
|
||||
* real_worked + vacation + worked_holiday + free_holiday. */
|
||||
function getFondUsed(data: UserTotalData): number {
|
||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||
const fondUsed =
|
||||
return (
|
||||
realWorked +
|
||||
(data.vacation_hours ?? 0) +
|
||||
(data.worked_holiday_hours ?? 0) +
|
||||
(data.svatek ?? 0);
|
||||
const fundVal = data.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
|
||||
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
|
||||
return "var(--gradient)";
|
||||
(data.svatek ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
|
||||
* the legacy getFundBarBackground conditional: over fund → warning, exactly
|
||||
* at fund → success, under fund → primary (was the accent gradient). */
|
||||
function getFundBarColor(data: UserTotalData): ProgressColor {
|
||||
const delta = getFondUsed(data) - (data.fund ?? 0);
|
||||
if (delta > 0) return "warning";
|
||||
if (delta >= 0) return "success";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
const PrintIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AddIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AttendanceAdmin() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -111,279 +158,238 @@ export default function AttendanceAdmin() {
|
||||
Object.keys(data.user_totals).length === 0;
|
||||
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const hasTotals = Object.keys(data.user_totals).length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Správa docházky</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Typography variant="h4">Správa docházky</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||
{hasData && (
|
||||
<button
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PrintIcon}
|
||||
onClick={handlePrint}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk docházky"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginRight: "0.5rem" }}
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
Tisk
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={openBulkModal}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
|
||||
Vyplnit měsíc
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button startIcon={AddIcon} onClick={openCreateModal}>
|
||||
Přidat záznam
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Měsíc">
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={month}
|
||||
onChange={(val: string) => setMonth(val)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Zaměstnanec">
|
||||
<select
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filterUserId}
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všichni</option>
|
||||
{data.users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
onChange={setFilterUserId}
|
||||
options={[
|
||||
{ value: "", label: "Všichni zaměstnanci" },
|
||||
...data.users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: user.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{/* User Totals */}
|
||||
{Object.keys(data.user_totals).length > 0 && (
|
||||
<motion.div
|
||||
className="admin-grid admin-grid-3 mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.09 }}
|
||||
{/* User Totals (KPI cards) */}
|
||||
{hasTotals && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||
const ut = userData as UserTotalData;
|
||||
const balance = data.leave_balances[uid];
|
||||
const statColor: StatCardColor = ut.working ? "success" : "default";
|
||||
return (
|
||||
<div key={uid} className="admin-card" style={{ display: "flex" }}>
|
||||
<div
|
||||
className="admin-card-body"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
<StatCard
|
||||
key={uid}
|
||||
label={ut.name}
|
||||
value={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex-row gap-2 mb-2">
|
||||
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
||||
<span
|
||||
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
|
||||
>
|
||||
{ut.working ? "\u2713" : "\u2717"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-stat-value">
|
||||
{formatMinutes(ut.minutes)}
|
||||
</div>
|
||||
<div className="admin-stat-label">odpracováno</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.75rem",
|
||||
<StatusChip
|
||||
color={ut.working ? "success" : "default"}
|
||||
label={ut.working ? "✓" : "✗"}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
color={statColor}
|
||||
footer={
|
||||
<Box>
|
||||
{/* Leave-type badges */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
minHeight: "2rem",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
minHeight: "1.75rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{ut.vacation_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-vacation">
|
||||
Dov: {ut.vacation_hours}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="info"
|
||||
label={`Dov: ${ut.vacation_hours}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.sick_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-sick">
|
||||
Nem: {ut.sick_hours}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="error"
|
||||
label={`Nem: ${ut.sick_hours}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||
<span className="attendance-leave-badge badge-holiday">
|
||||
Sv: {Math.round(ut.svatek)}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="warning"
|
||||
label={`Sv: ${Math.round(ut.svatek)}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.unpaid_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-unpaid">
|
||||
Nep: {ut.unpaid_hours}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={`Nep: ${ut.unpaid_hours}h`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Fond usage */}
|
||||
{ut.fund !== null &&
|
||||
(() => {
|
||||
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
|
||||
// (everything the user actually got paid for this month:
|
||||
// raw worked time with sub-day remainders, plus
|
||||
// vacation days, plus hours worked on holidays, plus
|
||||
// the 8h per unworked holiday from the fund).
|
||||
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
|
||||
const fondUsed =
|
||||
realWorked +
|
||||
(ut.vacation_hours ?? 0) +
|
||||
(ut.worked_holiday_hours ?? 0) +
|
||||
(ut.svatek ?? 0);
|
||||
// Fond "used" = real_worked + vacation +
|
||||
// worked_holiday + free_holiday (everything the user
|
||||
// actually got paid for this month).
|
||||
const fondUsed = getFondUsed(ut);
|
||||
const fundVal = ut.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
const pct = Math.min(
|
||||
100,
|
||||
(fondUsed / (ut.fund || 1)) * 100,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="kpi-card-footer"
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
paddingTop: "0.75rem",
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "0.8rem",
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||
{formatHoursDecimal(fundVal * 60)}h
|
||||
</span>
|
||||
</Typography>
|
||||
{delta > 0 && (
|
||||
<span className="text-warning fw-600">
|
||||
+{formatHoursDecimal(delta * 60)}h
|
||||
</span>
|
||||
)}
|
||||
{delta <= 0 && delta < 0 && (
|
||||
<span className="text-danger fw-600">
|
||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.375rem",
|
||||
height: "4px",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "2px",
|
||||
overflow: "hidden",
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "warning.main",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(fondUsed / (ut.fund || 1)) * 100,
|
||||
)}%`,
|
||||
background: getFundBarBackground(ut),
|
||||
borderRadius: "2px",
|
||||
transition: "width 0.3s ease",
|
||||
+{formatHoursDecimal(delta * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
{delta < 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "error.main",
|
||||
}}
|
||||
>
|
||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<ProgressBar
|
||||
value={pct}
|
||||
color={getFundBarColor(ut)}
|
||||
height={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
|
||||
>
|
||||
{data.leave_balances[uid] ? (
|
||||
<>
|
||||
|
||||
{/* Remaining vacation */}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
{balance ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Zbývá dovolené:{" "}
|
||||
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
|
||||
h / {data.leave_balances[uid].vacation_total}h
|
||||
</>
|
||||
{balance.vacation_remaining.toFixed(1)}h /{" "}
|
||||
{balance.vacation_total}h
|
||||
</Typography>
|
||||
) : (
|
||||
<span style={{ visibility: "hidden" }}>—</span>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ visibility: "hidden" }}
|
||||
>
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Records Table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<Card>
|
||||
<AttendanceShiftTable
|
||||
records={data.records}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
<BulkAttendanceModal
|
||||
@@ -435,7 +441,7 @@ export default function AttendanceAdmin() {
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -444,6 +450,6 @@ export default function AttendanceAdmin() {
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import { userListOptions } from "../lib/queries/users";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import FormField from "../components/FormField";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { todayLocalStr } from "../utils/formatters";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DateField,
|
||||
Field,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
PageHeader,
|
||||
Select,
|
||||
TextField,
|
||||
TimeField,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface CreateForm {
|
||||
@@ -102,89 +114,74 @@ export default function AttendanceCreate() {
|
||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Přidat záznam docházky</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Přidat záznam docházky"
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
style={{ maxWidth: "600px" }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<form onSubmit={handleSubmit} className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Zaměstnanec" required>
|
||||
<select
|
||||
value={form.user_id}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, user_id: e.target.value })
|
||||
← Zpět na správu
|
||||
</Button>
|
||||
}
|
||||
className="admin-form-select"
|
||||
required
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Datum směny" required>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val: string) => handleShiftDateChange(val)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Typ záznamu" required>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, leave_type: e.target.value })
|
||||
}
|
||||
className="admin-form-select"
|
||||
<Card sx={{ maxWidth: 640 }}>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
{/* Employee + Shift date */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<Field label="Zaměstnanec" required>
|
||||
<Select
|
||||
value={form.user_id}
|
||||
onChange={(val) => setForm({ ...form, user_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...users.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Datum směny" required>
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => handleShiftDateChange(val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Record type */}
|
||||
<Field label="Typ záznamu" required>
|
||||
<Select
|
||||
value={form.leave_type}
|
||||
onChange={(val) => setForm({ ...form, leave_type: val })}
|
||||
options={[
|
||||
{ value: "work", label: "Práce" },
|
||||
{ value: "vacation", label: "Dovolená" },
|
||||
{ value: "sick", label: "Nemoc" },
|
||||
{ value: "unpaid", label: "Neplacené volno" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Leave hours — shown only for non-work types */}
|
||||
{!isWorkType && (
|
||||
<FormField label="Počet hodin">
|
||||
<input
|
||||
<Field label="Počet hodin" hint="Výchozí 8 hodin pro celý den">
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
@@ -193,132 +190,142 @@ export default function AttendanceCreate() {
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Výchozí 8 hodin pro celý den
|
||||
</small>
|
||||
</FormField>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Work-type time fields */}
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Příchod - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod - datum">
|
||||
<DateField
|
||||
value={form.arrival_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_date: val })
|
||||
}
|
||||
onChange={(val) => setForm({ ...form, arrival_date: val })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Příchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Příchod - čas">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Začátek pauzy - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Break start */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_start_date}
|
||||
onChange={(val: string) =>
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Začátek pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Začátek pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val: string) =>
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Konec pauzy - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Break end */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Konec pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_end_date}
|
||||
onChange={(val: string) =>
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Konec pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Konec pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val: string) =>
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Odchod - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{/* Departure */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Odchod - datum">
|
||||
<DateField
|
||||
value={form.departure_date}
|
||||
onChange={(val: string) =>
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Odchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
</Field>
|
||||
<Field label="Odchod - čas">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val: string) =>
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
{/* Notes */}
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<Link
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: "flex", gap: 1.5, justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
Zrušit
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { iconBadgeSx } from "../theme";
|
||||
import {
|
||||
attendanceHistoryOptions,
|
||||
type AttendanceRecord,
|
||||
@@ -25,7 +26,20 @@ import {
|
||||
normalizeDateStr,
|
||||
formatHoursDecimal,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import FormField from "../components/FormField";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
FilterBar,
|
||||
MonthField,
|
||||
ProgressBar,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Leden",
|
||||
@@ -42,6 +56,21 @@ const MONTH_NAMES = [
|
||||
"Prosinec",
|
||||
];
|
||||
|
||||
const PrintIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatBreakRange = (record: AttendanceRecord): string => {
|
||||
if (record.break_start && record.break_end) {
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||
@@ -122,9 +151,7 @@ function getBusinessDaysInMonth(year: number, month: number): number {
|
||||
const renderProjectCell = (record: AttendanceRecord) => {
|
||||
if (record.project_logs && record.project_logs.length > 0) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||
{record.project_logs.map((log, i) => {
|
||||
let h: number,
|
||||
m: number,
|
||||
@@ -145,31 +172,28 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
||||
m = mins % 60;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
<StatusChip
|
||||
key={log.id || i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:
|
||||
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
|
||||
</span>
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
|
||||
sx={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return (
|
||||
<span
|
||||
className="admin-badge admin-badge-wrap"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{record.project_name}
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={record.project_name}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
height: "auto",
|
||||
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
@@ -373,88 +397,156 @@ export default function AttendanceHistory() {
|
||||
};
|
||||
};
|
||||
|
||||
const fund = computed.monthlyFund;
|
||||
const progressValue =
|
||||
fund.fund > 0 ? Math.min(100, (fund.worked / fund.fund) * 100) : 0;
|
||||
const progressColor =
|
||||
computed.delta > 0
|
||||
? "warning"
|
||||
: computed.delta >= 0
|
||||
? "success"
|
||||
: "primary";
|
||||
const deltaColor =
|
||||
computed.delta > 0
|
||||
? "warning.main"
|
||||
: computed.delta < 0
|
||||
? "error.main"
|
||||
: "success.main";
|
||||
|
||||
const columns: DataColumn<AttendanceRecord>[] = [
|
||||
{
|
||||
key: "date",
|
||||
header: "Datum",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => formatDate(record.shift_date),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "11%",
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Historie docházky</h1>
|
||||
<p className="admin-page-subtitle">{computed.monthName}</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
{records.length > 0 && (
|
||||
<button
|
||||
<StatusChip
|
||||
color={leaveTypeChipColor(leaveType)}
|
||||
label={getLeaveTypeName(leaveType)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "arrival",
|
||||
header: "Příchod",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "break",
|
||||
header: "Pauza",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatBreakRange(record);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "departure",
|
||||
header: "Odchod",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.departure_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "hours",
|
||||
header: "Hodiny",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
return workMinutes > 0 ? formatMinutes(workMinutes, true) : "—";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "projects",
|
||||
header: "Projekty",
|
||||
width: "18%",
|
||||
render: (record) => renderProjectCell(record),
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "11%",
|
||||
render: (record) => record.notes || "",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Historie docházky"
|
||||
subtitle={computed.monthName}
|
||||
actions={
|
||||
records.length > 0 ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PrintIcon}
|
||||
onClick={handlePrint}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk docházky"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginRight: "0.5rem" }}
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
Tisk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Měsíc">
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={month}
|
||||
onChange={(val: string) => setMonth(val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{/* Monthly Fund Card */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
{computed.monthlyFund && (
|
||||
<div
|
||||
style={{
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-icon info">
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconBadgeSx("info"),
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
@@ -468,230 +560,102 @@ export default function AttendanceHistory() {
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
||||
<div
|
||||
style={{
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: "200px" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: "0.375rem",
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
|
||||
Fond: {fund.worked}h / {fund.fund}h
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
||||
{computed.monthlyFund.fund}h
|
||||
</span>
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
{computed.monthlyFund.business_days} prac. dnů
|
||||
{computed.monthlyFund.holiday_count > 0 &&
|
||||
` + ${computed.monthlyFund.holiday_count} svátky`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-balance-bar">
|
||||
<div
|
||||
className="attendance-balance-progress"
|
||||
style={{
|
||||
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
|
||||
background:
|
||||
computed.delta > 0
|
||||
? "linear-gradient(135deg, var(--warning), #d97706)"
|
||||
: computed.delta >= 0
|
||||
? "linear-gradient(135deg, var(--success), #059669)"
|
||||
: "var(--gradient)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
{fund.business_days} prac. dnů
|
||||
{fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProgressBar value={progressValue} color={progressColor} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
marginTop: "0.5rem",
|
||||
gap: 0.5,
|
||||
mt: 1,
|
||||
minHeight: "1.5rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{computed.totalMinutes > 0 && (
|
||||
<span
|
||||
className="attendance-leave-badge"
|
||||
<StatusChip
|
||||
color="info"
|
||||
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
|
||||
title="Odpracováno (reálně)"
|
||||
>
|
||||
Práce: {formatHoursDecimal(computed.totalMinutes)}h
|
||||
</span>
|
||||
/>
|
||||
)}
|
||||
{computed.vacationHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-vacation">
|
||||
Dov: {computed.vacationHours}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="success"
|
||||
label={`Dov: ${computed.vacationHours}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.sickHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-sick">
|
||||
Nem: {computed.sickHours}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="error"
|
||||
label={`Nem: ${computed.sickHours}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.freeHolidayHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-holiday">
|
||||
Sv: {Math.round(computed.freeHolidayHours)}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="warning"
|
||||
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.unpaidHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-unpaid">
|
||||
Nep: {computed.unpaidHours}h
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={`Nep: ${computed.unpaidHours}h`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
className={
|
||||
computed.delta > 0
|
||||
? "text-warning fw-600"
|
||||
: computed.delta < 0
|
||||
? "text-danger fw-600"
|
||||
: "text-success fw-600"
|
||||
}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, color: deltaColor }}
|
||||
>
|
||||
{computed.delta > 0
|
||||
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
||||
: computed.delta < 0
|
||||
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
||||
: "Fond splněn"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!computed.monthlyFund && (
|
||||
<div
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "0.5rem 0",
|
||||
}}
|
||||
>
|
||||
Fond měsíce není k dispozici
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Card>
|
||||
|
||||
{/* Records Table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
{records.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
<DataTable<AttendanceRecord>
|
||||
columns={columns}
|
||||
rows={records}
|
||||
rowKey={(record) => record.id}
|
||||
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||
/>
|
||||
)}
|
||||
{records.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDate(record.shift_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreakRange(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? formatMinutes(workMinutes, true)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "150px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Card>
|
||||
|
||||
{/* Hidden Print Content */}
|
||||
{records.length > 0 && (
|
||||
@@ -896,6 +860,24 @@ export default function AttendanceHistory() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||
* sick→error(red), unpaid→default(gray). */
|
||||
function leaveTypeChipColor(
|
||||
type: string,
|
||||
): "default" | "success" | "error" | "warning" | "info" {
|
||||
switch (type) {
|
||||
case "vacation":
|
||||
return "info";
|
||||
case "sick":
|
||||
return "error";
|
||||
case "unpaid":
|
||||
return "default";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,47 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { styled } from "@mui/material/styles";
|
||||
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet map container (was the .attendance-location-map rule). zIndex:0
|
||||
// creates a stacking context so Leaflet's internal z-indexes stay contained.
|
||||
const LocationMap = styled("div")(({ theme }) => ({
|
||||
height: "400px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1.5rem",
|
||||
background: theme.vars!.palette.background.paper,
|
||||
position: "relative",
|
||||
zIndex: 0,
|
||||
"@media (max-width:640px)": {
|
||||
height: "clamp(220px, 45vh, 400px)",
|
||||
},
|
||||
}));
|
||||
|
||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
attendanceLocationOptions,
|
||||
type LocationRecord,
|
||||
} from "../lib/queries/attendance";
|
||||
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
||||
|
||||
const BackIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AttendanceLocation() {
|
||||
const alert = useAlert();
|
||||
@@ -167,127 +197,164 @@ export default function AttendanceLocation() {
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Poloha záznamu"
|
||||
actions={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/attendance/admin?month=${month}`}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
Zpět na správu
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-header">
|
||||
<h2 className="admin-card-title">
|
||||
{/* Info + map card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{record.user_name} — {formatDate(record.shift_date)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{hasAnyLocation && (
|
||||
<div ref={mapRef} className="attendance-location-map" />
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<div className="attendance-location-grid">
|
||||
{/* Arrival */}
|
||||
<div
|
||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
||||
{/* Leaflet map */}
|
||||
{hasAnyLocation && <LocationMap ref={mapRef} />}
|
||||
|
||||
{/* Location detail grid */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
mt: hasAnyLocation ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
opacity: hasArrivalLocation ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Příchod
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
|
||||
>
|
||||
<h3 className="attendance-location-title">Příchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
: "—"}
|
||||
</div>
|
||||
</Typography>
|
||||
{hasArrivalLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: "text.secondary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
{(hasDepartureLocation || record.departure_time) && (
|
||||
<div
|
||||
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
opacity: hasDepartureLocation ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Odchod
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
|
||||
>
|
||||
<h3 className="attendance-location-title">Odchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
: "—"}
|
||||
</div>
|
||||
</Typography>
|
||||
{hasDepartureLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: "text.secondary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import Pagination from "../components/Pagination";
|
||||
import FormField from "../components/FormField";
|
||||
import FormModal from "../components/FormModal";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { czechPlural } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
Modal,
|
||||
Select,
|
||||
DateField,
|
||||
TextField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -30,19 +44,23 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
access_denied: "Přístup odepřen",
|
||||
};
|
||||
|
||||
const ACTION_BADGE_CLASS: Record<string, string> = {
|
||||
create: "admin-badge-success",
|
||||
update: "admin-badge-info",
|
||||
delete: "admin-badge-danger",
|
||||
login: "admin-badge-secondary",
|
||||
login_failed: "admin-badge-danger",
|
||||
logout: "admin-badge-secondary",
|
||||
view: "admin-badge-info",
|
||||
activate: "admin-badge-success",
|
||||
deactivate: "admin-badge-warning",
|
||||
password_change: "admin-badge-info",
|
||||
permission_change: "admin-badge-warning",
|
||||
access_denied: "admin-badge-danger",
|
||||
// Maps legacy admin-badge-* CSS class suffixes → StatusChip color
|
||||
const ACTION_CHIP_COLOR: Record<
|
||||
string,
|
||||
"success" | "info" | "warning" | "error" | "default"
|
||||
> = {
|
||||
create: "success",
|
||||
update: "info",
|
||||
delete: "error",
|
||||
login: "default",
|
||||
login_failed: "error",
|
||||
logout: "default",
|
||||
view: "info",
|
||||
activate: "success",
|
||||
deactivate: "warning",
|
||||
password_change: "info",
|
||||
permission_change: "warning",
|
||||
access_denied: "error",
|
||||
};
|
||||
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
||||
@@ -53,6 +71,29 @@ const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
|
||||
const CLEANUP_DAYS_OPTIONS = [
|
||||
{ value: "30", label: "30 dní" },
|
||||
{ value: "60", label: "60 dní" },
|
||||
{ value: "90", label: "90 dní" },
|
||||
{ value: "180", label: "180 dní" },
|
||||
{ value: "365", label: "1 rok" },
|
||||
{ value: "0", label: "Vše" },
|
||||
];
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
@@ -82,11 +123,16 @@ export default function AuditLog() {
|
||||
date_to: "",
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(50);
|
||||
const [perPage] = useState(50);
|
||||
const [showCleanup, setShowCleanup] = useState(false);
|
||||
const [cleanupDays, setCleanupDays] = useState(90);
|
||||
// cleanupDays stored as string for the kit Select; converted to number at the boundary
|
||||
const [cleanupDaysStr, setCleanupDaysStr] = useState("90");
|
||||
|
||||
const { data: logsData, isPending } = useQuery({
|
||||
const {
|
||||
data: logsData,
|
||||
isPending,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"audit-log",
|
||||
{
|
||||
@@ -127,6 +173,10 @@ export default function AuditLog() {
|
||||
},
|
||||
};
|
||||
},
|
||||
// Keep the current rows on screen while a filter refetches, so the page
|
||||
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
||||
// whole entrance animation on every filter change).
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||
@@ -154,16 +204,8 @@ export default function AuditLog() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPage(1);
|
||||
setPerPage(newPerPage);
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const cleanupDays = Number(cleanupDaysStr);
|
||||
try {
|
||||
// Backend requires this literal confirmation token when wiping everything.
|
||||
await cleanupMutation.mutateAsync({
|
||||
@@ -181,229 +223,156 @@ export default function AuditLog() {
|
||||
};
|
||||
|
||||
if (isPending && logs.length === 0) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const columns: DataColumn<AuditLogEntry>[] = [
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Čas",
|
||||
width: "16%",
|
||||
mono: true,
|
||||
render: (log) => formatDatetime(log.created_at),
|
||||
},
|
||||
{
|
||||
key: "username",
|
||||
header: "Uživatel",
|
||||
width: "14%",
|
||||
bold: true,
|
||||
render: (log) => log.username || "-",
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: "Akce",
|
||||
width: "14%",
|
||||
render: (log) => (
|
||||
<StatusChip
|
||||
label={ACTION_LABELS[log.action] || log.action}
|
||||
color={ACTION_CHIP_COLOR[log.action] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "entity_type",
|
||||
header: "Typ entity",
|
||||
width: "14%",
|
||||
render: (log) =>
|
||||
ENTITY_TYPE_LABELS[log.entity_type || ""] || log.entity_type || "-",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "Popis",
|
||||
width: "30%",
|
||||
render: (log) => log.description || "-",
|
||||
},
|
||||
{
|
||||
key: "user_ip",
|
||||
header: "IP",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (log) => log.user_ip || "-",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Audit log</h1>
|
||||
{pagination && (
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination.total}{" "}
|
||||
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Audit log"
|
||||
subtitle={
|
||||
pagination
|
||||
? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}`
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={TrashIcon}
|
||||
onClick={() => setShowCleanup(true)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
Vyčistit
|
||||
</button>
|
||||
</motion.div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormModal
|
||||
{/* Cleanup Modal */}
|
||||
<Modal
|
||||
isOpen={showCleanup}
|
||||
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
||||
onSubmit={handleCleanup}
|
||||
title="Vyčistit audit log"
|
||||
submitLabel="Smazat"
|
||||
submitText="Smazat"
|
||||
loading={cleanupMutation.isPending}
|
||||
>
|
||||
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
|
||||
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={cleanupDays}
|
||||
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
|
||||
>
|
||||
<option value={30}>30 dní</option>
|
||||
<option value={60}>60 dní</option>
|
||||
<option value={90}>90 dní</option>
|
||||
<option value={180}>180 dní</option>
|
||||
<option value={365}>1 rok</option>
|
||||
<option value={0}>Vše</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
className="admin-confirm-message"
|
||||
style={{ fontSize: "12px", opacity: 0.6 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Smazat záznamy starší než:
|
||||
</Typography>
|
||||
<Box sx={{ maxWidth: 200, mb: 1 }}>
|
||||
<Select
|
||||
value={cleanupDaysStr}
|
||||
onChange={setCleanupDaysStr}
|
||||
options={CLEANUP_DAYS_OPTIONS}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ opacity: 0.6, fontSize: "0.75rem" }}>
|
||||
Tato akce je nevratná.
|
||||
</p>
|
||||
</FormModal>
|
||||
</Typography>
|
||||
</Modal>
|
||||
|
||||
<motion.div
|
||||
className="admin-card mb-4"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row admin-form-row-5">
|
||||
<FormField label="Hledat">
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Popis, uživatel..."
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
placeholder="Popis, uživatel..."
|
||||
fullWidth
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Akce">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 160px" }}>
|
||||
<Select
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange("action", e.target.value)}
|
||||
>
|
||||
<option value="">Všechny</option>
|
||||
{ACTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Typ entity">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
onChange={(val) => handleFilterChange("action", val)}
|
||||
options={[{ value: "", label: "Všechny akce" }, ...ACTION_OPTIONS]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filters.entity_type}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("entity_type", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Všechny</option>
|
||||
{ENTITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Od">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
onChange={(val) => handleFilterChange("entity_type", val)}
|
||||
options={[
|
||||
{ value: "", label: "Všechny entity" },
|
||||
...ENTITY_OPTIONS,
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={filters.date_from}
|
||||
onChange={(val: string) => handleFilterChange("date_from", val)}
|
||||
onChange={(val) => handleFilterChange("date_from", val)}
|
||||
label="Od"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={filters.date_to}
|
||||
onChange={(val: string) => handleFilterChange("date_to", val)}
|
||||
onChange={(val) => handleFilterChange("date_to", val)}
|
||||
label="Do"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-table-responsive">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Čas</th>
|
||||
<th>Uživatel</th>
|
||||
<th>Akce</th>
|
||||
<th>Typ entity</th>
|
||||
<th>Popis</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Žádné záznamy k zobrazení</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{logs.length > 0 &&
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDatetime(log.created_at)}
|
||||
</td>
|
||||
<td className="fw-500">{log.username || "-"}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
|
||||
>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
|
||||
log.entity_type ||
|
||||
"-"}
|
||||
</td>
|
||||
<td>{log.description || "-"}</td>
|
||||
<td className="admin-mono">{log.user_ip || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<AuditLogEntry>
|
||||
columns={columns}
|
||||
rows={logs}
|
||||
rowKey={(log) => log.id}
|
||||
empty={<EmptyState title="Žádné záznamy k zobrazení" />}
|
||||
/>
|
||||
<Pagination
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,18 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import apiFetch from "../utils/api";
|
||||
import { dashboardOptions } from "../lib/queries/dashboard";
|
||||
import { require2FAOptions } from "../lib/queries/settings";
|
||||
import { getCzechDate } from "../utils/dashboardHelpers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { Card, Button, StatusChip, PageEnter } from "../ui";
|
||||
import { iconBadgeSx } from "../theme";
|
||||
import DashKpiCards from "../components/dashboard/DashKpiCards";
|
||||
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
||||
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
||||
@@ -102,9 +105,6 @@ export default function Dashboard() {
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
const [disableCode, setDisableCode] = useState("");
|
||||
|
||||
useModalLock(show2FASetup);
|
||||
useModalLock(show2FADisable);
|
||||
|
||||
// Punch (prichod/odchod) primo z dashboardu
|
||||
const handleQuickPunch = useCallback(() => {
|
||||
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
||||
@@ -221,57 +221,51 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dash">
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h4">
|
||||
Vítejte zpět, {user?.fullName || user?.username}
|
||||
</h1>
|
||||
<p className="admin-page-subtitle">{getCzechDate()}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{getCzechDate()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 2FA Required Banner */}
|
||||
{user?.require2FA && !user?.totpEnabled && (
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{
|
||||
border: "2px solid var(--danger)",
|
||||
background: "var(--danger-light)",
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
border: 2,
|
||||
borderColor: "error.main",
|
||||
backgroundColor:
|
||||
"rgba(var(--mui-palette-error-mainChannel) / 0.12)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-card-body"
|
||||
style={{
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div className="flex-row-gap">
|
||||
<div
|
||||
style={{
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--danger-light)",
|
||||
color: "var(--danger)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
},
|
||||
iconBadgeSx("error"),
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
@@ -285,35 +279,33 @@ export default function Dashboard() {
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600 }}>
|
||||
Dvoufaktorové ověření je povinné
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
|
||||
nemáte přístup k ostatním sekcím systému.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
onClick={handleStart2FASetup}
|
||||
disabled={totpSubmitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ flexShrink: 0 }}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading spinner */}
|
||||
{dashLoading && (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* KPI cards — only show if user has any admin-level permissions */}
|
||||
@@ -336,11 +328,14 @@ export default function Dashboard() {
|
||||
|
||||
{/* Main content grid */}
|
||||
{!dashLoading && (
|
||||
<motion.div
|
||||
className="dash-main-grid"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
|
||||
gap: 3,
|
||||
alignItems: "start",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{hasPermission("settings.audit") && (
|
||||
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
||||
@@ -351,86 +346,146 @@ export default function Dashboard() {
|
||||
)}
|
||||
|
||||
{/* Pravy sloupec: projekty + nabidky */}
|
||||
<div className="dash-right-col">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{dashData?.projects && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Aktivní projekty</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Aktivní projekty</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/projects"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Vše →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
</Button>
|
||||
</Box>
|
||||
{dashData.projects.active_projects.length === 0 && (
|
||||
<div className="dash-empty-row">Žádné aktivní projekty</div>
|
||||
)}
|
||||
{dashData.projects.active_projects.map(
|
||||
(p: {
|
||||
id: number;
|
||||
name: string;
|
||||
customer_name: string | null;
|
||||
}) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/projects/${p.id}`}
|
||||
className="dash-project-row"
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
<div className="dash-project-name">{p.name}</div>
|
||||
Žádné aktivní projekty
|
||||
</Typography>
|
||||
)}
|
||||
{dashData.projects.active_projects.map((p) => (
|
||||
<Box
|
||||
key={p.id}
|
||||
component={RouterLink}
|
||||
to={`/projects/${p.id}`}
|
||||
sx={{
|
||||
display: "block",
|
||||
py: 1.25,
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
"&:hover": { color: "primary.main" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{p.name}
|
||||
</Typography>
|
||||
{p.customer_name && (
|
||||
<div className="dash-project-customer">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{p.customer_name}
|
||||
</div>
|
||||
</Typography>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{dashData?.offers && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Nabídky</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Nabídky</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/offers"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Zobrazit →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
<div className="dash-stat-row">
|
||||
<span>Otevřené</span>
|
||||
<span className="admin-badge admin-badge-info">
|
||||
{dashData.offers.open_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dash-stat-row">
|
||||
<span>Převedené na objednávku</span>
|
||||
<span className="admin-badge admin-badge-success">
|
||||
{dashData.offers.converted_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dash-stat-row">
|
||||
<span>Zneplatněné</span>
|
||||
<span className="admin-badge admin-badge-warning">
|
||||
{dashData.offers.expired_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">Otevřené</Typography>
|
||||
<StatusChip color="info" label={dashData.offers.open_count} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Převedené na objednávku
|
||||
</Typography>
|
||||
<StatusChip
|
||||
color="success"
|
||||
label={dashData.offers.converted_count}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">Zneplatněné</Typography>
|
||||
<StatusChip
|
||||
color="warning"
|
||||
label={dashData.offers.expired_count}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Profile + Sessions */}
|
||||
{!dashLoading && (
|
||||
<div className="dash-bottom">
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
|
||||
gap: 3,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<DashProfile
|
||||
totpEnabled={totpEnabled}
|
||||
totpLoading={totpLoading}
|
||||
@@ -452,8 +507,8 @@ export default function Dashboard() {
|
||||
setDisableCode={setDisableCode}
|
||||
/>
|
||||
<DashSessions />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,34 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||
import { czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
leavePendingOptions,
|
||||
leaveProcessedOptions,
|
||||
} from "../lib/queries/leave";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
ConfirmDialog,
|
||||
Modal,
|
||||
StatusChip,
|
||||
Field,
|
||||
TextField,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
Tabs,
|
||||
TabPanel,
|
||||
type DataColumn,
|
||||
type TabDef,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -23,12 +39,6 @@ const leaveTypeLabels: Record<string, string> = {
|
||||
unpaid: "Neplacené volno",
|
||||
};
|
||||
|
||||
const leaveTypeClasses: Record<string, string> = {
|
||||
vacation: "badge-vacation",
|
||||
sick: "badge-sick",
|
||||
unpaid: "badge-unpaid",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "Čeká na schválení",
|
||||
approved: "Schváleno",
|
||||
@@ -36,13 +46,25 @@ const statusLabels: Record<string, string> = {
|
||||
cancelled: "Zrušeno",
|
||||
};
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
pending: "badge-pending",
|
||||
approved: "badge-approved",
|
||||
rejected: "badge-rejected",
|
||||
cancelled: "badge-cancelled",
|
||||
// Leave type → StatusChip color
|
||||
const leaveTypeColor: Record<
|
||||
string,
|
||||
"info" | "error" | "default" | "success" | "warning"
|
||||
> = {
|
||||
vacation: "info",
|
||||
sick: "error",
|
||||
unpaid: "default",
|
||||
};
|
||||
|
||||
// Status → StatusChip color
|
||||
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
|
||||
{
|
||||
pending: "warning",
|
||||
approved: "success",
|
||||
rejected: "error",
|
||||
cancelled: "default",
|
||||
};
|
||||
|
||||
interface RawLeaveRequest {
|
||||
id: number;
|
||||
leave_type: string;
|
||||
@@ -192,290 +214,231 @@ export default function LeaveApproval() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pendingCount > 0
|
||||
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
|
||||
: "Žádné čekající žádosti"}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
const tabs: TabDef[] = [
|
||||
{ value: "pending", label: `Ke schválení (${pendingCount})` },
|
||||
{ value: "processed", label: "Vyřízené" },
|
||||
];
|
||||
|
||||
{/* Tabs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-tabs mb-6">
|
||||
<button
|
||||
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("pending")}
|
||||
>
|
||||
Ke schválení
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
className="admin-badge badge-pending"
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.7rem",
|
||||
padding: "0.15rem 0.5rem",
|
||||
}}
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("processed")}
|
||||
>
|
||||
Vyřízené
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
const processedColumns: DataColumn<LeaveRequest>[] = [
|
||||
{
|
||||
key: "employee_name",
|
||||
header: "Zaměstnanec",
|
||||
width: "16%",
|
||||
bold: true,
|
||||
render: (req) => req.employee_name,
|
||||
},
|
||||
{
|
||||
key: "leave_type",
|
||||
header: "Typ",
|
||||
width: "13%",
|
||||
render: (req) => (
|
||||
<StatusChip
|
||||
label={leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||
color={leaveTypeColor[req.leave_type] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "date_from",
|
||||
header: "Od",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (req) => formatDate(req.date_from),
|
||||
},
|
||||
{
|
||||
key: "date_to",
|
||||
header: "Do",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (req) => formatDate(req.date_to),
|
||||
},
|
||||
{
|
||||
key: "total_days",
|
||||
header: "Dny",
|
||||
width: "6%",
|
||||
mono: true,
|
||||
render: (req) => String(req.total_days),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "13%",
|
||||
render: (req) => (
|
||||
<StatusChip
|
||||
label={statusLabels[req.status] || req.status}
|
||||
color={statusColor[req.status] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reviewer_name",
|
||||
header: "Schválil",
|
||||
width: "12%",
|
||||
render: (req) => req.reviewer_name || "—",
|
||||
},
|
||||
{
|
||||
key: "reviewer_note",
|
||||
header: "Poznámka",
|
||||
width: "12%",
|
||||
render: (req) =>
|
||||
req.reviewer_note ? (
|
||||
<Box component="span" title={req.reviewer_note}>
|
||||
{req.reviewer_note.length > 40
|
||||
? `${req.reviewer_note.substring(0, 40)}...`
|
||||
: req.reviewer_note}
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reviewed_at",
|
||||
header: "Vyřízeno",
|
||||
width: "8%",
|
||||
mono: true,
|
||||
render: (req) => formatDatetime(req.reviewed_at),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Schvalování nepřítomnosti"
|
||||
subtitle={
|
||||
pendingCount > 0
|
||||
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
|
||||
: "Žádné čekající žádosti"
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(v) => setActiveTab(v as "pending" | "processed")}
|
||||
tabs={tabs}
|
||||
/>
|
||||
|
||||
{/* Pending Tab */}
|
||||
{activeTab === "pending" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<TabPanel value="pending" current={activeTab}>
|
||||
{pendingRequests.length === 0 ? (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-empty-state">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-muted mb-4"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<p>Žádné čekající žádosti</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<EmptyState title="Žádné čekající žádosti" />
|
||||
</Card>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{pendingRequests.map((req) => (
|
||||
<div key={req.id} className="admin-card">
|
||||
<div
|
||||
className="admin-card-body"
|
||||
style={{ padding: "1.25rem" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
<Card key={req.id}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
gap: "1rem",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex-row-gap mb-2">
|
||||
<strong style={{ fontSize: "1rem" }}>
|
||||
{req.employee_name}
|
||||
</strong>
|
||||
<span
|
||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
||||
>
|
||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: "1.5rem",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="strong"
|
||||
sx={{ fontSize: "1rem", fontWeight: 600 }}
|
||||
>
|
||||
{req.employee_name}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={
|
||||
leaveTypeLabels[req.leave_type] || req.leave_type
|
||||
}
|
||||
color={leaveTypeColor[req.leave_type] ?? "default"}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 3,
|
||||
flexWrap: "wrap",
|
||||
fontSize: "0.875rem",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Box component="span">
|
||||
<strong>{formatDate(req.date_from)}</strong> —{" "}
|
||||
<strong>{formatDate(req.date_to)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
</Box>
|
||||
<Box component="span">
|
||||
{req.total_days}{" "}
|
||||
{czechPlural(req.total_days, "den", "dny", "dnů")} (
|
||||
{req.total_hours}h)
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||
Podáno: {formatDatetime(req.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
{req.notes && (
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
fontSize: "0.875rem",
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{req.notes}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexShrink: 0 }}>
|
||||
<Button
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
setApproveModal({ open: true, request: req })
|
||||
}
|
||||
className="admin-btn admin-btn-sm"
|
||||
style={{
|
||||
background: "var(--success-light)",
|
||||
color: "var(--success)",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
Schválit
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
setRejectModal({ open: true, request: req })
|
||||
}
|
||||
className="admin-btn admin-btn-sm"
|
||||
style={{
|
||||
background: "var(--danger-light)",
|
||||
color: "var(--danger)",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
Zamítnout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Processed Tab */}
|
||||
{activeTab === "processed" && (
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{processedRequests.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<p>Zatím žádné vyřízené žádosti</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Typ</th>
|
||||
<th>Od</th>
|
||||
<th>Do</th>
|
||||
<th>Dny</th>
|
||||
<th>Stav</th>
|
||||
<th>Schválil</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Vyřízeno</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processedRequests.map((req) => (
|
||||
<tr key={req.id}>
|
||||
<td>
|
||||
<strong>{req.employee_name}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
||||
>
|
||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(req.date_from)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(req.date_to)}
|
||||
</td>
|
||||
<td className="admin-mono">{req.total_days}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${statusClasses[req.status] || ""}`}
|
||||
>
|
||||
{statusLabels[req.status] || req.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{req.reviewer_name || "—"}</td>
|
||||
<td style={{ maxWidth: "200px" }}>
|
||||
{req.reviewer_note ? (
|
||||
<span title={req.reviewer_note}>
|
||||
{req.reviewer_note.length > 40
|
||||
? `${req.reviewer_note.substring(0, 40)}...`
|
||||
: req.reviewer_note}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="admin-mono"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{formatDatetime(req.reviewed_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<TabPanel value="processed" current={activeTab}>
|
||||
<Card>
|
||||
<DataTable<LeaveRequest>
|
||||
columns={processedColumns}
|
||||
rows={processedRequests}
|
||||
rowKey={(req) => req.id}
|
||||
empty={<EmptyState title="Zatím žádné vyřízené žádosti" />}
|
||||
/>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* Approve Confirmation */}
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={approveModal.open}
|
||||
onClose={() => setApproveModal({ open: false, request: null })}
|
||||
onConfirm={handleApprove}
|
||||
@@ -486,12 +449,11 @@ export default function LeaveApproval() {
|
||||
: ""
|
||||
}
|
||||
confirmText="Schválit"
|
||||
type="info"
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Reject Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={rejectModal.open && !!rejectModal.request}
|
||||
onClose={() => {
|
||||
setRejectModal({ open: false, request: null });
|
||||
@@ -499,31 +461,31 @@ export default function LeaveApproval() {
|
||||
}}
|
||||
onSubmit={handleReject}
|
||||
title="Zamítnout žádost"
|
||||
submitLabel="Zamítnout"
|
||||
submitText="Zamítnout"
|
||||
loading={rejectMutation.isPending}
|
||||
>
|
||||
{rejectModal.request && (
|
||||
<>
|
||||
<p className="text-secondary mb-4">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{rejectModal.request.employee_name} —{" "}
|
||||
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
|
||||
{formatDate(rejectModal.request.date_from)} —{" "}
|
||||
{formatDate(rejectModal.request.date_to)} (
|
||||
{rejectModal.request.total_days} dnů)
|
||||
</p>
|
||||
<FormField label="Důvod zamítnutí" required>
|
||||
<textarea
|
||||
</Typography>
|
||||
<Field label="Důvod zamítnutí" required>
|
||||
<TextField
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.target.value)}
|
||||
placeholder="Uveďte důvod zamítnutí..."
|
||||
className="admin-form-textarea"
|
||||
multiline
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</FormModal>
|
||||
</div>
|
||||
</Modal>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,23 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
ConfirmDialog,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -24,18 +35,40 @@ const statusLabels: Record<string, string> = {
|
||||
cancelled: "Zrušeno",
|
||||
};
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
pending: "badge-pending",
|
||||
approved: "badge-approved",
|
||||
rejected: "badge-rejected",
|
||||
cancelled: "badge-cancelled",
|
||||
// Leave type → StatusChip color
|
||||
const leaveTypeColor: Record<
|
||||
string,
|
||||
"info" | "error" | "default" | "success" | "warning"
|
||||
> = {
|
||||
vacation: "info",
|
||||
sick: "error",
|
||||
unpaid: "default",
|
||||
};
|
||||
|
||||
const leaveTypeClasses: Record<string, string> = {
|
||||
vacation: "badge-vacation",
|
||||
sick: "badge-sick",
|
||||
unpaid: "badge-unpaid",
|
||||
};
|
||||
// Status → StatusChip color
|
||||
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
|
||||
{
|
||||
pending: "warning",
|
||||
approved: "success",
|
||||
rejected: "error",
|
||||
cancelled: "default",
|
||||
};
|
||||
|
||||
const CancelIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function LeaveRequests() {
|
||||
const alert = useAlert();
|
||||
@@ -72,167 +105,167 @@ export default function LeaveRequests() {
|
||||
}
|
||||
};
|
||||
|
||||
function renderNoteCell(req: LeaveRequest) {
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const columns: DataColumn<LeaveRequest>[] = [
|
||||
{
|
||||
key: "leave_type",
|
||||
header: "Typ",
|
||||
width: "14%",
|
||||
render: (req) => (
|
||||
<StatusChip
|
||||
label={leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||
color={leaveTypeColor[req.leave_type] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "date_from",
|
||||
header: "Od",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (req) => formatDate(req.date_from),
|
||||
},
|
||||
{
|
||||
key: "date_to",
|
||||
header: "Do",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (req) => formatDate(req.date_to),
|
||||
},
|
||||
{
|
||||
key: "total_days",
|
||||
header: "Dny",
|
||||
width: "7%",
|
||||
mono: true,
|
||||
render: (req) => String(req.total_days),
|
||||
},
|
||||
{
|
||||
key: "total_hours",
|
||||
header: "Hodiny",
|
||||
width: "8%",
|
||||
mono: true,
|
||||
render: (req) => `${req.total_hours}h`,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "16%",
|
||||
render: (req) => (
|
||||
<StatusChip
|
||||
label={statusLabels[req.status] || req.status}
|
||||
color={statusColor[req.status] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "20%",
|
||||
render: (req) => {
|
||||
const truncate = (text: string) =>
|
||||
text.length > 40 ? `${text.substring(0, 40)}...` : text;
|
||||
if (req.status === "rejected" && req.reviewer_note) {
|
||||
return (
|
||||
<span
|
||||
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
|
||||
<Box
|
||||
component="span"
|
||||
title={req.reviewer_note}
|
||||
sx={{
|
||||
color: "error.main",
|
||||
fontSize: "0.875rem",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{truncate(req.reviewer_note)}
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (req.notes) {
|
||||
return (
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
<Box
|
||||
component="span"
|
||||
title={req.notes}
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
fontSize: "0.875rem",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{truncate(req.notes)}
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||
—
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Moje žádosti</h1>
|
||||
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{requests.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nemáte žádné žádosti</p>
|
||||
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
|
||||
Novou žádost můžete podat na stránce Docházka
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Typ</th>
|
||||
<th>Od</th>
|
||||
<th>Do</th>
|
||||
<th>Dny</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Stav</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Podáno</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map((req) => (
|
||||
<tr key={req.id}>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
||||
>
|
||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(req.date_from)}
|
||||
</td>
|
||||
<td className="admin-mono">{formatDate(req.date_to)}</td>
|
||||
<td className="admin-mono">{req.total_days}</td>
|
||||
<td className="admin-mono">{req.total_hours}h</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${statusClasses[req.status] || ""}`}
|
||||
>
|
||||
{statusLabels[req.status] || req.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: "200px" }}>
|
||||
{renderNoteCell(req)}
|
||||
</td>
|
||||
<td
|
||||
className="admin-mono"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{formatDatetime(req.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
{req.status === "pending" && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setCancelModal({ open: true, id: req.id })
|
||||
}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Podáno",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
render: (req) => formatDatetime(req.created_at),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "8%",
|
||||
align: "right",
|
||||
render: (req) =>
|
||||
req.status === "pending" ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={CancelIcon}
|
||||
onClick={() => setCancelModal({ open: true, id: req.id })}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
<ConfirmModal
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Moje žádosti"
|
||||
subtitle="Přehled žádostí o nepřítomnost"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<DataTable<LeaveRequest>
|
||||
columns={columns}
|
||||
rows={requests}
|
||||
rowKey={(req) => req.id}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="Zatím nemáte žádné žádosti"
|
||||
description="Novou žádost můžete podat na stránce Docházka"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={cancelModal.open}
|
||||
onClose={() => setCancelModal({ open: false, id: null })}
|
||||
onConfirm={handleCancel}
|
||||
title="Zrušit žádost"
|
||||
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
|
||||
confirmText="Zrušit žádost"
|
||||
type="warning"
|
||||
loading={cancelMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { keyframes } from "@mui/system";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
@@ -8,7 +14,13 @@ import {
|
||||
shouldShowSessionExpiredAlert,
|
||||
shouldShowLogoutAlert,
|
||||
} from "../utils/api";
|
||||
import FormField from "../components/FormField";
|
||||
import { TextField, Button, CheckboxField, Field } from "../ui";
|
||||
|
||||
// Drift animation for the decorative background orbs (was @keyframes float).
|
||||
const float = keyframes({
|
||||
"0%, 100%": { transform: "translate(0, 0)" },
|
||||
"50%": { transform: "translate(30px, -30px)" },
|
||||
});
|
||||
|
||||
export default function Login() {
|
||||
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
|
||||
@@ -132,11 +144,17 @@ export default function Login() {
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="admin-login">
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: ["100vh", "100dvh"],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,32 +162,120 @@ export default function Login() {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Shared glass card sx (replaces .admin-login-card)
|
||||
const cardSx = (theme: Theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
maxHeight: "calc(100dvh - 2rem)",
|
||||
p: { xs: 4, sm: 5 },
|
||||
bgcolor: "background.paper",
|
||||
backdropFilter: "blur(20px)",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: "16px",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.04)",
|
||||
...theme.applyStyles("dark", {
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.15)",
|
||||
}),
|
||||
position: "relative" as const,
|
||||
zIndex: 1,
|
||||
overflowY: "auto" as const,
|
||||
});
|
||||
|
||||
// Framer entrance + shake choreography for the card panels (unchanged)
|
||||
const cardMotion = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: shake
|
||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||
: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: shake
|
||||
? { x: { duration: 0.5, ease: "easeOut" as const } }
|
||||
: { duration: 0.3 },
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="admin-login"
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={
|
||||
animatingOut
|
||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||
: { scale: 1, opacity: 1, filter: "none" }
|
||||
}
|
||||
transition={
|
||||
animatingOut
|
||||
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
||||
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
||||
}
|
||||
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
sx={{
|
||||
minHeight: ["100vh", "100dvh"],
|
||||
maxHeight: ["100vh", "100dvh"],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
p: 2,
|
||||
bgcolor: "background.default",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div className="bg-orb bg-orb-1" />
|
||||
<div className="bg-orb bg-orb-2" />
|
||||
{/* Animated background orbs (replaces .bg-orb) */}
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: "absolute",
|
||||
borderRadius: "50%",
|
||||
filter: "blur(80px)",
|
||||
pointerEvents: "none",
|
||||
width: 400,
|
||||
height: 400,
|
||||
background: "rgba(var(--mui-palette-primary-mainChannel) / 0.16)",
|
||||
top: "10%",
|
||||
left: "20%",
|
||||
animation: `${float} 20s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: "absolute",
|
||||
borderRadius: "50%",
|
||||
filter: "blur(80px)",
|
||||
pointerEvents: "none",
|
||||
width: 320,
|
||||
height: 320,
|
||||
background: "rgba(120, 119, 198, 0.13)",
|
||||
bottom: "20%",
|
||||
right: "15%",
|
||||
animation: `${float} 25s ease-in-out infinite reverse`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
{/* Theme toggle (replaces .admin-login-theme-btn) */}
|
||||
<IconButton
|
||||
onClick={toggleTheme}
|
||||
className="admin-login-theme-btn"
|
||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "1rem",
|
||||
right: "1rem",
|
||||
width: 44,
|
||||
height: 44,
|
||||
bgcolor: "background.paper",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
color: "text.secondary",
|
||||
overflow: "hidden",
|
||||
zIndex: 10,
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
"&:hover": {
|
||||
bgcolor: "background.paper",
|
||||
color: "text.primary",
|
||||
borderColor: "rgba(var(--mui-palette-text-primaryChannel) / 0.15)",
|
||||
transform: "scale(1.05)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
@@ -181,10 +287,7 @@ export default function Login() {
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
|
||||
>
|
||||
) : (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
@@ -195,122 +298,112 @@ export default function Login() {
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!show2FA ? (
|
||||
<motion.div
|
||||
key="login"
|
||||
className="admin-login-card"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={
|
||||
shake
|
||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||
: { opacity: 1, y: 0 }
|
||||
}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={
|
||||
shake
|
||||
? { x: { duration: 0.5, ease: "easeOut" } }
|
||||
: { duration: 0.3 }
|
||||
}
|
||||
>
|
||||
<div className="admin-login-header">
|
||||
<img
|
||||
<Box key="login" component={motion.div} {...cardMotion} sx={cardSx}>
|
||||
<Box sx={{ textAlign: "center", mb: 4 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={
|
||||
theme === "dark"
|
||||
? "/api/admin/company-settings/logo?variant=dark"
|
||||
: "/api/admin/company-settings/logo?variant=light"
|
||||
}
|
||||
alt="Logo"
|
||||
className="admin-login-logo"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
theme === "dark"
|
||||
? "/images/logo-dark.png"
|
||||
: "/images/logo-light.png";
|
||||
}}
|
||||
sx={{ height: 48, width: "auto", mb: 2 }}
|
||||
/>
|
||||
<h1 className="admin-login-title">Interní systém</h1>
|
||||
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>
|
||||
</div>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: "text.primary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Interní systém
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
|
||||
Přihlaste se ke svému účtu
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit} className="admin-form">
|
||||
<FormField label="Uživatelské jméno nebo e-mail">
|
||||
<input
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Field label="Uživatelské jméno nebo e-mail">
|
||||
<TextField
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
className="admin-form-input"
|
||||
placeholder="Zadejte uživatelské jméno"
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Heslo">
|
||||
<input
|
||||
<Field label="Heslo">
|
||||
<TextField
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="admin-form-input"
|
||||
placeholder="Zadejte heslo"
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<CheckboxField
|
||||
label="Zapamatovat si mě"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
onChange={setRemember}
|
||||
/>
|
||||
<span>Zapamatovat si mě</span>
|
||||
</label>
|
||||
</Box>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Button type="submit" disabled={loading} fullWidth>
|
||||
{loading ? (
|
||||
<>
|
||||
<div
|
||||
className="admin-spinner"
|
||||
style={{ width: 20, height: 20, borderWidth: 2 }}
|
||||
<CircularProgress
|
||||
size={20}
|
||||
thickness={5}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Přihlašování...
|
||||
</>
|
||||
) : (
|
||||
"Přihlásit se"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<motion.div
|
||||
key="2fa"
|
||||
className="admin-login-card"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={
|
||||
shake
|
||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||
: { opacity: 1, y: 0 }
|
||||
}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={
|
||||
shake
|
||||
? { x: { duration: 0.5, ease: "easeOut" } }
|
||||
: { duration: 0.3 }
|
||||
}
|
||||
<Box key="2fa" component={motion.div} {...cardMotion} sx={cardSx}>
|
||||
<Box sx={{ textAlign: "center", mb: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"rgba(var(--mui-palette-primary-mainChannel) / 0.1)",
|
||||
color: "primary.main",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-login-header">
|
||||
<div className="admin-login-2fa-icon">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
@@ -322,26 +415,31 @@ export default function Login() {
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
|
||||
<p className="admin-login-subtitle">
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: "text.primary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Dvoufaktorové ověření
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
|
||||
{useBackupCode
|
||||
? "Zadejte jeden ze záložních kódů"
|
||||
: "Zadejte 6místný kód z autentizační aplikace"}
|
||||
</p>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handle2FASubmit} className="admin-form">
|
||||
<FormField
|
||||
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
|
||||
>
|
||||
<input
|
||||
ref={totpInputRef}
|
||||
<Box component="form" onSubmit={handle2FASubmit}>
|
||||
<Field label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}>
|
||||
<TextField
|
||||
inputRef={totpInputRef}
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode={useBackupCode ? "text" : "numeric"}
|
||||
pattern={useBackupCode ? undefined : "[0-9]*"}
|
||||
maxLength={useBackupCode ? 8 : 6}
|
||||
value={totpCode}
|
||||
onChange={(e) => {
|
||||
const val = useBackupCode
|
||||
@@ -351,80 +449,93 @@ export default function Login() {
|
||||
}}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
className="admin-form-input"
|
||||
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
|
||||
style={
|
||||
useBackupCode
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputMode: useBackupCode ? "text" : "numeric",
|
||||
pattern: useBackupCode ? undefined : "[0-9]*",
|
||||
maxLength: useBackupCode ? 8 : 6,
|
||||
style: useBackupCode
|
||||
? {}
|
||||
: {
|
||||
textAlign: "center",
|
||||
fontSize: "1.5rem",
|
||||
letterSpacing: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Button type="submit" disabled={loading} fullWidth>
|
||||
{loading ? (
|
||||
<>
|
||||
<div
|
||||
className="admin-spinner"
|
||||
style={{ width: 20, height: 20, borderWidth: 2 }}
|
||||
<CircularProgress
|
||||
size={20}
|
||||
thickness={5}
|
||||
color="inherit"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
Ověřování...
|
||||
</>
|
||||
) : (
|
||||
"Ověřit"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<div
|
||||
style={{
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
gap: 1,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setTotpCode("");
|
||||
}}
|
||||
className="admin-back-link"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 400,
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
color: "text.primary",
|
||||
textDecoration: "underline",
|
||||
background: "transparent",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{useBackupCode
|
||||
? "Použít autentizační aplikaci"
|
||||
: "Použít záložní kód"}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={handleBack}
|
||||
className="admin-back-link"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 400,
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
color: "text.primary",
|
||||
textDecoration: "underline",
|
||||
background: "transparent",
|
||||
},
|
||||
}}
|
||||
>
|
||||
← Zpět na přihlášení
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,28 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Button } from "../ui";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<motion.div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div
|
||||
className="admin-empty-icon"
|
||||
style={{ width: 80, height: 80, marginBottom: "1.5rem" }}
|
||||
>
|
||||
<svg
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "0.5rem",
|
||||
color: "var(--text-primary)",
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "60vh",
|
||||
gap: 2,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" sx={{ fontSize: "6rem", fontWeight: 700 }}>
|
||||
404
|
||||
</h2>
|
||||
<p>Stránka nebyla nalezena.</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
>
|
||||
</Typography>
|
||||
<Typography variant="body1">Stránka nebyla nalezena.</Typography>
|
||||
<Button component={RouterLink} to="/" sx={{ mt: 0.5 }}>
|
||||
Zpět na Dashboard
|
||||
</Link>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import {
|
||||
offerCustomersOptions,
|
||||
@@ -13,6 +12,23 @@ import {
|
||||
type CustomField,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
CheckboxField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -42,6 +58,100 @@ interface CustomerForm {
|
||||
vat_id: string;
|
||||
}
|
||||
|
||||
const PlusIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
const SmallPlusIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
const UpIcon = (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
const DownIcon = (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function OffersCustomers() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -261,230 +371,195 @@ export default function OffersCustomers() {
|
||||
const fullFieldOrder = getFullFieldOrder();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const columns: DataColumn<Customer>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
width: "26%",
|
||||
render: (c) => (
|
||||
<Box>
|
||||
<Box sx={{ fontWeight: 500, color: "text.primary" }}>{c.name}</Box>
|
||||
{c.street && (
|
||||
<Box sx={{ fontSize: "11px", color: "text.disabled" }}>
|
||||
{c.street}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "city",
|
||||
header: "Město",
|
||||
width: "18%",
|
||||
render: (c) => c.city || "—",
|
||||
},
|
||||
{
|
||||
key: "company_id",
|
||||
header: "IČO",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
render: (c) => c.company_id || "—",
|
||||
},
|
||||
{
|
||||
key: "vat_id",
|
||||
header: "DIČ",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
render: (c) => c.vat_id || "—",
|
||||
},
|
||||
{
|
||||
key: "quotation_count",
|
||||
header: "Nabídky",
|
||||
width: "10%",
|
||||
render: (c) => (
|
||||
<StatusChip label={String(c.quotation_count || 0)} color="info" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "10%",
|
||||
align: "right",
|
||||
render: (c) => {
|
||||
const cannotDelete = c.quotation_count > 0;
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Zákazníci</h1>
|
||||
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
|
||||
</div>
|
||||
{hasPermission("customers.create") && (
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Přidat zákazníka
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat zákazníky..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredCustomers.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<p>
|
||||
{search
|
||||
? "Žádní zákazníci odpovídající hledání."
|
||||
: "Zatím nejsou žádní zákazníci."}
|
||||
</p>
|
||||
{!search && hasPermission("customers.create") && (
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Přidat prvního zákazníka
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Název</th>
|
||||
<th>Město</th>
|
||||
<th>IČO</th>
|
||||
<th>DIČ</th>
|
||||
<th>Nabídky</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCustomers.map((customer) => (
|
||||
<tr key={customer.id}>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{customer.name}
|
||||
</div>
|
||||
{customer.street && (
|
||||
<div
|
||||
className="text-tertiary"
|
||||
style={{ fontSize: "11px" }}
|
||||
>
|
||||
{customer.street}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{customer.city || "—"}</td>
|
||||
<td>{customer.company_id || "—"}</td>
|
||||
<td>{customer.vat_id || "—"}</td>
|
||||
<td>
|
||||
<span className="admin-badge admin-badge-info">
|
||||
{customer.quotation_count || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
{hasPermission("customers.edit") && (
|
||||
<button
|
||||
onClick={() => openEditModal(customer)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEditModal(c)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{hasPermission("customers.delete") && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, customer })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
// Disabled buttons don't fire title/tooltip, so wrap in a titled
|
||||
// span when delete is blocked (customer still has quotations).
|
||||
<Box
|
||||
component="span"
|
||||
title={
|
||||
customer.quotation_count > 0
|
||||
? "Nelze smazat zákazníka s nabídkami"
|
||||
: "Smazat"
|
||||
cannotDelete ? "Nelze smazat zákazníka s nabídkami" : "Smazat"
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, customer: c })}
|
||||
aria-label={
|
||||
customer.quotation_count > 0
|
||||
cannotDelete
|
||||
? "Nelze smazat zákazníka s nabídkami"
|
||||
: "Smazat"
|
||||
}
|
||||
disabled={customer.quotation_count > 0}
|
||||
disabled={cannotDelete}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Zákazníci"
|
||||
subtitle="Správa zákazníků pro nabídky"
|
||||
actions={
|
||||
hasPermission("customers.create") ? (
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat zákazníka
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Hledat zákazníky..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card>
|
||||
<DataTable<Customer>
|
||||
columns={columns}
|
||||
rows={filteredCustomers}
|
||||
rowKey={(c) => c.id}
|
||||
empty={
|
||||
<EmptyState
|
||||
title={
|
||||
search
|
||||
? "Žádní zákazníci odpovídající hledání."
|
||||
: "Zatím nejsou žádní zákazníci."
|
||||
}
|
||||
action={
|
||||
!search && hasPermission("customers.create") ? (
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat prvního zákazníka
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingCustomer ? "Upravit zákazníka" : "Nový zákazník"}
|
||||
submitLabel={editingCustomer ? "Uložit změny" : "Vytvořit zákazníka"}
|
||||
submitText={editingCustomer ? "Uložit změny" : "Vytvořit zákazníka"}
|
||||
loading={saving}
|
||||
size="lg"
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="admin-form">
|
||||
<FormField label="Název" required>
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Název" required>
|
||||
<TextField
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Název firmy / jméno"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Ulice">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="Ulice">
|
||||
<TextField
|
||||
value={form.street}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, street: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Město">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Město">
|
||||
<TextField
|
||||
value={form.city}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, city: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="PSČ">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="PSČ">
|
||||
<TextField
|
||||
value={form.postal_code}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
@@ -492,24 +567,26 @@ export default function OffersCustomers() {
|
||||
postal_code: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Země">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
</Box>
|
||||
<Field label="Země">
|
||||
<TextField
|
||||
value={form.country}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, country: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="IČO">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="IČO">
|
||||
<TextField
|
||||
value={form.company_id}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
@@ -517,41 +594,43 @@ export default function OffersCustomers() {
|
||||
company_id: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="DIČ">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="DIČ">
|
||||
<TextField
|
||||
value={form.vat_id}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, vat_id: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Dynamic custom fields */}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ display: "block", marginBottom: 4 }}
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "block",
|
||||
mb: 0.5,
|
||||
fontWeight: 600,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Vlastní pole
|
||||
</label>
|
||||
</Typography>
|
||||
{customFields.map((field, idx) => (
|
||||
<div key={field._key} style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
className="admin-form-row"
|
||||
style={{ marginBottom: 0, alignItems: "flex-end" }}
|
||||
<Box key={field._key} sx={{ mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
label={idx === 0 ? "Název" : " "}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
<TextField
|
||||
label={idx === 0 ? "Název" : undefined}
|
||||
value={field.name}
|
||||
onChange={(e) => {
|
||||
const updated = [...customFields];
|
||||
@@ -561,23 +640,17 @@ export default function OffersCustomers() {
|
||||
};
|
||||
setCustomFields(updated);
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Kontakt"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={idx === 0 ? "Hodnota" : " "}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
alignItems: idx === 0 ? "flex-end" : "center",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
<TextField
|
||||
label={idx === 0 ? "Hodnota" : undefined}
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
const updated = [...customFields];
|
||||
@@ -587,11 +660,11 @@ export default function OffersCustomers() {
|
||||
};
|
||||
setCustomFields(updated);
|
||||
}}
|
||||
className="admin-form-input"
|
||||
style={{ flex: 1 }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
const key = `custom_${idx}`;
|
||||
setFieldOrder((prev) => {
|
||||
@@ -605,50 +678,40 @@ export default function OffersCustomers() {
|
||||
return k;
|
||||
});
|
||||
});
|
||||
setCustomFields(
|
||||
customFields.filter((_, i) => i !== idx),
|
||||
);
|
||||
setCustomFields(customFields.filter((_, i) => i !== idx));
|
||||
}}
|
||||
className="admin-btn-icon danger"
|
||||
title="Odebrat pole"
|
||||
aria-label="Odebrat pole"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<CheckboxField
|
||||
label={
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||
Zobrazit název v PDF
|
||||
</Typography>
|
||||
}
|
||||
checked={field.showLabel !== false}
|
||||
onChange={(e) => {
|
||||
onChange={(checked) => {
|
||||
const updated = [...customFields];
|
||||
updated[idx] = {
|
||||
...updated[idx],
|
||||
showLabel: e.target.checked,
|
||||
showLabel: checked,
|
||||
};
|
||||
setCustomFields(updated);
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "0.8rem" }}>
|
||||
Zobrazit název v PDF
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
startIcon={SmallPlusIcon}
|
||||
onClick={() =>
|
||||
setCustomFields([
|
||||
...customFields,
|
||||
@@ -660,90 +723,83 @@ export default function OffersCustomers() {
|
||||
},
|
||||
])
|
||||
}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ marginTop: 4, fontSize: "0.85rem" }}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Přidat pole
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Field order for PDF */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="admin-form-label">Pořadí polí v PDF</label>
|
||||
<small
|
||||
className="admin-form-hint"
|
||||
style={{ display: "block", marginBottom: 8 }}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Pořadí polí v PDF
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 1 }}
|
||||
>
|
||||
Určuje pořadí řádků v adresním bloku zákazníka na PDF nabídce.
|
||||
</small>
|
||||
<div className="admin-reorder-list">
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||
{fullFieldOrder.map((key, index) => (
|
||||
<div key={key} className="admin-reorder-item">
|
||||
<div className="admin-reorder-arrows">
|
||||
<button
|
||||
type="button"
|
||||
<Box
|
||||
key={key}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveField(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="admin-btn-icon"
|
||||
title="Nahoru"
|
||||
aria-label="Nahoru"
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
{UpIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveField(index, 1)}
|
||||
disabled={index === fullFieldOrder.length - 1}
|
||||
className="admin-btn-icon"
|
||||
title="Dolů"
|
||||
aria-label="Dolů"
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
className={`admin-reorder-label${key.startsWith("custom_") ? " accent" : ""}`}
|
||||
{DownIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: key.startsWith("custom_")
|
||||
? "primary.main"
|
||||
: "text.primary",
|
||||
fontWeight: key.startsWith("custom_") ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{getFieldDisplayName(key)}
|
||||
</span>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirm Modal */}
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, customer: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -751,9 +807,9 @@ export default function OffersCustomers() {
|
||||
message={`Opravdu chcete smazat zákazníka "${deleteConfirm.customer?.name}"? Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useRef, type ReactNode } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import RichEditor from "../components/RichEditor";
|
||||
import {
|
||||
@@ -16,8 +15,25 @@ import {
|
||||
type ScopeSection,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
Tabs,
|
||||
TabPanel,
|
||||
type DataColumn,
|
||||
type TabDef,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -33,45 +49,118 @@ interface ScopeForm {
|
||||
sections: ScopeSection[];
|
||||
}
|
||||
|
||||
const PlusIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
const UpIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
const DownIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function OffersTemplates() {
|
||||
const { hasPermission } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
|
||||
|
||||
if (!hasPermission("settings.templates")) return <Forbidden />;
|
||||
|
||||
const tabs: TabDef[] = [
|
||||
{ value: "items", label: "Šablony položek" },
|
||||
{ value: "scopes", label: "Šablony rozsahu" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Šablony</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
Šablony položek a rozsahu projektu
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Šablony"
|
||||
subtitle="Šablony položek a rozsahu projektu"
|
||||
/>
|
||||
|
||||
<div className="admin-tabs mb-4">
|
||||
<button
|
||||
className={`admin-tab ${activeTab === "items" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("items")}
|
||||
>
|
||||
Šablony položek
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === "scopes" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("scopes")}
|
||||
>
|
||||
Šablony rozsahu
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(v) => setActiveTab(v as "items" | "scopes")}
|
||||
tabs={tabs}
|
||||
/>
|
||||
|
||||
{activeTab === "items" ? <ItemTemplatesTab /> : <ScopeTemplatesTab />}
|
||||
</div>
|
||||
<TabPanel value="items" current={activeTab}>
|
||||
<ItemTemplatesTab />
|
||||
</TabPanel>
|
||||
<TabPanel value="scopes" current={activeTab}>
|
||||
<ScopeTemplatesTab />
|
||||
</TabPanel>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,155 +258,135 @@ function ItemTemplatesTab() {
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h3 className="admin-card-title">
|
||||
Šablony položek ({templates.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Přidat
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{templates.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<p>Zatím žádné šablony položek.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Název</th>
|
||||
<th>Popis</th>
|
||||
<th>Cena</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="fw-500">{t.name}</td>
|
||||
<td style={{ color: "var(--text-secondary)" }}>
|
||||
|
||||
const columns: DataColumn<ItemTemplate>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
width: "26%",
|
||||
bold: true,
|
||||
render: (t) => t.name,
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "Popis",
|
||||
width: "30%",
|
||||
render: (t) => (
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
{t.description || "—"}
|
||||
</td>
|
||||
<td>{Number(t.default_price).toFixed(2)}</td>
|
||||
<td style={{ color: "var(--text-secondary)" }}>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "default_price",
|
||||
header: "Cena",
|
||||
width: "14%",
|
||||
mono: true,
|
||||
render: (t) => Number(t.default_price).toFixed(2),
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
header: "Kategorie",
|
||||
width: "18%",
|
||||
render: (t) => (
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
{t.category || "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "12%",
|
||||
align: "right",
|
||||
render: (t) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEdit(t)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, template: t })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, template: t })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Šablony položek ({templates.length})
|
||||
</Typography>
|
||||
<Button size="small" startIcon={PlusIcon} onClick={openCreate}>
|
||||
Přidat
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<DataTable<ItemTemplate>
|
||||
columns={columns}
|
||||
rows={templates}
|
||||
rowKey={(t) => t.id}
|
||||
empty={<EmptyState title="Zatím žádné šablony položek." />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Item Template Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingTemplate ? "Upravit šablonu" : "Nová šablona položky"}
|
||||
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
|
||||
submitText={editingTemplate ? "Uložit" : "Vytvořit"}
|
||||
loading={saving}
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="admin-form">
|
||||
<FormField label="Název" required>
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Název" required>
|
||||
<TextField
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
</Field>
|
||||
<Field label="Popis">
|
||||
<TextField
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
description: e.target.value,
|
||||
}))
|
||||
setForm((p) => ({ ...p, description: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Výchozí cena">
|
||||
<input
|
||||
</Field>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Výchozí cena">
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.default_price}
|
||||
onChange={(e) =>
|
||||
@@ -326,26 +395,21 @@ function ItemTemplatesTab() {
|
||||
default_price: parseFloat(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="admin-form-input"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
slotProps={{ htmlInput: { step: "0.01", inputMode: "decimal" } }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Kategorie">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="Kategorie">
|
||||
<TextField
|
||||
value={form.category}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, category: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -353,10 +417,10 @@ function ItemTemplatesTab() {
|
||||
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -533,268 +597,256 @@ function ScopeTemplatesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h3 className="admin-card-title">
|
||||
Šablony rozsahu ({templates.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Přidat
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{templates.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<p>Zatím žádné šablony rozsahu.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Název</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="fw-500">{t.name}</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
onClick={() => openEdit(t)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, template: t })
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
|
||||
const columns: DataColumn<ScopeTemplate>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
width: "80%",
|
||||
bold: true,
|
||||
render: (t) => t.name,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "20%",
|
||||
align: "right",
|
||||
render: (t) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEdit(t)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, template: t })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Šablony rozsahu ({templates.length})
|
||||
</Typography>
|
||||
<Button size="small" startIcon={PlusIcon} onClick={openCreate}>
|
||||
Přidat
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<DataTable<ScopeTemplate>
|
||||
columns={columns}
|
||||
rows={templates}
|
||||
rowKey={(t) => t.id}
|
||||
empty={<EmptyState title="Zatím žádné šablony rozsahu." />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Scope Template Modal (large) */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={
|
||||
editingTemplate ? "Upravit šablonu rozsahu" : "Nová šablona rozsahu"
|
||||
}
|
||||
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
|
||||
size="lg"
|
||||
submitText={editingTemplate ? "Uložit" : "Vytvořit"}
|
||||
maxWidth="lg"
|
||||
loading={saving}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<FormField label="Název šablony" required>
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Název šablony" required>
|
||||
<TextField
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label mb-2">Sekce</label>
|
||||
<div className="admin-scope-list">
|
||||
<Box>
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "block",
|
||||
mb: 1,
|
||||
fontWeight: 600,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Sekce
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{form.sections.map((section, index) => (
|
||||
<div key={section._key} className="admin-scope-section">
|
||||
<div className="admin-scope-section-header">
|
||||
<span className="admin-scope-number">{index + 1}.</span>
|
||||
<span className="admin-scope-title">
|
||||
{section.title ||
|
||||
section.title_cz ||
|
||||
`Sekce ${index + 1}`}
|
||||
</span>
|
||||
<div className="admin-scope-actions">
|
||||
<button
|
||||
type="button"
|
||||
<Box
|
||||
key={section._key}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: "text.secondary" }}
|
||||
>
|
||||
{index + 1}.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, flex: 1 }}
|
||||
noWrap
|
||||
>
|
||||
{section.title || section.title_cz || `Sekce ${index + 1}`}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="admin-btn-icon"
|
||||
title="Posunout nahoru"
|
||||
aria-label="Posunout nahoru"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
{UpIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveSection(index, 1)}
|
||||
disabled={index === form.sections.length - 1}
|
||||
className="admin-btn-icon"
|
||||
title="Posunout dolů"
|
||||
aria-label="Posunout dolů"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{DownIcon}
|
||||
</IconButton>
|
||||
{form.sections.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeSection(index)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Odebrat"
|
||||
aria-label="Odebrat"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField
|
||||
label={
|
||||
<>
|
||||
<span className="offers-lang-badge">EN</span> Název
|
||||
sekce
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<StatusChip label="EN" color="info" />
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Název sekce
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
value={section.title}
|
||||
onChange={(e) =>
|
||||
updateSection(index, "title", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Název sekce (anglicky)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={
|
||||
<>
|
||||
<span className="offers-lang-badge offers-lang-badge-cz">
|
||||
CZ
|
||||
</span>{" "}
|
||||
Název sekce
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
<StatusChip label="CZ" color="success" />
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Název sekce
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
value={section.title_cz}
|
||||
onChange={(e) =>
|
||||
updateSection(index, "title_cz", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Název sekce (česky)"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Obsah">
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Field label="Obsah">
|
||||
<RichEditor
|
||||
value={section.content}
|
||||
onChange={(val) => updateSection(index, "content", val)}
|
||||
placeholder="Obsah sekce..."
|
||||
minHeight="150px"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
startIcon={PlusIcon}
|
||||
onClick={addSection}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat sekci
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
Přidat sekci
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -802,9 +854,9 @@ function ScopeTemplatesTab() {
|
||||
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import apiFetch from "../utils/api";
|
||||
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 apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import SortIcon from "../components/SortIcon";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
orderListOptions,
|
||||
@@ -20,7 +17,26 @@ import {
|
||||
} from "../lib/queries/orders";
|
||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import Pagination from "../components/Pagination";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
Select,
|
||||
StatusChip,
|
||||
CheckboxField,
|
||||
FileUpload,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -31,11 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
stornovana: "Stornována",
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
prijata: "admin-badge-order-prijata",
|
||||
v_realizaci: "admin-badge-order-realizace",
|
||||
dokoncena: "admin-badge-order-dokoncena",
|
||||
stornovana: "admin-badge-order-stornovana",
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "success" | "error" | "warning" | "info"
|
||||
> = {
|
||||
prijata: "info",
|
||||
v_realizaci: "warning",
|
||||
dokoncena: "success",
|
||||
stornovana: "error",
|
||||
};
|
||||
|
||||
interface Order {
|
||||
@@ -51,12 +70,94 @@ interface Order {
|
||||
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() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort("order_number");
|
||||
const { sort, order, handleSort } = useTableSort("order_number");
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
@@ -204,7 +305,9 @@ export default function Orders() {
|
||||
pagination,
|
||||
isPending,
|
||||
isFetching,
|
||||
} = usePaginatedQuery<Order>(orderListOptions({ search, sort, order, page }));
|
||||
} = usePaginatedQuery<Order>(
|
||||
orderListOptions({ search: debouncedSearch, sort, order, page }),
|
||||
);
|
||||
|
||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||
|
||||
@@ -221,281 +324,195 @@ export default function Orders() {
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Objednávky</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? orders.length}{" "}
|
||||
{czechPlural(
|
||||
pagination?.total ?? orders.length,
|
||||
const total = pagination?.total ?? orders.length;
|
||||
const subtitle = `${total} ${czechPlural(
|
||||
total,
|
||||
"objednávka",
|
||||
"objednávky",
|
||||
"objednávek",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission("orders.create") && (
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
onClick={openCreate}
|
||||
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>
|
||||
Vytvořit objednávku
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}`;
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
const columns: DataColumn<Order>[] = [
|
||||
{
|
||||
key: "order_number",
|
||||
header: "Číslo",
|
||||
width: "14%",
|
||||
sortKey: "order_number",
|
||||
mono: true,
|
||||
render: (o) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/orders/${o.id}`}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<input
|
||||
type="text"
|
||||
{o.order_number}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "quotation",
|
||||
header: "Nabídka",
|
||||
width: "14%",
|
||||
render: (o) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/offers/${o.quotation_id}`}
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{o.quotation_number}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "customer",
|
||||
header: "Zákazník",
|
||||
width: "20%",
|
||||
render: (o) => o.customer_name || "—",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "13%",
|
||||
sortKey: "status",
|
||||
render: (o) => (
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[o.status] || o.status}
|
||||
color={STATUS_COLORS[o.status] || "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Datum",
|
||||
width: "12%",
|
||||
sortKey: "created_at",
|
||||
mono: true,
|
||||
render: (o) => formatDate(o.created_at),
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "13%",
|
||||
align: "right",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (o) => formatCurrency(o.total, o.currency),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "14%",
|
||||
align: "right",
|
||||
render: (o) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/orders/${o.id}`)}
|
||||
aria-label="Detail"
|
||||
title="Detail"
|
||||
>
|
||||
{ViewIcon}
|
||||
</IconButton>
|
||||
{o.invoice_id ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => navigate(`/invoices/${o.invoice_id}`)}
|
||||
aria-label="Zobrazit fakturu"
|
||||
title="Zobrazit fakturu"
|
||||
>
|
||||
{InvoiceViewIcon}
|
||||
</IconButton>
|
||||
) : (
|
||||
hasPermission("invoices.create") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/invoices/new?fromOrder=${o.id}`)}
|
||||
aria-label="Vytvořit fakturu"
|
||||
title="Vytvořit fakturu"
|
||||
>
|
||||
{InvoiceCreateIcon}
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{hasPermission("orders.delete") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Objednávky"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
hasPermission("orders.create") ? (
|
||||
<Button startIcon={PlusIcon} onClick={openCreate}>
|
||||
Vytvořit objednávku
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<path d="M16 10a4 4 0 0 1-8 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nejsou žádné objednávky.</p>
|
||||
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
|
||||
Objednávky se vytvářejí z nabídek.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("order_number")}
|
||||
>
|
||||
Číslo{" "}
|
||||
<SortIcon
|
||||
column="order_number"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
<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."
|
||||
/>
|
||||
</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} />
|
||||
</div>
|
||||
</motion.div>
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => {
|
||||
setDeleteConfirm({ show: false, order: null });
|
||||
@@ -504,58 +521,55 @@ export default function Orders() {
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat objednávku"
|
||||
message={
|
||||
<>
|
||||
Opravdu chcete smazat objednávku "
|
||||
{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>
|
||||
</>
|
||||
deleteConfirm.order
|
||||
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
|
||||
: ""
|
||||
}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
>
|
||||
<CheckboxField
|
||||
label="Smazat i soubory projektu na disku"
|
||||
checked={deleteFiles}
|
||||
onChange={setDeleteFiles}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSubmit={handleCreate}
|
||||
title="Vytvořit objednávku bez nabídky"
|
||||
submitLabel="Vytvořit"
|
||||
subtitle={`Číslo objednávky: ${
|
||||
nextNumberQuery.data?.number ??
|
||||
nextNumberQuery.data?.next_number ??
|
||||
"…"
|
||||
} (přiděleno automaticky)`}
|
||||
submitText="Vytvořit"
|
||||
loading={creating}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Zákazník">
|
||||
<select
|
||||
<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={(e) =>
|
||||
setCreateForm({ ...createForm, customer_id: e.target.value })
|
||||
onChange={(value) =>
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
>
|
||||
<option value="">— bez zákazníka —</option>
|
||||
{(customersQuery.data ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Číslo objednávky zákazníka">
|
||||
<input
|
||||
type="text"
|
||||
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({
|
||||
@@ -563,83 +577,86 @@ export default function Orders() {
|
||||
customer_order_number: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Měna">
|
||||
<select
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Měna">
|
||||
<Select
|
||||
value={createForm.currency}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, currency: e.target.value })
|
||||
onChange={(value) =>
|
||||
setCreateForm({ ...createForm, currency: value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
>
|
||||
<option value="CZK">CZK</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Sazba DPH (%)">
|
||||
<input
|
||||
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 })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Cena za jednotku (bez DPH)">
|
||||
<input
|
||||
<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 })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Množství">
|
||||
<input
|
||||
</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 })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
step="1"
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormField label="Předmět (scope)">
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Předmět (scope)">
|
||||
<TextField
|
||||
value={createForm.scope_title}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, scope_title: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
<Field label="Popis">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={createForm.scope_description}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
@@ -647,109 +664,41 @@ export default function Orders() {
|
||||
scope_description: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={createForm.notes}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, notes: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Příloha (PO zákazníka, PDF)">
|
||||
{orderAttachment ? (
|
||||
<div className="flex-row gap-2">
|
||||
<span className="text-md">
|
||||
{orderAttachment.name}{" "}
|
||||
<span className="text-tertiary">
|
||||
({(orderAttachment.size / 1024).toFixed(0)} KB)
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrderAttachment(null)}
|
||||
className="admin-btn-icon"
|
||||
title="Odebrat"
|
||||
style={{ marginLeft: "auto" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
Vybrat soubor
|
||||
<input
|
||||
type="file"
|
||||
<Field label="Příloha (PO zákazníka, PDF)">
|
||||
<FileUpload
|
||||
files={orderAttachment ? [orderAttachment] : []}
|
||||
onFilesChange={(files) => setOrderAttachment(files[0] ?? null)}
|
||||
accept="application/pdf"
|
||||
onChange={(e) =>
|
||||
setOrderAttachment(e.target.files?.[0] || null)
|
||||
}
|
||||
style={{ display: "none" }}
|
||||
multiple={false}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
<CheckboxField
|
||||
label="Účtovat DPH"
|
||||
checked={createForm.apply_vat}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, apply_vat: e.target.checked })
|
||||
}
|
||||
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
|
||||
/>
|
||||
<span>Účtovat DPH</span>
|
||||
</label>
|
||||
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
<CheckboxField
|
||||
label="Vytvořit propojený projekt"
|
||||
checked={createForm.create_project}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
create_project: e.target.checked,
|
||||
})
|
||||
}
|
||||
onChange={(v) => setCreateForm({ ...createForm, create_project: v })}
|
||||
/>
|
||||
<span>Vytvořit propojený projekt</span>
|
||||
</label>
|
||||
|
||||
<p className="admin-form-hint">
|
||||
Číslo objednávky:{" "}
|
||||
<strong>
|
||||
{nextNumberQuery.data?.number ??
|
||||
nextNumberQuery.data?.next_number ??
|
||||
"…"}
|
||||
</strong>{" "}
|
||||
(přiděleno automaticky)
|
||||
</p>
|
||||
</div>
|
||||
</FormModal>
|
||||
</div>
|
||||
</Modal>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import PlanGrid from "../components/PlanGrid";
|
||||
import PlanCellModal from "../components/PlanCellModal";
|
||||
import PlanCategoriesModal from "../components/PlanCategoriesModal";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { Button, Alert, LoadingState, PageEnter } from "../ui";
|
||||
import { projectListOptions, type Project } from "../lib/queries/projects";
|
||||
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
|
||||
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
|
||||
@@ -542,127 +545,109 @@ export default function PlanWork() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plan-work-page">
|
||||
<motion.h1
|
||||
className="admin-page-title"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<PageEnter sx={{ position: "relative" }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
Plán prací
|
||||
</motion.h1>
|
||||
</Typography>
|
||||
|
||||
{!canEdit && (
|
||||
<motion.div
|
||||
className="plan-banner"
|
||||
role="status"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<span className="plan-banner-icon" aria-hidden>
|
||||
i
|
||||
</span>
|
||||
<span>
|
||||
<Box role="status" sx={{ mb: 2 }}>
|
||||
<Alert severity="info">
|
||||
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
||||
jsou dostupné oprávněným uživatelům.
|
||||
</span>
|
||||
</motion.div>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="plan-toolbar"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Nav buttons grouped so they stay on one row when the toolbar
|
||||
stacks on mobile (see .plan-toolbar-nav in plan.css). On desktop
|
||||
the wrapper is an inline-flex with the same 12px gap, so the
|
||||
layout is visually identical. */}
|
||||
<div className="plan-toolbar-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={goToToday}
|
||||
>
|
||||
wraps on mobile. */}
|
||||
<Box sx={{ display: "inline-flex", gap: 1 }}>
|
||||
<Button variant="outlined" color="inherit" onClick={goToToday}>
|
||||
Dnes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={goPrev}
|
||||
aria-label="Předchozí"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={goNext}
|
||||
aria-label="Další"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<span className="plan-range-label-slot">
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
className="plan-range-label"
|
||||
<motion.div
|
||||
key={`${isoDate(range.from)}-${view}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
||||
</motion.span>
|
||||
</Typography>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
<div
|
||||
className="plan-view-toggle"
|
||||
</Box>
|
||||
<Box
|
||||
role="group"
|
||||
aria-label="Měřítko zobrazení"
|
||||
sx={{ display: "inline-flex", gap: 1 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
view === "week"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
<Button
|
||||
variant={view === "week" ? "contained" : "outlined"}
|
||||
color={view === "week" ? "primary" : "inherit"}
|
||||
onClick={() => setViewWithAnim("week")}
|
||||
>
|
||||
Týden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
view === "month"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === "month" ? "contained" : "outlined"}
|
||||
color={view === "month" ? "primary" : "inherit"}
|
||||
onClick={() => setViewWithAnim("month")}
|
||||
>
|
||||
Měsíc
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary plan-toolbar-cat"
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => setCatModalOpen(true)}
|
||||
>
|
||||
Správa kategorií
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{gridLoading && (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
)}
|
||||
{gridLoading && <LoadingState />}
|
||||
|
||||
{/*
|
||||
Grid entrance: same pattern as the h1 / banner / toolbar above
|
||||
@@ -681,11 +666,6 @@ export default function PlanWork() {
|
||||
patch the cache in place and pulse a single cell via pulseKey.
|
||||
*/}
|
||||
{grid ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut", delay: 0.18 }}
|
||||
>
|
||||
<PlanGrid
|
||||
data={grid}
|
||||
projects={projects}
|
||||
@@ -694,7 +674,6 @@ export default function PlanWork() {
|
||||
pulseKey={lastMutated}
|
||||
onCellClick={openCell}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<PlanCellModal
|
||||
@@ -718,6 +697,6 @@ export default function PlanWork() {
|
||||
onClose={() => setCatModalOpen(false)}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
import {
|
||||
projectDetailOptions,
|
||||
@@ -13,11 +16,21 @@ import {
|
||||
} from "../lib/queries/projects";
|
||||
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormField from "../components/FormField";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import ProjectFileManager from "../components/ProjectFileManager";
|
||||
import apiFetch from "../utils/api";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Field,
|
||||
TextField,
|
||||
Select,
|
||||
DateField,
|
||||
StatusChip,
|
||||
CheckboxField,
|
||||
ConfirmDialog,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
headerActionsSx,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -27,6 +40,15 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
zruseny: "Zrušený",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "success" | "error" | "warning" | "info"
|
||||
> = {
|
||||
aktivni: "success",
|
||||
dokonceny: "info",
|
||||
zruseny: "default",
|
||||
};
|
||||
|
||||
function formatNoteDate(dateStr: string) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
@@ -51,6 +73,36 @@ interface ProjectForm {
|
||||
responsible_user_id: string;
|
||||
}
|
||||
|
||||
const BackIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
@@ -226,424 +278,362 @@ export default function ProjectDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const canEdit = hasPermission("projects.edit");
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/projects"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět"
|
||||
aria-label="Zpět"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
Zpět
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Typography variant="h4">
|
||||
Projekt {project.project_number}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[project.status] || project.status}
|
||||
color={STATUS_COLORS[project.status] || "default"}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{canEdit && (
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Uložit"
|
||||
)}
|
||||
</button>
|
||||
<Box sx={headerActionsSx}>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Ukládání..." : "Uložit"}
|
||||
</Button>
|
||||
{!project.order_id && (
|
||||
<button
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Smazat
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Form */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Základní údaje</h3>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Číslo projektu">
|
||||
<input
|
||||
type="text"
|
||||
value={project.project_number}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
cursor: "default",
|
||||
{/* Basic info form */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Název">
|
||||
<input
|
||||
type="text"
|
||||
>
|
||||
<Field label="Číslo projektu">
|
||||
<TextField value={project.project_number} fullWidth disabled />
|
||||
</Field>
|
||||
<Field label="Název">
|
||||
<TextField
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Název projektu"
|
||||
fullWidth
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Zákazník">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="Zákazník">
|
||||
<TextField
|
||||
value={project.customer_name || "—"}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
cursor: "default",
|
||||
}}
|
||||
fullWidth
|
||||
disabled
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Zodpovědná osoba">
|
||||
<select
|
||||
</Field>
|
||||
<Field label="Zodpovědná osoba">
|
||||
<Select
|
||||
value={form.responsible_user_id}
|
||||
onChange={(e) =>
|
||||
updateForm("responsible_user_id", e.target.value)
|
||||
}
|
||||
className="admin-form-select"
|
||||
onChange={(v) => updateForm("responsible_user_id", v)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<option value="">— Nevybráno —</option>
|
||||
<MenuItem value="">— Nevybráno —</MenuItem>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
<MenuItem key={u.id} value={String(u.id)}>
|
||||
{u.name}
|
||||
</option>
|
||||
</MenuItem>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<FormField label="Stav">
|
||||
<select
|
||||
</Select>
|
||||
</Field>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Stav">
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(e) => updateForm("status", e.target.value)}
|
||||
className="admin-form-select"
|
||||
onChange={(v) => updateForm("status", v)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<option value="aktivni">Aktivní</option>
|
||||
<option value="dokonceny">Dokončený</option>
|
||||
<option value="zruseny">Zrušený</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Datum zahájení">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
<MenuItem value="aktivni">Aktivní</MenuItem>
|
||||
<MenuItem value="dokonceny">Dokončený</MenuItem>
|
||||
<MenuItem value="zruseny">Zrušený</MenuItem>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Datum zahájení">
|
||||
<DateField
|
||||
value={form.start_date}
|
||||
onChange={(val: string) => updateForm("start_date", val)}
|
||||
onChange={(val) => updateForm("start_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Datum ukončení">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
</Field>
|
||||
<Field label="Datum ukončení">
|
||||
<DateField
|
||||
value={form.end_date}
|
||||
onChange={(val: string) => updateForm("end_date", val)}
|
||||
onChange={(val) => updateForm("end_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Poznámky</h3>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Poznámky
|
||||
</Typography>
|
||||
|
||||
{/* Add note */}
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
multiline
|
||||
minRows={2}
|
||||
placeholder="Napište poznámku..."
|
||||
style={{ resize: "vertical", width: "100%" }}
|
||||
fullWidth
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
||||
handleAddNote();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleAddNote}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
disabled={addingNote || !newNote.trim()}
|
||||
>
|
||||
{addingNote ? (
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
) : (
|
||||
"Přidat poznámku"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{addingNote ? "Přidávání..." : "Přidat poznámku"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legacy notes (read-only) */}
|
||||
{project.notes && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--text-secondary)",
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-tertiary)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 0.5 }}
|
||||
>
|
||||
Starší poznámka (před zavedením systému)
|
||||
</div>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
|
||||
</div>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{project.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Notes list */}
|
||||
{notes.length === 0 && !project.notes && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "1rem 0",
|
||||
}}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center", py: 2 }}
|
||||
>
|
||||
Zatím žádné poznámky
|
||||
</div>
|
||||
</Typography>
|
||||
)}
|
||||
{(notes.length > 0 || project.notes) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
<Box
|
||||
key={note.id}
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
style={{
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "0.25rem",
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{note.user_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNoteDate(note.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||
>
|
||||
{note.content}
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<button
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
className="admin-btn-icon"
|
||||
title="Smazat poznámku"
|
||||
aria-label="Smazat poznámku"
|
||||
disabled={deletingNoteId === note.id}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
opacity: deletingNoteId === note.id ? 0.5 : 1,
|
||||
}}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{deletingNoteId === note.id ? (
|
||||
<div
|
||||
className="admin-spinner"
|
||||
style={{ width: 14, height: 14, borderWidth: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
{TrashIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Card>
|
||||
|
||||
{/* Project File Manager */}
|
||||
<motion.div
|
||||
style={{ marginBottom: "1rem" }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<ProjectFileManager
|
||||
projectId={project.id}
|
||||
projectNumber={project.project_number}
|
||||
hasPermission={hasPermission}
|
||||
hasNasFolder={project.has_nas_folder ?? false}
|
||||
/>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Links */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Propojení
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Propojení</h3>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Objednávka">
|
||||
<div>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Objednávka
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{project.order_id ? (
|
||||
<Link
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/orders/${project.order_id}`}
|
||||
className="link-accent"
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{project.order_number}
|
||||
{project.order_status && (
|
||||
<span
|
||||
className="text-tertiary"
|
||||
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1 }}
|
||||
>
|
||||
(
|
||||
{STATUS_LABELS[project.order_status] ||
|
||||
project.order_status}
|
||||
)
|
||||
</span>
|
||||
</Box>
|
||||
)}
|
||||
</Link>
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Nabídka">
|
||||
<div>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Nabídka
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{project.quotation_id ? (
|
||||
<Link
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/offers/${project.quotation_id}`}
|
||||
className="link-accent"
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{project.quotation_number}
|
||||
</Link>
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm}
|
||||
onClose={() => {
|
||||
setDeleteConfirm(false);
|
||||
@@ -651,30 +641,19 @@ export default function ProjectDetail() {
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat projekt"
|
||||
message={
|
||||
<>
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
message={`Opravdu chcete smazat projekt „${project.project_number} – ${project.name}"? Tato akce je nevratná.`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
>
|
||||
{project.has_nas_folder && (
|
||||
<CheckboxField
|
||||
label="Smazat i soubory na disku"
|
||||
checked={deleteFiles}
|
||||
onChange={setDeleteFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
|
||||
import { formatDate, czechPlural } from "../utils/formatters";
|
||||
import SortIcon from "../components/SortIcon";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
@@ -20,7 +17,24 @@ import {
|
||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||
import { userListOptions } from "../lib/queries/users";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import Pagination from "../components/Pagination";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
FilterBar,
|
||||
TextField,
|
||||
Select,
|
||||
DateField,
|
||||
StatusChip,
|
||||
CheckboxField,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -30,10 +44,13 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
zruseny: "Zrušený",
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
aktivni: "admin-badge-project-aktivni",
|
||||
dokonceny: "admin-badge-project-dokonceny",
|
||||
zruseny: "admin-badge-project-zruseny",
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "success" | "error" | "warning" | "info"
|
||||
> = {
|
||||
aktivni: "success",
|
||||
dokonceny: "info",
|
||||
zruseny: "default",
|
||||
};
|
||||
|
||||
interface Project {
|
||||
@@ -49,12 +66,56 @@ interface Project {
|
||||
order_number?: string;
|
||||
}
|
||||
|
||||
const PlusIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Projects() {
|
||||
const alert = useAlert();
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const { sort, order, handleSort, activeSort } =
|
||||
useTableSort("project_number");
|
||||
const { sort, order, handleSort } = useTableSort("project_number");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
@@ -175,267 +236,190 @@ export default function Projects() {
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
const columns: DataColumn<Project>[] = [
|
||||
{
|
||||
key: "project_number",
|
||||
header: "Číslo",
|
||||
width: "12%",
|
||||
sortKey: "project_number",
|
||||
mono: true,
|
||||
render: (p) => (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/projects/${p.id}`}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Projekty</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? projects.length}{" "}
|
||||
{czechPlural(
|
||||
pagination?.total ?? projects.length,
|
||||
"projekt",
|
||||
"projekty",
|
||||
"projektů",
|
||||
{p.project_number}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
width: "16%",
|
||||
sortKey: "name",
|
||||
bold: true,
|
||||
render: (p) => p.name || "—",
|
||||
},
|
||||
{
|
||||
key: "customer",
|
||||
header: "Zákazník",
|
||||
width: "13%",
|
||||
render: (p) => p.customer_name || "—",
|
||||
},
|
||||
{
|
||||
key: "responsible",
|
||||
header: "Zodpovědná osoba",
|
||||
width: "14%",
|
||||
render: (p) => p.responsible_user_name || "—",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "10%",
|
||||
sortKey: "status",
|
||||
render: (p) => (
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[p.status] || p.status}
|
||||
color={STATUS_COLORS[p.status] || "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "order",
|
||||
header: "Objednávka",
|
||||
width: "12%",
|
||||
render: (p) =>
|
||||
p.order_id ? (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/orders/${p.order_id}`}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{p.order_number}
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "start_date",
|
||||
header: "Začátek",
|
||||
width: "8%",
|
||||
mono: true,
|
||||
render: (p) => formatDate(p.start_date),
|
||||
},
|
||||
{
|
||||
key: "end_date",
|
||||
header: "Konec",
|
||||
width: "8%",
|
||||
mono: true,
|
||||
render: (p) => formatDate(p.end_date),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "7%",
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/projects/${p.id}`)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
{hasPermission("projects.delete") && !p.order_id && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteTarget(p)}
|
||||
aria-label="Smazat"
|
||||
title="Smazat projekt"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission("projects.create") && (
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
onClick={openCreate}
|
||||
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 projekt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
return (
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 3,
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<input
|
||||
type="text"
|
||||
<Typography variant="h4">Projekty</Typography>
|
||||
{hasPermission("projects.create") && (
|
||||
<Button startIcon={PlusIcon} onClick={openCreate}>
|
||||
Přidat projekt
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nejsou žádné projekty.</p>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<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".
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("project_number")}
|
||||
>
|
||||
Číslo{" "}
|
||||
<SortIcon
|
||||
column="project_number"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
Název{" "}
|
||||
<SortIcon column="name" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th>Zákazník</th>
|
||||
<th>Zodpovědná osoba</th>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
Stav{" "}
|
||||
<SortIcon
|
||||
column="status"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("start_date")}
|
||||
>
|
||||
Začátek{" "}
|
||||
<SortIcon
|
||||
column="start_date"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("end_date")}
|
||||
>
|
||||
Konec{" "}
|
||||
<SortIcon
|
||||
column="end_date"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th>Typ</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>
|
||||
<span className="admin-badge">
|
||||
{p.order_id ? "Z objednávky" : "Samostatný"}
|
||||
</span>
|
||||
</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
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending &&
|
||||
deleteTarget?.id === p.id ? (
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
) : (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => {
|
||||
setDeleteTarget(null);
|
||||
@@ -444,135 +428,119 @@ export default function Projects() {
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat projekt"
|
||||
message={
|
||||
<>
|
||||
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
|
||||
<label
|
||||
className="admin-form-checkbox"
|
||||
style={{ marginTop: "1rem", display: "flex" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
onChange={(e) => setDeleteFiles(e.target.checked)}
|
||||
/>
|
||||
<span>Smazat i soubory na disku</span>
|
||||
</label>
|
||||
</>
|
||||
deleteTarget
|
||||
? `Opravdu chcete smazat projekt ${deleteTarget.project_number}?`
|
||||
: ""
|
||||
}
|
||||
confirmText="Smazat"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
>
|
||||
<CheckboxField
|
||||
label="Smazat i soubory na disku"
|
||||
checked={deleteFiles}
|
||||
onChange={setDeleteFiles}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSubmit={handleCreate}
|
||||
title="Přidat projekt"
|
||||
submitLabel="Vytvořit"
|
||||
subtitle={`Číslo projektu: ${
|
||||
nextNumberQuery.data?.number ??
|
||||
nextNumberQuery.data?.next_number ??
|
||||
"…"
|
||||
} (přiděleno automaticky)`}
|
||||
submitText="Vytvořit"
|
||||
loading={creating}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<FormField label="Název" error={createErrors.name} required>
|
||||
<input
|
||||
type="text"
|
||||
<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: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Název projektu"
|
||||
aria-invalid={!!createErrors.name}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Zákazník">
|
||||
<select
|
||||
<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={(e) =>
|
||||
setCreateForm({ ...createForm, customer_id: e.target.value })
|
||||
onChange={(value) =>
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
>
|
||||
<option value="">— bez zákazníka —</option>
|
||||
{(customersQuery.data ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Zodpovědná osoba">
|
||||
<select
|
||||
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={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
responsible_user_id: e.target.value,
|
||||
})
|
||||
onChange={(value) =>
|
||||
setCreateForm({ ...createForm, responsible_user_id: value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
>
|
||||
<option value="">— nepřiřazeno —</option>
|
||||
{(usersQuery.data ?? [])
|
||||
options={[
|
||||
{ value: "", label: "— nepřiřazeno —" },
|
||||
...(usersQuery.data ?? [])
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.first_name} {u.last_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: `${u.first_name} ${u.last_name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Začátek">
|
||||
<input
|
||||
type="date"
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Začátek">
|
||||
<DateField
|
||||
value={createForm.start_date}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, start_date: e.target.value })
|
||||
onChange={(val) =>
|
||||
setCreateForm({ ...createForm, start_date: val })
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Konec">
|
||||
<input
|
||||
type="date"
|
||||
</Field>
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Konec">
|
||||
<DateField
|
||||
value={createForm.end_date}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, end_date: e.target.value })
|
||||
onChange={(val) =>
|
||||
setCreateForm({ ...createForm, end_date: val })
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={createForm.notes}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, notes: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<p className="admin-form-hint">
|
||||
Číslo projektu:{" "}
|
||||
<strong>
|
||||
{nextNumberQuery.data?.number ??
|
||||
nextNumberQuery.data?.next_number ??
|
||||
"…"}
|
||||
</strong>{" "}
|
||||
(přiděleno automaticky)
|
||||
</p>
|
||||
</div>
|
||||
</FormModal>
|
||||
</div>
|
||||
</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 { useQuery } from "@tanstack/react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm, todayLocalStr } from "../utils/formatters";
|
||||
@@ -17,9 +14,25 @@ import {
|
||||
tripListOptions,
|
||||
tripVehiclesOptions,
|
||||
type BackendTrip,
|
||||
type TripVehicle,
|
||||
} from "../lib/queries/trips";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
Select,
|
||||
DateField,
|
||||
StatusChip,
|
||||
LoadingState,
|
||||
StatCard,
|
||||
PageEnter,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface TripForm {
|
||||
@@ -33,6 +46,114 @@ interface TripForm {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const PlusIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TripsIcon = (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
const TotalIcon = (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
);
|
||||
const BusinessIcon = (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||
<circle cx="5.5" cy="18" r="2" />
|
||||
<circle cx="18.5" cy="18" r="2" />
|
||||
<path d="M8 18h8" />
|
||||
</svg>
|
||||
);
|
||||
const PrivateIcon = (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Trips() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -197,11 +318,7 @@ export default function Trips() {
|
||||
};
|
||||
|
||||
if (tripsLoading) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const totals = trips.reduce(
|
||||
@@ -216,440 +333,341 @@ export default function Trips() {
|
||||
{ total: 0, business: 0, private: 0, count: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
const recentTrips = trips.slice(0, 10);
|
||||
|
||||
const columns: DataColumn<BackendTrip>[] = [
|
||||
{
|
||||
key: "trip_date",
|
||||
header: "Datum",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (trip) => formatDate(trip.trip_date),
|
||||
},
|
||||
{
|
||||
key: "vehicle",
|
||||
header: "Vozidlo",
|
||||
width: "12%",
|
||||
render: (trip) => (
|
||||
<StatusChip label={trip.vehicles?.spz ?? ""} color="default" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "driver",
|
||||
header: "Řidič",
|
||||
width: "16%",
|
||||
render: (trip) =>
|
||||
trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : "",
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
header: "Trasa",
|
||||
width: "22%",
|
||||
render: (trip) => (
|
||||
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "distance",
|
||||
header: "Vzdálenost",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (trip) =>
|
||||
`${formatKm(trip.distance ?? trip.end_km - trip.start_km)} km`,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "12%",
|
||||
render: (trip) => (
|
||||
<StatusChip
|
||||
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||
color={trip.is_business ? "success" : "warning"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "10%",
|
||||
align: "right",
|
||||
render: (trip) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEditModal(trip)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Kniha jízd</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h4">Kniha jízd</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{new Date().toLocaleDateString("cs-CZ", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat jízdu
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div
|
||||
className="admin-grid admin-grid-4"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "1fr 1fr",
|
||||
lg: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-icon info">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">{totals.count}</span>
|
||||
<span className="admin-stat-label">Počet jízd</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.total)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Celkem naježděno</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-icon success">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||
<circle cx="5.5" cy="18" r="2" />
|
||||
<circle cx="18.5" cy="18" r="2" />
|
||||
<path d="M8 18h8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.business)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Služební</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stat-card warning">
|
||||
<div className="admin-stat-icon warning">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.private)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Soukromé</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<StatCard
|
||||
label="Počet jízd"
|
||||
value={totals.count}
|
||||
icon={TripsIcon}
|
||||
color="info"
|
||||
/>
|
||||
<StatCard
|
||||
label="Celkem naježděno"
|
||||
value={`${formatKm(totals.total)} km`}
|
||||
icon={TotalIcon}
|
||||
color="default"
|
||||
/>
|
||||
<StatCard
|
||||
label="Služební"
|
||||
value={`${formatKm(totals.business)} km`}
|
||||
icon={BusinessIcon}
|
||||
color="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Soukromé"
|
||||
value={`${formatKm(totals.private)} km`}
|
||||
icon={PrivateIcon}
|
||||
color="warning"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Recent Trips */}
|
||||
<motion.div
|
||||
className="admin-card mt-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Poslední jízdy</h2>
|
||||
<Link
|
||||
<Typography variant="h6">Poslední jízdy</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/trips/history"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
Zobrazit historii
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{trips.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nemáte žádné záznamy jízd.</p>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
</Button>
|
||||
</Box>
|
||||
<DataTable<BackendTrip>
|
||||
columns={columns}
|
||||
rows={recentTrips}
|
||||
rowKey={(trip) => trip.id}
|
||||
empty={
|
||||
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Zatím nemáte žádné záznamy jízd.
|
||||
</Typography>
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat první jízdu
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Vozidlo</th>
|
||||
<th>Řidič</th>
|
||||
<th>Trasa</th>
|
||||
<th>Vzdálenost</th>
|
||||
<th>Typ</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trips.slice(0, 10).map((trip) => (
|
||||
<tr key={trip.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDate(trip.trip_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge">
|
||||
{trip.vehicles?.spz ?? ""}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{trip.users
|
||||
? `${trip.users.first_name} ${trip.users.last_name}`
|
||||
: ""}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
<strong>
|
||||
{formatKm(
|
||||
trip.distance ?? trip.end_km - trip.start_km,
|
||||
)}{" "}
|
||||
km
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
||||
>
|
||||
{trip.is_business ? "Služební" : "Soukromá"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
onClick={() => openEditModal(trip)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, tripId: trip.id })
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
|
||||
loading={submitMutation.isPending}
|
||||
size="lg"
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Vozidlo" error={errors.vehicle_id} required>
|
||||
<select
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Vozidlo" required error={errors.vehicle_id}>
|
||||
<Select
|
||||
value={form.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleVehicleChange(e.target.value);
|
||||
onChange={(value) => {
|
||||
handleVehicleChange(value);
|
||||
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte vozidlo</option>
|
||||
{vehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.spz} - {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
error={!!errors.vehicle_id}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte vozidlo" },
|
||||
...vehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField label="Datum jízdy" error={errors.trip_date} required>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
<Field label="Datum jízdy" required error={errors.trip_date}>
|
||||
<DateField
|
||||
value={form.trip_date}
|
||||
onChange={(val: string) => {
|
||||
onChange={(val) => {
|
||||
setForm({ ...form, trip_date: val });
|
||||
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<FormField
|
||||
label="Počáteční stav km"
|
||||
error={errors.start_km}
|
||||
required
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
<Field label="Počáteční stav km" required error={errors.start_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.start_km}
|
||||
error={!!errors.start_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, start_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, start_km: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Konečný stav km" error={errors.end_km} required>
|
||||
<input
|
||||
<Field label="Konečný stav km" required error={errors.end_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.end_km}
|
||||
error={!!errors.end_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, end_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, end_km: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Vzdálenost">
|
||||
<input
|
||||
<Field label="Vzdálenost">
|
||||
<TextField
|
||||
type="text"
|
||||
value={`${formatKm(calculateDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
disabled
|
||||
slotProps={{ htmlInput: { readOnly: true } }}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Místo odjezdu" error={errors.route_from} required>
|
||||
<input
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Místo odjezdu" required error={errors.route_from}>
|
||||
<TextField
|
||||
type="text"
|
||||
value={form.route_from}
|
||||
error={!!errors.route_from}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_from: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_from: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Místo příjezdu" error={errors.route_to} required>
|
||||
<input
|
||||
<Field label="Místo příjezdu" required error={errors.route_to}>
|
||||
<TextField
|
||||
type="text"
|
||||
value={form.route_to}
|
||||
error={!!errors.route_to}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_to: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_to: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<FormField label="Typ jízdy">
|
||||
<select
|
||||
value={form.is_business}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
is_business: parseInt(e.target.value),
|
||||
})
|
||||
<Field label="Typ jízdy">
|
||||
<Select
|
||||
value={String(form.is_business)}
|
||||
onChange={(value) =>
|
||||
setForm({ ...form, is_business: parseInt(value) })
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
<option value={0}>Soukromá</option>
|
||||
</select>
|
||||
</FormField>
|
||||
options={[
|
||||
{ value: "1", label: "Služební" },
|
||||
{ value: "0", label: "Soukromá" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
|
||||
onConfirm={() => handleDelete(deleteConfirm.tripId!)}
|
||||
@@ -657,9 +675,9 @@ export default function Trips() {
|
||||
message="Opravdu chcete smazat tento záznam?"
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import {
|
||||
tripListOptions,
|
||||
tripVehiclesOptions,
|
||||
tripUsersOptions,
|
||||
type BackendTrip,
|
||||
type TripVehicle,
|
||||
type TripUser,
|
||||
} from "../lib/queries/trips";
|
||||
import {
|
||||
companySettingsOptions,
|
||||
type CompanySettingsData,
|
||||
} from "../lib/queries/settings";
|
||||
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import FormField from "../components/FormField";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm } from "../utils/formatters";
|
||||
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";
|
||||
|
||||
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() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [filterMonth, setFilterMonth] = useState(() =>
|
||||
String(new Date().getMonth() + 1),
|
||||
);
|
||||
const [filterYear, setFilterYear] = useState(() =>
|
||||
String(new Date().getFullYear()),
|
||||
);
|
||||
const [filterPeriod, setFilterPeriod] = useState(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
});
|
||||
const [filterVehicleId, setFilterVehicleId] = useState("");
|
||||
const [filterUserId, setFilterUserId] = useState("");
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
@@ -113,8 +219,8 @@ export default function TripsAdmin() {
|
||||
|
||||
const { data: tripsData, isPending } = useQuery(
|
||||
tripListOptions({
|
||||
month: Number(filterMonth) || undefined,
|
||||
year: Number(filterYear) || undefined,
|
||||
month: Number(filterPeriod.slice(5, 7)) || undefined,
|
||||
year: Number(filterPeriod.slice(0, 4)) || undefined,
|
||||
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
||||
userId: filterUserId ? Number(filterUserId) : undefined,
|
||||
perPage: 100,
|
||||
@@ -212,10 +318,10 @@ export default function TripsAdmin() {
|
||||
};
|
||||
|
||||
const getPeriodName = () =>
|
||||
new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString(
|
||||
"cs-CZ",
|
||||
{ month: "long", year: "numeric" },
|
||||
);
|
||||
new Date(
|
||||
Number(filterPeriod.slice(0, 4)),
|
||||
Number(filterPeriod.slice(5, 7)) - 1,
|
||||
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
|
||||
const getSelectedVehicleName = () => {
|
||||
if (!filterVehicleId) return null;
|
||||
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
||||
@@ -331,463 +437,352 @@ export default function TripsAdmin() {
|
||||
.reduce((sum, t) => sum + t.distance, 0),
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
const columns: DataColumn<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"
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Správa knihy jízd</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, trip })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">Správa knihy jízd</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||
{trips.length > 0 && (
|
||||
<button
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PrintIcon}
|
||||
onClick={handlePrint}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<Link to="/vehicles" className="admin-btn admin-btn-secondary">
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/vehicles"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Vozidla
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row admin-form-row-4">
|
||||
<FormField label="Měsíc" style={{ marginBottom: 0 }}>
|
||||
<select
|
||||
value={filterMonth}
|
||||
onChange={(e) => setFilterMonth(e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<option key={i + 1} value={i + 1}>
|
||||
{new Date(2000, i).toLocaleString("cs-CZ", {
|
||||
month: "long",
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Rok" style={{ marginBottom: 0 }}>
|
||||
<select
|
||||
value={filterYear}
|
||||
onChange={(e) => setFilterYear(e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 2 + i;
|
||||
return (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Vozidlo" style={{ marginBottom: 0 }}>
|
||||
<select
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<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
|
||||
onChange={setFilterVehicleId}
|
||||
options={[
|
||||
{ value: "", label: "Všechna vozidla" },
|
||||
...vehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<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>
|
||||
onChange={setFilterUserId}
|
||||
options={[
|
||||
{ value: "", label: "Všichni řidiči" },
|
||||
...tripUsers.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<motion.div
|
||||
className="admin-grid admin-grid-3 mt-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
{/* Stats Cards */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(3, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-icon info">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">{totals.count}</span>
|
||||
<span className="admin-stat-label">Počet jízd</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.total)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Celkem naježděno</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-icon success">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||
<circle cx="5.5" cy="18" r="2" />
|
||||
<circle cx="18.5" cy="18" r="2" />
|
||||
<path d="M8 18h8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.business)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Služební km</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<StatCard
|
||||
label="Počet jízd"
|
||||
value={totals.count}
|
||||
icon={TripsIcon}
|
||||
color="info"
|
||||
/>
|
||||
<StatCard
|
||||
label="Celkem naježděno"
|
||||
value={`${formatKm(totals.total)} km`}
|
||||
icon={TotalIcon}
|
||||
color="default"
|
||||
/>
|
||||
<StatCard
|
||||
label="Služební km"
|
||||
value={`${formatKm(totals.business)} km`}
|
||||
icon={BusinessIcon}
|
||||
color="success"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Trips Table */}
|
||||
<motion.div
|
||||
className="admin-card mt-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
{trips.length === 0 && (
|
||||
<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 })
|
||||
<DataTable<Trip>
|
||||
columns={columns}
|
||||
rows={trips}
|
||||
rowKey={(trip) => trip.id}
|
||||
empty={
|
||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Card>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showEditModal && !!editingTrip}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onSubmit={handleEditSubmit}
|
||||
title="Upravit jízdu"
|
||||
subtitle={editingTrip?.driver_name}
|
||||
loading={editMutation.isPending}
|
||||
size="lg"
|
||||
maxWidth="lg"
|
||||
>
|
||||
{editingTrip && (
|
||||
<div className="admin-form">
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ marginTop: "0", marginBottom: "0.75rem" }}
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{editingTrip.driver_name}
|
||||
</p>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Vozidlo">
|
||||
<select
|
||||
<Field label="Vozidlo">
|
||||
<Select
|
||||
value={editForm.vehicle_id}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
vehicle_id: e.target.value,
|
||||
})
|
||||
onChange={(value) =>
|
||||
setEditForm({ ...editForm, vehicle_id: value })
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
{vehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.spz} - {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
options={vehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField label="Datum jízdy">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
<Field label="Datum jízdy">
|
||||
<DateField
|
||||
value={editForm.trip_date}
|
||||
onChange={(val: string) =>
|
||||
onChange={(val) =>
|
||||
setEditForm({ ...editForm, trip_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Počáteční stav km">
|
||||
<input
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Počáteční stav km">
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={editForm.start_km}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, start_km: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Konečný stav km">
|
||||
<input
|
||||
<Field label="Konečný stav km">
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={editForm.end_km}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, end_km: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Vzdálenost">
|
||||
<input
|
||||
<Field label="Vzdálenost">
|
||||
<TextField
|
||||
type="text"
|
||||
value={`${formatKm(calculateDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
disabled
|
||||
slotProps={{ htmlInput: { readOnly: true } }}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Místo odjezdu">
|
||||
<input
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Místo odjezdu">
|
||||
<TextField
|
||||
type="text"
|
||||
value={editForm.route_from}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
route_from: e.target.value,
|
||||
})
|
||||
setEditForm({ ...editForm, route_from: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Místo příjezdu">
|
||||
<input
|
||||
<Field label="Místo příjezdu">
|
||||
<TextField
|
||||
type="text"
|
||||
value={editForm.route_to}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, route_to: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<FormField label="Typ jízdy">
|
||||
<select
|
||||
value={editForm.is_business}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
is_business: parseInt(e.target.value),
|
||||
})
|
||||
<Field label="Typ jízdy">
|
||||
<Select
|
||||
value={String(editForm.is_business)}
|
||||
onChange={(value) =>
|
||||
setEditForm({ ...editForm, is_business: parseInt(value) })
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
<option value={0}>Soukromá</option>
|
||||
</select>
|
||||
</FormField>
|
||||
options={[
|
||||
{ value: "1", label: "Služební" },
|
||||
{ value: "0", label: "Soukromá" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={editForm.notes}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, notes: e.target.value })
|
||||
}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</FormModal>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, trip: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -915,6 +910,6 @@ export default function TripsAdmin() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { motion } from "framer-motion";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm } from "../utils/formatters";
|
||||
import FormField from "../components/FormField";
|
||||
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
|
||||
import {
|
||||
tripHistoryOptions,
|
||||
tripVehiclesOptions,
|
||||
type BackendTrip,
|
||||
type TripVehicle,
|
||||
} from "../lib/queries/trips";
|
||||
Card,
|
||||
DataTable,
|
||||
Select,
|
||||
MonthField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
StatCard,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
interface Trip {
|
||||
id: number;
|
||||
@@ -29,8 +35,56 @@ interface Trip {
|
||||
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() {
|
||||
const alert = useAlert();
|
||||
const { user, hasPermission } = useAuth();
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
@@ -48,7 +102,7 @@ export default function TripsHistory() {
|
||||
userId: user?.id,
|
||||
}),
|
||||
);
|
||||
const trips = (tripsData ?? []).map((t) => ({
|
||||
const trips: Trip[] = (tripsData ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
trip_date: t.trip_date,
|
||||
spz: t.vehicles?.spz ?? "",
|
||||
@@ -81,222 +135,156 @@ export default function TripsHistory() {
|
||||
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
const columns: DataColumn<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" }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Historie jízd</h1>
|
||||
<p className="admin-page-subtitle">{getMonthName(month)}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
{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 (
|
||||
<PageEnter>
|
||||
<PageHeader title="Historie jízd" subtitle={getMonthName(month)} />
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Měsíc">
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={month}
|
||||
onChange={(val: string) => setMonth(val)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Vozidlo">
|
||||
<select
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={vehicleId}
|
||||
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>
|
||||
onChange={(value) => setVehicleId(value)}
|
||||
options={[
|
||||
{ value: "", label: "Všechna vozidla" },
|
||||
...vehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<motion.div
|
||||
className="admin-grid admin-grid-3 mt-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "1fr 1fr",
|
||||
lg: "repeat(3, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mt: 3,
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-icon info">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">{totals.count}</span>
|
||||
<span className="admin-stat-label">Počet jízd</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.total)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Celkem naježděno</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-icon success">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||
<circle cx="5.5" cy="18" r="2" />
|
||||
<circle cx="18.5" cy="18" r="2" />
|
||||
<path d="M8 18h8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.business)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Služební km</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<StatCard
|
||||
label="Počet jízd"
|
||||
value={totals.count}
|
||||
icon={TripsIcon}
|
||||
color="info"
|
||||
/>
|
||||
<StatCard
|
||||
label="Celkem naježděno"
|
||||
value={`${formatKm(totals.total)} km`}
|
||||
icon={TotalIcon}
|
||||
color="default"
|
||||
/>
|
||||
<StatCard
|
||||
label="Služební km"
|
||||
value={`${formatKm(totals.business)} km`}
|
||||
icon={BusinessIcon}
|
||||
color="success"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Trips Table */}
|
||||
<motion.div
|
||||
className="admin-card mt-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
{trips.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Žádné záznamy jízd pro vybrané období.</p>
|
||||
</div>
|
||||
<DataTable<Trip>
|
||||
columns={columns}
|
||||
rows={trips}
|
||||
rowKey={(trip) => trip.id}
|
||||
empty={
|
||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{trips.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>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>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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 { useAlert } from "../context/AlertContext";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormField from "../components/FormField";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import FormModal from "../components/FormModal";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
userListOptions,
|
||||
roleListOptions,
|
||||
type User,
|
||||
} 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";
|
||||
|
||||
@@ -22,10 +37,59 @@ interface FormData {
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role_id: number | string;
|
||||
role_id: string;
|
||||
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() {
|
||||
const { user: currentUser, updateUser, hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
@@ -39,6 +103,7 @@ export default function Users() {
|
||||
user: User | null;
|
||||
}>({ isOpen: false, user: null });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
username: "",
|
||||
email: "",
|
||||
@@ -58,8 +123,8 @@ export default function Users() {
|
||||
"projects",
|
||||
];
|
||||
|
||||
const saveUser = useApiMutation<FormData, void>({
|
||||
url: (input) =>
|
||||
const saveUser = useApiMutation<UserPayload, void>({
|
||||
url: () =>
|
||||
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
|
||||
method: () => (editingUser ? "PUT" : "POST"),
|
||||
invalidate: USER_INVALIDATE,
|
||||
@@ -115,9 +180,10 @@ export default function Users() {
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role_id: roles[0]?.id || "",
|
||||
role_id: roles[0]?.id ? String(roles[0].id) : "",
|
||||
is_active: true,
|
||||
});
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@@ -129,9 +195,10 @@ export default function Users() {
|
||||
password: "",
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role_id: user.role_id,
|
||||
role_id: String(user.role_id),
|
||||
is_active: user.is_active,
|
||||
});
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@@ -141,9 +208,22 @@ export default function Users() {
|
||||
};
|
||||
|
||||
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);
|
||||
try {
|
||||
await saveUser.mutateAsync(formData);
|
||||
await saveUser.mutateAsync({
|
||||
...formData,
|
||||
role_id: Number(formData.role_id),
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} 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 () => {
|
||||
if (!deleteModal.user) return;
|
||||
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) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
const columns: DataColumn<User>[] = [
|
||||
{
|
||||
key: "user",
|
||||
header: "Uživatel",
|
||||
render: (u) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Avatar sx={{ bgcolor: "primary.main", width: 36, height: 36 }}>
|
||||
{(u.first_name || u.username).charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{u.first_name} {u.last_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@{u.username}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{ key: "email", header: "E-mail", render: (u) => u.email },
|
||||
{
|
||||
key: "role",
|
||||
header: "Role",
|
||||
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"
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Uživatelé</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
Správa uživatelských účtů a oprávnění
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
{u.id !== currentUser?.id && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteModal({ isOpen: true, user: u })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
<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 uživatele
|
||||
</button>
|
||||
</motion.div>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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",
|
||||
return (
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 3,
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<Typography variant="h4">Správa uživatelů</Typography>
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat uživatele
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<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}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingUser ? "Upravit uživatele" : "Přidat nového uživatele"}
|
||||
submitLabel={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
|
||||
loading={saving}
|
||||
submitText={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Jméno">
|
||||
<input
|
||||
type="text"
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Jméno" required error={errors.first_name}>
|
||||
<TextField
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
first_name: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
error={!!errors.first_name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, first_name: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, first_name: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Příjmení">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Příjmení" required error={errors.last_name}>
|
||||
<TextField
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
last_name: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
error={!!errors.last_name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, last_name: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, last_name: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormField label="Uživatelské jméno">
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Uživatelské jméno" required error={errors.username}>
|
||||
<TextField
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
error={!!errors.username}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, username: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, username: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="E-mail">
|
||||
<input
|
||||
<Field label="E-mail" required error={errors.email}>
|
||||
<TextField
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
error={!!errors.email}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, email: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, email: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField
|
||||
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
|
||||
<Field
|
||||
label={`Heslo${editingUser ? " (ponechte prázdné pro zachování stávajícího)" : ""}`}
|
||||
required={!editingUser}
|
||||
error={errors.password}
|
||||
>
|
||||
<input
|
||||
<TextField
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
required={!editingUser}
|
||||
className="admin-form-input"
|
||||
error={!!errors.password}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, password: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, password: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Role">
|
||||
<select
|
||||
<Field label="Role">
|
||||
<Select
|
||||
value={formData.role_id}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, role_id: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-select"
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
is_active: e.target.checked,
|
||||
})
|
||||
}
|
||||
onChange={(value) => setFormData({ ...formData, role_id: value })}
|
||||
options={roles.map((role) => ({
|
||||
value: String(role.id),
|
||||
label: role.display_name || role.name,
|
||||
}))}
|
||||
/>
|
||||
<span>Účet je aktivní</span>
|
||||
</label>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
|
||||
<ConfirmModal
|
||||
<SwitchField
|
||||
label="Účet je aktivní"
|
||||
checked={formData.is_active}
|
||||
onChange={(v) => setFormData({ ...formData, is_active: v })}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={closeDeleteModal}
|
||||
onClose={() => setDeleteModal({ isOpen: false, user: null })}
|
||||
onConfirm={handleDelete}
|
||||
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"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleteUser.isPending}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { useState } from "react";
|
||||
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 { useAuth } from "../context/AuthContext";
|
||||
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 FormField from "../components/FormField";
|
||||
import { vehicleListOptions } from "../lib/queries/vehicles";
|
||||
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";
|
||||
|
||||
@@ -35,6 +47,50 @@ interface VehicleForm {
|
||||
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() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -167,279 +223,197 @@ export default function Vehicles() {
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Správa vozidel</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 vozidlo
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{vehicles.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="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"
|
||||
const columns: DataColumn<Vehicle>[] = [
|
||||
{ key: "spz", header: "SPZ", mono: true, bold: true, render: (v) => v.spz },
|
||||
{ key: "name", header: "Název", render: (v) => v.name },
|
||||
{
|
||||
key: "brand",
|
||||
header: "Značka / Model",
|
||||
render: (v) =>
|
||||
v.brand || v.model ? `${v.brand || ""} ${v.model || ""}`.trim() : "—",
|
||||
},
|
||||
{
|
||||
key: "initial_km",
|
||||
header: "Počáteční km",
|
||||
align: "right",
|
||||
mono: true,
|
||||
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
|
||||
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"
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, vehicle: v })}
|
||||
aria-label="Smazat"
|
||||
title="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>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<FormModal
|
||||
return (
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 3,
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">Správa vozidel</Typography>
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat vozidlo
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<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}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
|
||||
loading={saving}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="SPZ" error={errors.spz} required>
|
||||
<input
|
||||
type="text"
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="SPZ" required error={errors.spz}>
|
||||
<TextField
|
||||
value={form.spz}
|
||||
placeholder="1AB 2345"
|
||||
error={!!errors.spz}
|
||||
onChange={(e) => {
|
||||
setForm({
|
||||
...form,
|
||||
spz: e.target.value.toUpperCase(),
|
||||
});
|
||||
setForm({ ...form, spz: e.target.value.toUpperCase() });
|
||||
setErrors((prev) => ({ ...prev, spz: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="1AB 2345"
|
||||
aria-invalid={!!errors.spz}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Název" error={errors.name} required>
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
value={form.name}
|
||||
placeholder="Služební #1"
|
||||
error={!!errors.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, name: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Služební #1"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Značka">
|
||||
<input
|
||||
type="text"
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Značka">
|
||||
<TextField
|
||||
value={form.brand}
|
||||
onChange={(e) => setForm({ ...form, brand: e.target.value })}
|
||||
className="admin-form-input"
|
||||
placeholder="Škoda"
|
||||
onChange={(e) => setForm({ ...form, brand: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Model">
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 200px" }}>
|
||||
<Field label="Model">
|
||||
<TextField
|
||||
value={form.model}
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
className="admin-form-input"
|
||||
placeholder="Octavia Combi"
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Počáteční stav km</label>
|
||||
<input
|
||||
<Field
|
||||
label="Počáteční stav km"
|
||||
hint="Stav tachometru při přidání vozidla"
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.initial_km}
|
||||
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
initial_km: parseInt(e.target.value) || 0,
|
||||
})
|
||||
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Stav tachometru při přidání vozidla
|
||||
</small>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
<SwitchField
|
||||
label="Vozidlo je aktivní"
|
||||
checked={form.is_active}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, is_active: e.target.checked })
|
||||
}
|
||||
onChange={(v) => setForm({ ...form, is_active: v })}
|
||||
/>
|
||||
<span>Vozidlo je aktivní</span>
|
||||
</label>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -452,6 +426,6 @@ export default function Vehicles() {
|
||||
confirmText="Smazat"
|
||||
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 Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
warehouseStockStatusOptions,
|
||||
warehouseBelowMinimumOptions,
|
||||
warehouseReservationListOptions,
|
||||
warehouseMovementLogOptions,
|
||||
type WarehouseItem,
|
||||
type WarehouseReservation,
|
||||
} from "../lib/queries/warehouse";
|
||||
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> = {
|
||||
receipt: "Příjem",
|
||||
issue: "Výdej",
|
||||
};
|
||||
|
||||
const TYPE_TONE: Record<string, string> = {
|
||||
receipt: "admin-badge-incoming",
|
||||
issue: "admin-badge-outgoing",
|
||||
// Movement type → StatusChip color (legacy: incoming=success, outgoing=info)
|
||||
const TYPE_COLOR: Record<string, "success" | "info"> = {
|
||||
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() {
|
||||
const { hasPermission } = useAuth();
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const { data: stockItems, isPending: stockPending } = useQuery(
|
||||
warehouseStockStatusOptions(),
|
||||
@@ -60,253 +117,249 @@ export default function Warehouse() {
|
||||
const isLoading =
|
||||
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 (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Sklad</h1>
|
||||
<p className="admin-page-subtitle">Přehled skladových zásob</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Sklad"
|
||||
subtitle="Přehled skladových zásob"
|
||||
actions={
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||
{hasPermission("warehouse.operate") && (
|
||||
<>
|
||||
<Link
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/receipts/new"
|
||||
className="admin-btn admin-btn-primary"
|
||||
startIcon={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>
|
||||
Nový příjem
|
||||
</Link>
|
||||
<Link
|
||||
</Button>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/issues/new"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
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" />
|
||||
<line x1="12" y1="18" x2="12" y2="6" />
|
||||
</svg>
|
||||
Nový výdej
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasPermission("warehouse.manage") && (
|
||||
<Link
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/categories"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Kategorie
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary">
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/items"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Položky
|
||||
</Link>
|
||||
<Link
|
||||
</Button>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/reports"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Reporty
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="admin-kpi-grid admin-kpi-4">
|
||||
<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.06,
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-label">Celkem položek</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{isLoading ? "—" : totalItems}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-stat-card success"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: reducedMotion ? 0 : 0.25,
|
||||
delay: reducedMotion ? 0 : 0.08,
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-label">Hodnota zásob</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className={`admin-stat-card ${belowMinimumCount > 0 ? "danger" : "warning"}`}
|
||||
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>
|
||||
<StatCard
|
||||
color="info"
|
||||
label="Celkem položek"
|
||||
value={isLoading ? "—" : totalItems}
|
||||
/>
|
||||
<StatCard
|
||||
color="success"
|
||||
label="Hodnota zásob"
|
||||
value={isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
|
||||
/>
|
||||
<StatCard
|
||||
color={belowMinColor}
|
||||
label="Pod minimem"
|
||||
value={isLoading ? "—" : belowMinimumCount}
|
||||
/>
|
||||
<StatCard
|
||||
color="info"
|
||||
label="Aktivní rezervace"
|
||||
value={isLoading ? "—" : activeReservationCount}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Below minimum alert */}
|
||||
{belowMin.length > 0 && (
|
||||
<motion.div
|
||||
className="admin-card admin-card-danger"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: reducedMotion ? 0 : 0.25,
|
||||
delay: reducedMotion ? 0 : 0.14,
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
|
||||
<Typography variant="h6" sx={{ color: "error.main" }}>
|
||||
Položky pod minimem
|
||||
</h2>
|
||||
<Link
|
||||
</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/reports"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Reporty →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Název</th>
|
||||
<th>K dispozici</th>
|
||||
<th>Min. množství</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{belowMin.map((item) => (
|
||||
<tr key={item.id} className="admin-warehouse-row-danger">
|
||||
<td className="fw-500">{item.name}</td>
|
||||
<td className="admin-mono admin-warehouse-danger-value">
|
||||
{item.available_quantity ?? 0}
|
||||
</td>
|
||||
<td className="admin-mono">{item.min_quantity ?? 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
<DataTable<BelowMinRow>
|
||||
columns={belowMinColumns}
|
||||
rows={belowMin.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
available_quantity: item.available_quantity ?? 0,
|
||||
min_quantity: item.min_quantity ?? 0,
|
||||
}))}
|
||||
rowKey={(item) => item.id}
|
||||
rowDanger={() => true}
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Recent movements */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: reducedMotion ? 0 : 0.25,
|
||||
delay: reducedMotion ? 0 : 0.16,
|
||||
<Box>
|
||||
<Card>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Poslední pohyby</h2>
|
||||
<Link
|
||||
<Typography variant="h6">Poslední pohyby</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/reports"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Vše →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
</Button>
|
||||
</Box>
|
||||
{movementsLoading ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : recentMovements.length === 0 ? (
|
||||
<div className="admin-empty-state">Žádné nedávné pohyby</div>
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<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>
|
||||
<DataTable<MovementRow>
|
||||
columns={movementColumns}
|
||||
rows={recentMovements.map((row, i) => ({ ...row, _idx: i }))}
|
||||
rowKey={(row) => row._idx}
|
||||
empty={<EmptyState title="Žádné nedávné pohyby" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Card>
|
||||
</Box>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
|
||||
import FormField from "../components/FormField";
|
||||
import {
|
||||
warehouseCategoryListOptions,
|
||||
type WarehouseCategory,
|
||||
} from "../lib/queries/warehouse";
|
||||
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";
|
||||
|
||||
@@ -22,6 +33,50 @@ interface CategoryForm {
|
||||
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() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -121,207 +176,145 @@ export default function WarehouseCategories() {
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">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>
|
||||
const total = categories.length;
|
||||
const subtitle = `${total} ${
|
||||
total === 1
|
||||
? "kategorie"
|
||||
: total >= 2 && total <= 4
|
||||
? "kategorie"
|
||||
: "kategorií"
|
||||
}`;
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{categories.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nejsou žádné kategorie.</p>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Přidat první kategorii
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{categories.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Název</th>
|
||||
<th>Popis</th>
|
||||
<th>Pořadí</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id}>
|
||||
<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">
|
||||
<button
|
||||
onClick={() => openEditModal(category)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
const columns: DataColumn<WarehouseCategory>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
width: "35%",
|
||||
bold: true,
|
||||
render: (c) => c.name,
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "Popis",
|
||||
width: "40%",
|
||||
render: (c) => c.description || "—",
|
||||
},
|
||||
{
|
||||
key: "sort_order",
|
||||
header: "Pořadí",
|
||||
width: "15%",
|
||||
mono: true,
|
||||
render: (c) => String(c.sort_order),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "10%",
|
||||
align: "right",
|
||||
render: (c) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEditModal(c)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
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, category })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, category: c })}
|
||||
aria-label="Smazat"
|
||||
title="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>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Kategorie skladu"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat kategorii
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<DataTable<WarehouseCategory>
|
||||
columns={columns}
|
||||
rows={categories}
|
||||
rowKey={(c) => c.id}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné kategorie."
|
||||
action={
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat první kategorii
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
|
||||
loading={saving}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<FormField label="Název" error={errors.name} required>
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
value={form.name}
|
||||
error={!!errors.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, name: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Název kategorie"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
<Field label="Popis">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, description: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Volitelný popis kategorie"
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
|
||||
<FormField label="Pořadí řazení">
|
||||
<input
|
||||
<Field label="Pořadí řazení">
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.sort_order}
|
||||
value={String(form.sort_order)}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
sort_order: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Nižší hodnota = výše v seznamu
|
||||
</small>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, category: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -333,7 +326,8 @@ export default function WarehouseCategories() {
|
||||
}
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
|
||||
import { formatDate } from "../utils/formatters";
|
||||
@@ -11,12 +10,29 @@ import {
|
||||
warehouseInventoryListOptions,
|
||||
type WarehouseInventorySession,
|
||||
} 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 STATUS_BADGE: Record<string, string> = {
|
||||
DRAFT: "admin-badge-warning",
|
||||
CONFIRMED: "admin-badge-active",
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"warning" | "success" | "error" | "info" | "default"
|
||||
> = {
|
||||
DRAFT: "warning",
|
||||
CONFIRMED: "success",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
@@ -24,8 +40,23 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
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() {
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
@@ -39,190 +70,129 @@ export default function WarehouseInventory() {
|
||||
}),
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const handleRowClick = (session: WarehouseInventorySession) => {
|
||||
navigate(`/warehouse/inventory/${session.id}`);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setStatusFilter("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Inventury</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? items.length}{" "}
|
||||
{pagination?.total === 1
|
||||
const total = pagination?.total ?? items.length;
|
||||
const subtitle = `${total} ${
|
||||
total === 1
|
||||
? "inventura"
|
||||
: pagination?.total !== undefined &&
|
||||
pagination.total >= 2 &&
|
||||
pagination.total <= 4
|
||||
: total >= 2 && total <= 4
|
||||
? "inventury"
|
||||
: "inventur"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
: "inventur"
|
||||
}`;
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
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 (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Inventury"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
onClick={() => navigate("/warehouse/inventory/new")}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<select
|
||||
Nová inventura
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 160px" }}>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všechny stavy</option>
|
||||
<option value="DRAFT">Návrh</option>
|
||||
<option value="CONFIRMED">Potvrzeno</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: "", label: "Všechny stavy" },
|
||||
{ value: "DRAFT", label: "Návrh" },
|
||||
{ value: "CONFIRMED", label: "Potvrzeno" },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{statusFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={resetFilters}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
<Button variant="outlined" color="inherit" onClick={resetFilters}>
|
||||
Zrušit filtry
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${statusFilter}-${page}-${items.length}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>
|
||||
{statusFilter
|
||||
? "Žádné inventury pro zadaný filtr."
|
||||
: "Zatím nejsou žádné inventury."}
|
||||
</p>
|
||||
{!statusFilter && (
|
||||
<button
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<WarehouseInventorySession>
|
||||
columns={columns}
|
||||
rows={items}
|
||||
rowKey={(s) => s.id}
|
||||
onRowClick={(s) => navigate(`/warehouse/inventory/${s.id}`)}
|
||||
empty={
|
||||
statusFilter ? (
|
||||
<EmptyState title="Žádné inventury pro zadaný filtr." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné inventury."
|
||||
action={
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
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>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
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 Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormField from "../components/FormField";
|
||||
|
||||
import { formatDate } from "../utils/formatters";
|
||||
import {
|
||||
warehouseInventoryDetailOptions,
|
||||
type WarehouseInventorySession,
|
||||
type WarehouseInventoryItem,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
StatusChip,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
ConfirmDialog,
|
||||
headerActionsSx,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
DRAFT: "admin-badge-warning",
|
||||
CONFIRMED: "admin-badge-active",
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"default" | "success" | "error" | "warning" | "info"
|
||||
> = {
|
||||
DRAFT: "warning",
|
||||
CONFIRMED: "success",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
@@ -25,6 +41,19 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
@@ -65,206 +94,204 @@ export default function WarehouseInventoryDetail() {
|
||||
const confirming = confirmMutation.isPending;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<Link
|
||||
<Box>
|
||||
<PageHeader
|
||||
title="Inventura"
|
||||
actions={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/inventory"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět na seznam"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-empty-state">
|
||||
<p>Inventura nebyla nalezena.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Zpět
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<EmptyState title="Inventura nebyla nalezena." />
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const s = session as WarehouseInventorySession;
|
||||
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 (
|
||||
<div>
|
||||
<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 (
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
<PageHeader
|
||||
title={s.session_number || "Inventura"}
|
||||
actions={
|
||||
<Box sx={headerActionsSx}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/inventory"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět na seznam"
|
||||
aria-label="Zpět na seznam"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
{s.session_number || "Inventura"}
|
||||
</h1>
|
||||
</div>
|
||||
<span
|
||||
className={`admin-badge ${STATUS_BADGE[s.status] ?? ""}`}
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
{STATUS_LABEL[s.status] ?? s.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
Zpět
|
||||
</Button>
|
||||
<StatusChip
|
||||
label={STATUS_LABEL[s.status] ?? s.status}
|
||||
color={STATUS_COLOR[s.status] ?? "default"}
|
||||
/>
|
||||
{s.status === "DRAFT" && (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={confirming}
|
||||
>
|
||||
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Header info */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Číslo inventury
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Základní údaje</h3>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Číslo inventury">
|
||||
<div className="admin-mono fw-500">
|
||||
{s.session_number || "—"}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Vytvořeno">
|
||||
<div className="admin-mono">{formatDate(s.created_at)}</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<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 && (
|
||||
<FormField label="Poznámky">
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{s.notes}</div>
|
||||
</FormField>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Lines table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Položky</h3>
|
||||
{items.length === 0 ? (
|
||||
<div className="admin-empty-state">Žádné řádky</div>
|
||||
) : (
|
||||
<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>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
<DataTable<WarehouseInventoryItem>
|
||||
columns={itemColumns}
|
||||
rows={items}
|
||||
rowKey={(row) => row.id}
|
||||
empty={<EmptyState title="Žádné řádky" />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Confirm modal */}
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleConfirm}
|
||||
title="Potvrdit inventuru"
|
||||
message={`Opravdu chcete potvrdit inventuru „${s.session_number || s.id}"? Potvrzením se aktualizují skladové zásoby.`}
|
||||
confirmText="Potvrdit inventuru"
|
||||
type="warning"
|
||||
loading={confirming}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useState, useId, useRef } from "react";
|
||||
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import FormField from "../components/FormField";
|
||||
import ItemPicker from "../components/warehouse/ItemPicker";
|
||||
import LocationSelect from "../components/warehouse/LocationSelect";
|
||||
import WarehouseMovementTable from "../components/warehouse/WarehouseMovementTable";
|
||||
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
|
||||
import type { WarehouseLocation } from "../lib/queries/warehouse";
|
||||
|
||||
import {
|
||||
warehouseLocationListOptions,
|
||||
type WarehouseLocation,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { Button, Card, TextField, Select, Field, PageEnter } from "../ui";
|
||||
|
||||
interface InventoryItem {
|
||||
key: string;
|
||||
@@ -21,22 +22,63 @@ interface InventoryItem {
|
||||
actual_qty: number;
|
||||
}
|
||||
|
||||
const defaultInventoryItem = () => ({
|
||||
item_id: null,
|
||||
location_id: null,
|
||||
actual_qty: 0,
|
||||
});
|
||||
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 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() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const baseId = useId();
|
||||
const counterRef = useRef(0);
|
||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
||||
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { data: locations = [] } = useQuery(warehouseLocationListOptions());
|
||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<IconButton
|
||||
component={RouterLink}
|
||||
to="/warehouse/inventory"
|
||||
className="admin-btn-icon"
|
||||
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" />
|
||||
</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}
|
||||
>
|
||||
{BackIcon}
|
||||
</IconButton>
|
||||
<Typography variant="h4">Nová inventura</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? "Ukládání..." : "Vytvořit inventuru"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Notes */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Základní údaje</h3>
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
placeholder="Volitelné poznámky k inventuře"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
{/* Lines */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, index) => (
|
||||
<Box
|
||||
key={item.key}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
md: "minmax(180px, 2fr) minmax(140px, 1.5fr) 120px auto",
|
||||
},
|
||||
gap: 1,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Položky</h3>
|
||||
<WarehouseMovementTable<InventoryItem>
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
mode="inventory"
|
||||
defaultItem={defaultInventoryItem}
|
||||
renderHeader={() => (
|
||||
<>
|
||||
<th className="admin-warehouse-col-item">Položka</th>
|
||||
<th className="admin-warehouse-col-location">Lokace</th>
|
||||
<th className="admin-warehouse-col-qty">Skutečné množství</th>
|
||||
<th>Akce</th>
|
||||
</>
|
||||
)}
|
||||
renderRow={(item, _index, updateItem, removeItem) => (
|
||||
<>
|
||||
<td className="admin-warehouse-col-item">
|
||||
<ItemPicker
|
||||
value={item.item_id}
|
||||
onChange={(id) => updateItem("item_id", id)}
|
||||
onChange={(id) => updateItem(index, "item_id", id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-location">
|
||||
<LocationSelect
|
||||
value={item.location_id}
|
||||
onChange={(locId) => updateItem("location_id", locId)}
|
||||
<Select
|
||||
value={
|
||||
item.location_id === null ? "" : String(item.location_id)
|
||||
}
|
||||
onChange={(val) =>
|
||||
updateItem(index, "location_id", val ? Number(val) : null)
|
||||
}
|
||||
options={locationOptions}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-qty">
|
||||
<input
|
||||
<TextField
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.actual_qty || ""}
|
||||
onChange={(e) =>
|
||||
updateItem("actual_qty", Number(e.target.value))
|
||||
updateItem(index, "actual_qty", Number(e.target.value))
|
||||
}
|
||||
min="0"
|
||||
step="0.001"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+([\.,][0-9]+)?"
|
||||
aria-label={`Skutečné množství, řádek ${_index + 1}`}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
step: 0.001,
|
||||
inputMode: "decimal",
|
||||
pattern: "[0-9]+([\\.,][0-9]+)?",
|
||||
"aria-label": `Skutečné množství, řádek ${index + 1}`,
|
||||
}}
|
||||
placeholder="Skutečné množství"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
onClick={removeItem}
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => removeItem(index)}
|
||||
title="Odebrat řádek"
|
||||
aria-label="Odebrat řádek"
|
||||
sx={{ justifySelf: { xs: "start", md: "center" } }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PlusIcon}
|
||||
onClick={addItem}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
<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>
|
||||
Přidat položku
|
||||
</Button>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormField from "../components/FormField";
|
||||
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
warehouseIssueDetailOptions,
|
||||
type WarehouseIssue,
|
||||
type WarehouseIssueItem,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
StatusChip,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
ConfirmDialog,
|
||||
PageEnter,
|
||||
headerActionsSx,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
DRAFT: "admin-badge-warning",
|
||||
CONFIRMED: "admin-badge-active",
|
||||
CANCELLED: "admin-badge-danger",
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"default" | "success" | "error" | "warning" | "info"
|
||||
> = {
|
||||
DRAFT: "warning",
|
||||
CONFIRMED: "success",
|
||||
CANCELLED: "error",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
@@ -27,6 +43,19 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
CANCELLED: "Zrušeno",
|
||||
};
|
||||
|
||||
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 WarehouseIssueDetail() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
@@ -92,257 +121,272 @@ export default function WarehouseIssueDetail() {
|
||||
const cancelling = cancelMutation.isPending;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (error || !issue) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<Link
|
||||
<Box>
|
||||
<PageHeader
|
||||
title="Výdejka"
|
||||
actions={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/issues"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět na seznam"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-empty-state">
|
||||
<p>Výdejka nebyla nalezena.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Zpět
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<EmptyState title="Výdejka nebyla nalezena." />
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const iss = issue as WarehouseIssue;
|
||||
const items = iss.items ?? [];
|
||||
|
||||
const itemColumns: DataColumn<WarehouseIssueItem>[] = [
|
||||
{
|
||||
key: "item",
|
||||
header: "Položka",
|
||||
width: "20%",
|
||||
bold: true,
|
||||
render: (row) => row.item?.name || `ID: ${row.item_id}`,
|
||||
},
|
||||
{
|
||||
key: "batch",
|
||||
header: "Šarže",
|
||||
width: "18%",
|
||||
mono: true,
|
||||
render: (row) =>
|
||||
row.batch
|
||||
? `${formatDate(row.batch.received_at)} – ${formatCurrency(Number(row.batch.unit_price), "CZK")}`
|
||||
: "—",
|
||||
},
|
||||
{
|
||||
key: "quantity",
|
||||
header: "Množství",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (row) => String(Number(row.quantity)),
|
||||
},
|
||||
{
|
||||
key: "unit_price",
|
||||
header: "Cena/ks",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
render: (row) =>
|
||||
row.batch ? formatCurrency(Number(row.batch.unit_price), "CZK") : "—",
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "Celkem",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (row) =>
|
||||
row.batch
|
||||
? formatCurrency(
|
||||
Number(row.quantity) * Number(row.batch.unit_price),
|
||||
"CZK",
|
||||
)
|
||||
: "—",
|
||||
},
|
||||
{
|
||||
key: "location",
|
||||
header: "Lokace",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (row) => row.location?.code || "—",
|
||||
},
|
||||
{
|
||||
key: "reservation",
|
||||
header: "Rezervace",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (row) =>
|
||||
row.reservation
|
||||
? `ID: ${row.reservation.id} (${Number(row.reservation.remaining_qty)} ks)`
|
||||
: "—",
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "6%",
|
||||
render: (row) => row.notes || "—",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
<PageHeader
|
||||
title={iss.issue_number || "Nová výdejka"}
|
||||
subtitle={
|
||||
iss.project
|
||||
? `${iss.project.project_number} – ${iss.project.name}`
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Box sx={headerActionsSx}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/issues"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět na seznam"
|
||||
aria-label="Zpět na seznam"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
{iss.issue_number || "Nová výdejka"}
|
||||
</h1>
|
||||
{iss.project && (
|
||||
<p className="admin-page-subtitle">
|
||||
{iss.project.project_number} – {iss.project.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`admin-badge ${STATUS_BADGE[iss.status] ?? ""}`}
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
{STATUS_LABEL[iss.status] ?? iss.status}
|
||||
</span>
|
||||
</div>
|
||||
{canOperate && (
|
||||
<div className="admin-page-actions">
|
||||
{iss.status === "DRAFT" && (
|
||||
Zpět
|
||||
</Button>
|
||||
<StatusChip
|
||||
label={STATUS_LABEL[iss.status] ?? iss.status}
|
||||
color={STATUS_COLOR[iss.status] ?? "default"}
|
||||
/>
|
||||
{canOperate && iss.status === "DRAFT" && (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => navigate(`/warehouse/issues/${iss.id}/edit`)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
Upravit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={confirming}
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={confirming}>
|
||||
{confirming ? "Potvrzování..." : "Potvrdit"}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setCancelConfirm(true)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{iss.status === "CONFIRMED" && (
|
||||
<button
|
||||
{canOperate && iss.status === "CONFIRMED" && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setCancelConfirm(true)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Header info */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
{/* Basic info */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Číslo dokladu
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Základní údaje</h3>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<FormField label="Číslo dokladu">
|
||||
<div className="admin-mono fw-500">
|
||||
{iss.issue_number || "—"}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Projekt">
|
||||
<div>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Projekt
|
||||
</Typography>
|
||||
{iss.project ? (
|
||||
<a
|
||||
className="link-accent admin-mono fw-500"
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="a"
|
||||
href={`/projects/${iss.project.id}`}
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 500,
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{iss.project.project_number} – {iss.project.name}
|
||||
</a>
|
||||
</Typography>
|
||||
) : (
|
||||
"—"
|
||||
<Typography variant="body2">—</Typography>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Vydal">
|
||||
<div className="admin-mono fw-500">
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vydal
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{iss.issued_by_user
|
||||
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
|
||||
: "—"}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Vytvořeno">
|
||||
<div className="admin-mono">{formatDate(iss.created_at)}</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vytvořeno
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{formatDate(iss.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{iss.notes && (
|
||||
<FormField label="Poznámky">
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{iss.notes}</div>
|
||||
</FormField>
|
||||
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Poznámky
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{iss.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Lines table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Položky</h3>
|
||||
{items.length === 0 ? (
|
||||
<div className="admin-empty-state">Žádné řádky</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Položka</th>
|
||||
<th>Šarže</th>
|
||||
<th>Množství</th>
|
||||
<th>Cena/ks</th>
|
||||
<th>Celkem</th>
|
||||
<th>Lokace</th>
|
||||
<th>Rezervace</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="fw-500">
|
||||
{item.item?.name || `ID: ${item.item_id}`}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{item.batch
|
||||
? `${formatDate(item.batch.received_at)} – ${formatCurrency(Number(item.batch.unit_price), "CZK")}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="admin-mono">{Number(item.quantity)}</td>
|
||||
<td className="admin-mono">
|
||||
{item.batch
|
||||
? formatCurrency(Number(item.batch.unit_price), "CZK")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="admin-mono fw-500">
|
||||
{item.batch
|
||||
? formatCurrency(
|
||||
Number(item.quantity) *
|
||||
Number(item.batch.unit_price),
|
||||
"CZK",
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{item.location?.code || "—"}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{item.reservation
|
||||
? `ID: ${item.reservation.id} (${Number(item.reservation.remaining_qty)} ks)`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{item.notes || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
<DataTable<WarehouseIssueItem>
|
||||
columns={itemColumns}
|
||||
rows={items}
|
||||
rowKey={(row) => row.id}
|
||||
empty={<EmptyState title="Žádné řádky" />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Cancel confirmation modal */}
|
||||
<ConfirmModal
|
||||
{/* Cancel confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={cancelConfirm}
|
||||
onClose={() => setCancelConfirm(false)}
|
||||
onConfirm={handleCancel}
|
||||
@@ -352,6 +396,6 @@ export default function WarehouseIssueDetail() {
|
||||
confirmVariant="danger"
|
||||
loading={cancelling}
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,170 @@
|
||||
import { useState, useEffect, useRef, useId } from "react";
|
||||
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
useLocation,
|
||||
Link as RouterLink,
|
||||
} from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import FormField from "../components/FormField";
|
||||
import WarehouseMovementTable, {
|
||||
type MovementItem,
|
||||
} from "../components/warehouse/WarehouseMovementTable";
|
||||
import ItemPicker from "../components/warehouse/ItemPicker";
|
||||
|
||||
import { warehouseIssueDetailOptions } from "../lib/queries/warehouse";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import {
|
||||
warehouseIssueDetailOptions,
|
||||
warehouseLocationListOptions,
|
||||
type WarehouseLocation,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { projectListOptions, type Project } from "../lib/queries/projects";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
TextField,
|
||||
Select,
|
||||
Field,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
} from "../ui";
|
||||
|
||||
interface IssueForm {
|
||||
project_id: number | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function parseDecimal(raw: string): number {
|
||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
|
||||
/** Inline batch dropdown: lists FIFO batches for an item, sets batch + unit price. */
|
||||
function BatchSelect({
|
||||
itemId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
itemId: number | null;
|
||||
value: number | null;
|
||||
onChange: (batchId: number, unitPrice: number) => void;
|
||||
}) {
|
||||
const { data: response } = useQuery({
|
||||
queryKey: ["warehouse", "batches", itemId],
|
||||
queryFn: () =>
|
||||
jsonQuery<BatchesResponse>(
|
||||
`/api/admin/warehouse/items/${itemId}/batches`,
|
||||
),
|
||||
enabled: !!itemId,
|
||||
});
|
||||
|
||||
const batches = response?.batches ?? [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{response && response.reserved_quantity > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 0.5 }}
|
||||
>
|
||||
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
|
||||
{response.reserved_quantity} ks)
|
||||
</Typography>
|
||||
)}
|
||||
<Select
|
||||
value={value === null ? "" : String(value)}
|
||||
onChange={(val) => {
|
||||
const batchId = Number(val);
|
||||
const batch = batches.find((b) => b.id === batchId);
|
||||
if (batch) onChange(batch.id, Number(batch.unit_price));
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "-- auto FIFO --" },
|
||||
...batches.map((b) => ({
|
||||
value: String(b.id),
|
||||
label: `${new Date(b.received_at).toLocaleDateString("cs-CZ")} | ${b.quantity} ks | ${Number(b.unit_price).toFixed(2)} Kč`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WarehouseIssueForm() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
@@ -87,6 +233,8 @@ export default function WarehouseIssueForm() {
|
||||
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
|
||||
const projects = projectsData?.data ?? [];
|
||||
|
||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||
|
||||
useEffect(() => {
|
||||
formInitialized.current = false;
|
||||
}, [id]);
|
||||
@@ -134,6 +282,22 @@ export default function WarehouseIssueForm() {
|
||||
]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateItem = (
|
||||
index: number,
|
||||
field: keyof MovementItem,
|
||||
value: unknown,
|
||||
) => {
|
||||
setItems((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.project_id) {
|
||||
@@ -245,138 +409,201 @@ export default function WarehouseIssueForm() {
|
||||
};
|
||||
|
||||
if (isEdit && isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const projectOptions = [
|
||||
{ value: "", label: "Vyberte projekt..." },
|
||||
...projects.map((p: Project) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const locationOptions = [
|
||||
{ value: "", label: "-- bez lokace --" },
|
||||
...(locations ?? []).map((loc: WarehouseLocation) => ({
|
||||
value: String(loc.id),
|
||||
label: `${loc.code} - ${loc.name}`,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<IconButton
|
||||
component={RouterLink}
|
||||
to="/warehouse/issues"
|
||||
className="admin-btn-icon"
|
||||
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" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
{BackIcon}
|
||||
</IconButton>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
type="button"
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleSaveDraft}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Ukládání..." : "Uložit jako návrh"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAndConfirm}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleSaveAndConfirm} disabled={saving}>
|
||||
{saving ? "Ukládání..." : "Uložit a potvrdit"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Header fields */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Základní údaje</h3>
|
||||
<div className="admin-form">
|
||||
<FormField label="Projekt">
|
||||
<select
|
||||
value={form.project_id ?? ""}
|
||||
onChange={(e) =>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Field label="Projekt" error={errors.project_id}>
|
||||
<Select
|
||||
value={form.project_id === null ? "" : String(form.project_id)}
|
||||
onChange={(val) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
project_id:
|
||||
e.target.value === "" ? null : Number(e.target.value),
|
||||
project_id: val === "" ? null : Number(val),
|
||||
}))
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte projekt...</option>
|
||||
{projects.map((p: Project) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.project_number} – {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.project_id && (
|
||||
<div className="admin-form-error">{errors.project_id}</div>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
options={projectOptions}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
placeholder="Volitelné poznámky"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
{/* Lines */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Položky</h3>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
{errors.items && (
|
||||
<div
|
||||
className="admin-form-error"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ display: "block", mb: 1.5 }}
|
||||
>
|
||||
{errors.items}
|
||||
</div>
|
||||
</Typography>
|
||||
)}
|
||||
<WarehouseMovementTable
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
mode="issue"
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, index) => (
|
||||
<Box
|
||||
key={item.key}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
md: "minmax(160px, 1.6fr) minmax(160px, 1.6fr) 100px 100px minmax(130px, 1.1fr) minmax(120px, 1.2fr) auto",
|
||||
},
|
||||
gap: 1,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<ItemPicker
|
||||
value={item.item_id}
|
||||
itemName={item.item_name}
|
||||
onChange={(id) => updateItem(index, "item_id", id)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<BatchSelect
|
||||
itemId={item.item_id}
|
||||
value={item.batch_id}
|
||||
onChange={(batchId, unitPrice) => {
|
||||
updateItem(index, "batch_id", batchId);
|
||||
updateItem(index, "unit_price", unitPrice);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(index, "quantity", parseDecimal(e.target.value))
|
||||
}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
step: 0.001,
|
||||
inputMode: "decimal",
|
||||
pattern: "[0-9]+([\\.,][0-9]+)?",
|
||||
"aria-label": `Množství, řádek ${index + 1}`,
|
||||
}}
|
||||
placeholder="Množství"
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price || ""}
|
||||
disabled
|
||||
inputProps={{
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
inputMode: "decimal",
|
||||
pattern: "[0-9]+([\\.,][0-9]+)?",
|
||||
"aria-label": `Cena za kus, řádek ${index + 1}`,
|
||||
}}
|
||||
placeholder="Cena/ks"
|
||||
/>
|
||||
<Select
|
||||
value={
|
||||
item.location_id === null ? "" : String(item.location_id)
|
||||
}
|
||||
onChange={(val) =>
|
||||
updateItem(index, "location_id", val ? Number(val) : null)
|
||||
}
|
||||
options={locationOptions}
|
||||
/>
|
||||
<TextField
|
||||
value={item.notes ?? ""}
|
||||
onChange={(e) => updateItem(index, "notes", e.target.value)}
|
||||
placeholder="Poznámka"
|
||||
/>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => removeItem(index)}
|
||||
title="Odebrat řádek"
|
||||
aria-label="Odebrat řádek"
|
||||
sx={{ justifySelf: { xs: "start", md: "center" } }}
|
||||
>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PlusIcon}
|
||||
onClick={addItem}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Přidat položku
|
||||
</Button>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Pagination from "../components/Pagination";
|
||||
import Box from "@mui/material/Box";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
|
||||
@@ -16,13 +13,32 @@ import {
|
||||
type WarehouseIssue,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { projectListOptions, type Project } from "../lib/queries/projects";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
TextField,
|
||||
Select,
|
||||
DateField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
DRAFT: "admin-badge-warning",
|
||||
CONFIRMED: "admin-badge-active",
|
||||
CANCELLED: "admin-badge-danger",
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"warning" | "success" | "error" | "info" | "default"
|
||||
> = {
|
||||
DRAFT: "warning",
|
||||
CONFIRMED: "success",
|
||||
CANCELLED: "error",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
@@ -31,8 +47,23 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
CANCELLED: "Zrušeno",
|
||||
};
|
||||
|
||||
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 WarehouseIssues() {
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -58,16 +89,10 @@ export default function WarehouseIssues() {
|
||||
}),
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const handleRowClick = (issue: WarehouseIssue) => {
|
||||
navigate(`/warehouse/issues/${issue.id}`);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearch("");
|
||||
setStatusFilter("");
|
||||
@@ -77,256 +102,190 @@ export default function WarehouseIssues() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasActiveFilters = statusFilter || projectId || dateFrom || dateTo;
|
||||
const hasActiveFilters = !!(statusFilter || projectId || dateFrom || dateTo);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Výdejky</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? items.length}{" "}
|
||||
{pagination?.total === 1
|
||||
? "doklad"
|
||||
: pagination?.total !== undefined &&
|
||||
pagination.total >= 2 &&
|
||||
pagination.total <= 4
|
||||
? "doklady"
|
||||
: "dokladů"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
{canOperate && (
|
||||
<button
|
||||
onClick={() => navigate("/warehouse/issues/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á výdejka
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
const total = pagination?.total ?? items.length;
|
||||
const subtitle = `${total} ${
|
||||
total === 1 ? "doklad" : total >= 2 && total <= 4 ? "doklady" : "dokladů"
|
||||
}`;
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
const columns: DataColumn<WarehouseIssue>[] = [
|
||||
{
|
||||
key: "issue_number",
|
||||
header: "Číslo dokladu",
|
||||
width: "15%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (r) => r.issue_number || "—",
|
||||
},
|
||||
{
|
||||
key: "project",
|
||||
header: "Projekt",
|
||||
width: "25%",
|
||||
render: (r) =>
|
||||
r.project ? `${r.project.project_number} – ${r.project.name}` : "—",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "13%",
|
||||
render: (r) => (
|
||||
<StatusChip
|
||||
label={STATUS_LABEL[r.status] ?? r.status}
|
||||
color={STATUS_COLOR[r.status] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "issued_by_user",
|
||||
header: "Vydal",
|
||||
width: "20%",
|
||||
render: (r) =>
|
||||
r.issued_by_user
|
||||
? `${r.issued_by_user.first_name} ${r.issued_by_user.last_name}`
|
||||
: "—",
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Vytvořeno",
|
||||
width: "14%",
|
||||
mono: true,
|
||||
render: (r) => formatDate(r.created_at),
|
||||
},
|
||||
{
|
||||
key: "items_count",
|
||||
header: "Položek",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
render: (r) => String(r._count?.items ?? 0),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Výdejky"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
canOperate ? (
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
onClick={() => navigate("/warehouse/issues/new")}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<div className="admin-search">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
Nová výdejka
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle čísla dokladu..."
|
||||
className="admin-form-input"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 160px" }}>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všechny stavy</option>
|
||||
<option value="DRAFT">Návrh</option>
|
||||
<option value="CONFIRMED">Potvrzeno</option>
|
||||
<option value="CANCELLED">Zrušeno</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: "", label: "Všechny stavy" },
|
||||
{ value: "DRAFT", label: "Návrh" },
|
||||
{ value: "CONFIRMED", label: "Potvrzeno" },
|
||||
{ value: "CANCELLED", label: "Zrušeno" },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{projects.length > 0 && (
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => {
|
||||
setProjectId(
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
);
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={projectId === "" ? "" : String(projectId)}
|
||||
onChange={(val) => {
|
||||
setProjectId(val === "" ? "" : Number(val));
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všechny projekty</option>
|
||||
{projects.map((p: Project) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.project_number} – {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={[
|
||||
{ value: "", label: "Všechny projekty" },
|
||||
...projects.map((p: Project) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={dateFrom}
|
||||
onChange={(val) => {
|
||||
setDateFrom(val);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Od"
|
||||
label="Od"
|
||||
/>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={dateTo}
|
||||
onChange={(val) => {
|
||||
setDateTo(val);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Do"
|
||||
label="Do"
|
||||
/>
|
||||
</Box>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={resetFilters}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
<Button variant="outlined" color="inherit" onClick={resetFilters}>
|
||||
Zrušit filtry
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${debouncedSearch}-${statusFilter}-${projectId}-${dateFrom}-${dateTo}-${page}-${items.length}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>
|
||||
{search || hasActiveFilters
|
||||
? "Žádné doklady pro zadaný filtr."
|
||||
: "Zatím nejsou žádné výdejky."}
|
||||
</p>
|
||||
{canOperate && !search && !hasActiveFilters && (
|
||||
<button
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<WarehouseIssue>
|
||||
columns={columns}
|
||||
rows={items}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => navigate(`/warehouse/issues/${r.id}`)}
|
||||
empty={
|
||||
search || hasActiveFilters ? (
|
||||
<EmptyState title="Žádné doklady pro zadaný filtr." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné výdejky."
|
||||
action={
|
||||
canOperate ? (
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
onClick={() => navigate("/warehouse/issues/new")}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Vytvořit první výdejku
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Číslo dokladu</th>
|
||||
<th>Projekt</th>
|
||||
<th>Stav</th>
|
||||
<th>Vydal</th>
|
||||
<th>Vytvořeno</th>
|
||||
<th>Položek</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((issue) => (
|
||||
<tr
|
||||
key={issue.id}
|
||||
onClick={() => handleRowClick(issue)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<td className="admin-mono fw-500">
|
||||
{issue.issue_number || "—"}
|
||||
</td>
|
||||
<td>
|
||||
{issue.project
|
||||
? `${issue.project.project_number} – ${issue.project.name}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${STATUS_BADGE[issue.status] ?? ""}`}
|
||||
>
|
||||
{STATUS_LABEL[issue.status] ?? issue.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{issue.issued_by_user
|
||||
? `${issue.issued_by_user.first_name} ${issue.issued_by_user.last_name}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(issue.created_at)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{issue._count?.items ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import FormField from "../components/FormField";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
@@ -15,6 +16,22 @@ import {
|
||||
type WarehouseItem,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Field,
|
||||
TextField,
|
||||
Select,
|
||||
StatCard,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
ConfirmDialog,
|
||||
PageEnter,
|
||||
headerActionsSx,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
interface Batch {
|
||||
id: number;
|
||||
@@ -52,6 +69,35 @@ interface ItemForm {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
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 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 WarehouseItemDetail() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
@@ -69,6 +115,7 @@ export default function WarehouseItemDetail() {
|
||||
notes: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const isNew = id === "new";
|
||||
const itemQuery = useQuery(warehouseItemDetailOptions(id, !!id && !isNew));
|
||||
@@ -176,62 +223,24 @@ export default function WarehouseItemDetail() {
|
||||
const saving = saveMutation.isPending;
|
||||
|
||||
if (isPending && !isNew) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const batches: Batch[] = item?.batches ?? [];
|
||||
const locations: ItemLocation[] = item?.item_locations ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
to="/warehouse/items"
|
||||
className="admin-btn-icon"
|
||||
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" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
{id === "new"
|
||||
? "Nová položka"
|
||||
: item
|
||||
? item.name
|
||||
: "Detail položky"}
|
||||
</h1>
|
||||
{item?.item_number && (
|
||||
<p className="admin-page-subtitle">{item.item_number}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="admin-page-actions">
|
||||
const title = isNew ? "Nová položka" : item ? item.name : "Detail položky";
|
||||
const subtitle = item?.item_number || undefined;
|
||||
|
||||
// Edit / view mode action buttons
|
||||
const headerActions = canManage ? (
|
||||
<Box sx={headerActionsSx}>
|
||||
{editing || isNew ? (
|
||||
<>
|
||||
{!isNew && (
|
||||
<button
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setErrors({});
|
||||
@@ -252,349 +261,391 @@ export default function WarehouseItemDetail() {
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={saving}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
</>
|
||||
) : isNew ? (
|
||||
"Vytvořit"
|
||||
) : (
|
||||
"Uložit"
|
||||
)}
|
||||
</button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Ukládání..." : isNew ? "Vytvořit" : "Uložit"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{item && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ color: "var(--danger)" }}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
Smazat
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<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 startIcon={EditIcon} onClick={() => setEditing(true)}>
|
||||
Upravit
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Box>
|
||||
) : undefined;
|
||||
|
||||
// Batch table columns
|
||||
const batchColumns: DataColumn<Batch>[] = [
|
||||
{
|
||||
key: "received_at",
|
||||
header: "Datum příjmu",
|
||||
width: "22%",
|
||||
mono: true,
|
||||
render: (b) => formatDate(b.received_at),
|
||||
},
|
||||
{
|
||||
key: "original_qty",
|
||||
header: "Původní množství",
|
||||
width: "20%",
|
||||
mono: true,
|
||||
render: (b) => String(Number(b.original_qty)),
|
||||
},
|
||||
{
|
||||
key: "quantity",
|
||||
header: "Zůstatek",
|
||||
width: "18%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (b) => String(Number(b.quantity)),
|
||||
},
|
||||
{
|
||||
key: "unit_price",
|
||||
header: "Cena za jednotku",
|
||||
width: "20%",
|
||||
mono: true,
|
||||
render: (b) => formatCurrency(Number(b.unit_price), "CZK"),
|
||||
},
|
||||
{
|
||||
key: "value",
|
||||
header: "Hodnota",
|
||||
width: "20%",
|
||||
mono: true,
|
||||
render: (b) =>
|
||||
formatCurrency(Number(b.quantity) * Number(b.unit_price), "CZK"),
|
||||
},
|
||||
];
|
||||
|
||||
// Location table columns
|
||||
const locationColumns: DataColumn<ItemLocation>[] = [
|
||||
{
|
||||
key: "code",
|
||||
header: "Kód",
|
||||
width: "30%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (l) => l.location?.code || "—",
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
width: "50%",
|
||||
render: (l) => l.location?.name || "—",
|
||||
},
|
||||
{
|
||||
key: "quantity",
|
||||
header: "Množství",
|
||||
width: "20%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (l) => String(Number(l.quantity)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Box sx={headerActionsSx}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/items"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
Zpět
|
||||
</Button>
|
||||
{headerActions}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Basic info */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
|
||||
{editing || isNew ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Základní údaje</h3>
|
||||
<div className="admin-form">
|
||||
{editing || id === "new" ? (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Číslo položky" error={errors.item_number}>
|
||||
<input
|
||||
type="text"
|
||||
<Field label="Číslo položky" error={errors.item_number}>
|
||||
<TextField
|
||||
value={form.item_number}
|
||||
onChange={(e) => {
|
||||
updateForm("item_number", e.target.value);
|
||||
setErrors((prev) => ({ ...prev, item_number: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. SKL-001"
|
||||
fullWidth
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Název" error={errors.name} required>
|
||||
<input
|
||||
type="text"
|
||||
</Field>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
value={form.name}
|
||||
error={!!errors.name}
|
||||
onChange={(e) => {
|
||||
updateForm("name", e.target.value);
|
||||
setErrors((prev) => ({ ...prev, name: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Název položky"
|
||||
aria-invalid={!!errors.name}
|
||||
fullWidth
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
</Field>
|
||||
</Box>
|
||||
<Field label="Popis">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => updateForm("description", e.target.value)}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
placeholder="Volitelný popis položky"
|
||||
fullWidth
|
||||
/>
|
||||
</FormField>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Kategorie">
|
||||
<select
|
||||
value={form.category_id}
|
||||
onChange={(e) =>
|
||||
updateForm("category_id", e.target.value)
|
||||
}
|
||||
className="admin-form-select"
|
||||
</Field>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<option value="">— Bez kategorie —</option>
|
||||
<Field label="Kategorie">
|
||||
<Select
|
||||
value={form.category_id}
|
||||
onChange={(v) => updateForm("category_id", v)}
|
||||
>
|
||||
<MenuItem value="">— Bez kategorie —</MenuItem>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
<MenuItem key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</option>
|
||||
</MenuItem>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Jednotka" error={errors.unit} required>
|
||||
<input
|
||||
type="text"
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Jednotka" required error={errors.unit}>
|
||||
<TextField
|
||||
value={form.unit}
|
||||
error={!!errors.unit}
|
||||
onChange={(e) => {
|
||||
updateForm("unit", e.target.value);
|
||||
setErrors((prev) => ({ ...prev, unit: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="ks, m, kg..."
|
||||
aria-invalid={!!errors.unit}
|
||||
fullWidth
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Minimální množství">
|
||||
<input
|
||||
</Field>
|
||||
<Field
|
||||
label="Minimální množství"
|
||||
hint="Upozornění při poklesu pod tuto hranici"
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
inputProps={{ inputMode: "numeric", min: 0 }}
|
||||
value={form.min_quantity}
|
||||
onChange={(e) =>
|
||||
updateForm("min_quantity", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
onChange={(e) => updateForm("min_quantity", e.target.value)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
fullWidth
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Upozornění při poklesu pod tuto hranici
|
||||
</small>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
</Field>
|
||||
</Box>
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateForm("notes", e.target.value)}
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky"
|
||||
fullWidth
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Číslo položky">
|
||||
<div style={{ fontWeight: 500 }} className="admin-mono">
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/* Číslo položky */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Číslo položky
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{item?.item_number || "—"}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Název">
|
||||
<div style={{ fontWeight: 500 }}>{item?.name || "—"}</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Popis">
|
||||
<div>{item?.description || "—"}</div>
|
||||
</FormField>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Kategorie">
|
||||
<div>{item?.category?.name || "—"}</div>
|
||||
</FormField>
|
||||
<FormField label="Jednotka">
|
||||
<div>{item?.unit || "—"}</div>
|
||||
</FormField>
|
||||
<FormField label="Minimální množství">
|
||||
<div className="admin-mono">
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Název */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Název
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item?.name || "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Popis */}
|
||||
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Popis
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{item?.description || "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Kategorie */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Kategorie
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{item?.category?.name || "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Jednotka */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Jednotka
|
||||
</Typography>
|
||||
<Typography variant="body2">{item?.unit || "—"}</Typography>
|
||||
</Box>
|
||||
{/* Minimální množství */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Minimální množství
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{item?.min_quantity != null ? item.min_quantity : "—"}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Poznámky */}
|
||||
{item?.notes && (
|
||||
<FormField label="Poznámky">
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{item.notes}</div>
|
||||
</FormField>
|
||||
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Poznámky
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{item.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Card>
|
||||
|
||||
{/* Stock info — only in view mode, for existing items */}
|
||||
{id !== "new" && !editing && item && (
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr 1fr",
|
||||
md: "1fr 1fr 1fr 1fr",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Skladové zásoby</h3>
|
||||
<div className="admin-kpi-grid admin-kpi-4">
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-label">Celkové množství</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{item.total_quantity ?? 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-label">K dispozici</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{item.available_quantity ?? 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card warning">
|
||||
<div className="admin-stat-label">Hodnota zásob</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{item.stock_value != null
|
||||
<StatCard
|
||||
label="Celkové množství"
|
||||
value={`${item.total_quantity ?? 0} ${item.unit}`}
|
||||
color="info"
|
||||
/>
|
||||
<StatCard
|
||||
label="K dispozici"
|
||||
value={`${item.available_quantity ?? 0} ${item.unit}`}
|
||||
color="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Hodnota zásob"
|
||||
value={
|
||||
item.stock_value != null
|
||||
? formatCurrency(item.stock_value, "CZK")
|
||||
: "0,00 Kč"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`admin-stat-card ${item.below_minimum ? "danger" : "info"}`}
|
||||
>
|
||||
<div className="admin-stat-label">Pod minimem</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{item.below_minimum ? "Ano" : "Ne"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
: "0,00 Kč"
|
||||
}
|
||||
color="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label="Pod minimem"
|
||||
value={item.below_minimum ? "Ano" : "Ne"}
|
||||
color={item.below_minimum ? "error" : "info"}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Batches table — only in view mode, for existing items */}
|
||||
{id !== "new" && !editing && (
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.1 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Dávky (FIFO)</h3>
|
||||
{batches.length === 0 ? (
|
||||
<div className="admin-empty-state">Zatím žádné dávky</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum příjmu</th>
|
||||
<th>Původní množství</th>
|
||||
<th>Zůstatek</th>
|
||||
<th>Cena za jednotku</th>
|
||||
<th>Hodnota</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((batch) => (
|
||||
<tr key={batch.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDate(batch.received_at)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{Number(batch.original_qty)}
|
||||
</td>
|
||||
<td className="admin-mono fw-500">
|
||||
{Number(batch.quantity)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatCurrency(Number(batch.unit_price), "CZK")}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatCurrency(
|
||||
Number(batch.quantity) * Number(batch.unit_price),
|
||||
"CZK",
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Dávky (FIFO)
|
||||
</Typography>
|
||||
<DataTable<Batch>
|
||||
columns={batchColumns}
|
||||
rows={batches}
|
||||
rowKey={(b) => b.id}
|
||||
empty={<EmptyState title="Zatím žádné dávky" />}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Locations table — only in view mode, for existing items */}
|
||||
{id !== "new" && !editing && (
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Umístění</h3>
|
||||
{locations.length === 0 ? (
|
||||
<div className="admin-empty-state">Zatím žádná umístění</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kód</th>
|
||||
<th>Název</th>
|
||||
<th>Množství</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{locations.map((loc) => (
|
||||
<tr key={loc.id}>
|
||||
<td className="admin-mono fw-500">
|
||||
{loc.location?.code || "—"}
|
||||
</td>
|
||||
<td>{loc.location?.name || "—"}</td>
|
||||
<td className="admin-mono fw-500">
|
||||
{Number(loc.quantity)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Umístění
|
||||
</Typography>
|
||||
<DataTable<ItemLocation>
|
||||
columns={locationColumns}
|
||||
rows={locations}
|
||||
rowKey={(l) => l.id}
|
||||
empty={<EmptyState title="Zatím žádná umístění" />}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat položku"
|
||||
message={`Opravdu chcete smazat položku „${item?.name ?? ""}"?`}
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Pagination from "../components/Pagination";
|
||||
import SortIcon from "../components/SortIcon";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
@@ -17,11 +14,39 @@ import {
|
||||
warehouseCategoryListOptions,
|
||||
type WarehouseItem,
|
||||
} from "../lib/queries/warehouse";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
TextField,
|
||||
Select,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
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 WarehouseItems() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -30,7 +55,7 @@ export default function WarehouseItems() {
|
||||
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort("item_number");
|
||||
const { sort, order, handleSort } = useTableSort("item_number");
|
||||
|
||||
const { data: categories = [] } = useQuery(warehouseCategoryListOptions());
|
||||
|
||||
@@ -50,266 +75,168 @@ export default function WarehouseItems() {
|
||||
|
||||
const canManage = hasPermission("warehouse.manage");
|
||||
|
||||
const handleRowClick = (item: WarehouseItem) => {
|
||||
navigate(`/warehouse/items/${item.id}`);
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Skladové položky</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? items.length}{" "}
|
||||
{czechPlural(
|
||||
pagination?.total ?? items.length,
|
||||
"položka",
|
||||
"položky",
|
||||
"položek",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => navigate("/warehouse/items/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>
|
||||
Přidat položku
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
const total = pagination?.total ?? items.length;
|
||||
const subtitle = `${total} ${czechPlural(total, "položka", "položky", "položek")}`;
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
const columns: DataColumn<WarehouseItem>[] = [
|
||||
{
|
||||
key: "item_number",
|
||||
header: "Číslo",
|
||||
width: "10%",
|
||||
sortKey: "item_number",
|
||||
mono: true,
|
||||
render: (i) => i.item_number || "—",
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
width: "24%",
|
||||
sortKey: "name",
|
||||
bold: true,
|
||||
render: (i) => i.name,
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
header: "Kategorie",
|
||||
width: "14%",
|
||||
render: (i) => i.category?.name || "—",
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
header: "Jednotka",
|
||||
width: "9%",
|
||||
render: (i) => i.unit,
|
||||
},
|
||||
{
|
||||
key: "total_quantity",
|
||||
header: "Množství",
|
||||
width: "10%",
|
||||
sortKey: "total_quantity",
|
||||
mono: true,
|
||||
render: (i) => String(i.total_quantity ?? 0),
|
||||
},
|
||||
{
|
||||
key: "available_quantity",
|
||||
header: "K dispozici",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (i) => String(i.available_quantity ?? 0),
|
||||
},
|
||||
{
|
||||
key: "stock_value",
|
||||
header: "Hodnota",
|
||||
width: "12%",
|
||||
sortKey: "stock_value",
|
||||
mono: true,
|
||||
render: (i) =>
|
||||
i.stock_value != null
|
||||
? formatCurrency(i.stock_value, "CZK")
|
||||
: "0,00 Kč",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "11%",
|
||||
render: (i) =>
|
||||
i.below_minimum ? (
|
||||
<StatusChip label="Pod min." color="error" />
|
||||
) : i.is_active ? (
|
||||
<StatusChip label="OK" color="success" />
|
||||
) : (
|
||||
<StatusChip label="Neaktivní" color="default" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Skladové položky"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
onClick={() => navigate("/warehouse/items/new")}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<div className="admin-search">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
Přidat položku
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle názvu nebo čísla položky..."
|
||||
className="admin-form-input"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => {
|
||||
setCategoryId(
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
);
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={categoryId === "" ? "" : String(categoryId)}
|
||||
onChange={(val) => {
|
||||
setCategoryId(val === "" ? "" : Number(val));
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všechny kategorie</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={[
|
||||
{ value: "", label: "Všechny kategorie" },
|
||||
...categories.map((cat) => ({
|
||||
value: String(cat.id),
|
||||
label: cat.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${debouncedSearch}-${categoryId}-${page}-${sort}-${order}-${items.length}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="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" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>
|
||||
{search || categoryId
|
||||
? "Žádné položky pro zadaný filtr."
|
||||
: "Zatím nejsou žádné skladové položky."}
|
||||
</p>
|
||||
{canManage && !search && !categoryId && (
|
||||
<button
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<WarehouseItem>
|
||||
columns={columns}
|
||||
rows={items}
|
||||
rowKey={(i) => i.id}
|
||||
rowInactive={(i) => !i.is_active}
|
||||
onRowClick={(i) => navigate(`/warehouse/items/${i.id}`)}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
search || categoryId ? (
|
||||
<EmptyState title="Žádné položky pro zadaný filtr." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Zatím nejsou žádné skladové položky."
|
||||
action={
|
||||
canManage ? (
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
onClick={() => navigate("/warehouse/items/new")}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Přidat první položku
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("item_number")}
|
||||
>
|
||||
Číslo{" "}
|
||||
<SortIcon
|
||||
column="item_number"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
Název{" "}
|
||||
<SortIcon
|
||||
column="name"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Jednotka</th>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("total_quantity")}
|
||||
>
|
||||
Množství{" "}
|
||||
<SortIcon
|
||||
column="total_quantity"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th>K dispozici</th>
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("stock_value")}
|
||||
>
|
||||
Hodnota{" "}
|
||||
<SortIcon
|
||||
column="stock_value"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th>Stav</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
onClick={() => handleRowClick(item)}
|
||||
className={
|
||||
!item.is_active ? "admin-table-row-inactive" : ""
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<td className="admin-mono">
|
||||
{item.item_number || "—"}
|
||||
</td>
|
||||
<td className="fw-500">{item.name}</td>
|
||||
<td>{item.category?.name || "—"}</td>
|
||||
<td>{item.unit}</td>
|
||||
<td className="admin-mono">
|
||||
{item.total_quantity ?? 0}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{item.available_quantity ?? 0}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{item.stock_value != null
|
||||
? formatCurrency(item.stock_value, "CZK")
|
||||
: "0,00 Kč"}
|
||||
</td>
|
||||
<td>
|
||||
{item.below_minimum ? (
|
||||
<span className="admin-badge admin-badge-danger">
|
||||
Pod min.
|
||||
</span>
|
||||
) : item.is_active ? (
|
||||
<span className="admin-badge admin-badge-active">
|
||||
OK
|
||||
</span>
|
||||
) : (
|
||||
<span className="admin-badge admin-badge-inactive">
|
||||
Neaktivní
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user