Compare commits
147 Commits
v1.9.6
...
671323b9ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -274,6 +274,19 @@ When entity A embeds/references entity B, A's mutation handlers invalidate B's d
|
||||
|
||||
---
|
||||
|
||||
## Frontend — MUI Migration (in progress)
|
||||
|
||||
The admin frontend is being migrated off ~7,100 lines of hand-written CSS onto **MUI (Material UI v7)** — a "Soft-SaaS shell + dense tables" look, dark/light preserved, **incremental and always shippable**.
|
||||
|
||||
- **Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md`. **Per-phase plans:** `docs/superpowers/plans/2026-06-06-mui-migration-phase-*.md`.
|
||||
- **Progress is tracked in agent memory** (`project_mui_migration.md` — what's merged, what's next). **Keep it updated as each phase merges.** Memory practice generally: durable, non-obvious facts about ongoing work / decisions / gotchas belong in the agent memory store (one fact per file, indexed in `MEMORY.md`), not left only in chat.
|
||||
- **Migrating a page:** mirror `src/admin/pages/Vehicles.tsx` (the pilot). Import only from the **`src/admin/ui/` kit** — never `@mui/*` directly in pages. **Preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation; drop the page's `admin-*` CSS usage. Per page: plan → subagent-driven build → spec + code-quality review → user visual check → merge.
|
||||
- **Kit (`src/admin/ui/`):** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField (date-fns v4, `dd.MM.yyyy`, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable via `sortKey`/`sortBy`/`onSort`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField, SwitchField, Field, Alert. Theme + tokens in `src/admin/theme.ts`; one `data-theme` attribute drives MUI **and** legacy CSS. Dev-only `/ui-kit` showcase route.
|
||||
- **Gates every step:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
|
||||
- **Dialog gotchas (solved in the kit — don't reintroduce):** dialogs lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroll container); Modal/ConfirmDialog freeze title/label/loading through the close fade (derive-state-from-props) so nothing flashes during the transition. Login renders OUTSIDE the AppShell (sibling route).
|
||||
|
||||
---
|
||||
|
||||
## Database Conventions
|
||||
|
||||
- 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": "1.9.8",
|
||||
"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,157 +64,160 @@ const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||
|
||||
export default function AdminApp() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/admin"
|
||||
element={<AttendanceAdmin />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/balances"
|
||||
element={<AttendanceBalances />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/requests"
|
||||
element={<LeaveRequests />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/approval"
|
||||
element={<LeaveApproval />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="offers" element={<Offers />} />
|
||||
<Route path="offers/new" element={<OfferDetail />} />
|
||||
<Route path="offers/:id" element={<OfferDetail />} />
|
||||
<Route
|
||||
path="offers/customers"
|
||||
element={<OffersCustomers />}
|
||||
/>
|
||||
<Route
|
||||
path="offers/templates"
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route path="warehouse/items" element={<WarehouseItems />} />
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts"
|
||||
element={<WarehouseReceipts />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/new"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id"
|
||||
element={<WarehouseReceiptDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id/edit"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues"
|
||||
element={<WarehouseIssues />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/new"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id"
|
||||
element={<WarehouseIssueDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id/edit"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reservations"
|
||||
element={<WarehouseReservations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory"
|
||||
element={<WarehouseInventory />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/new"
|
||||
element={<WarehouseInventoryForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/:id"
|
||||
element={<WarehouseInventoryDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reports"
|
||||
element={<WarehouseReports />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/suppliers"
|
||||
element={<WarehouseSuppliers />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/locations"
|
||||
element={<WarehouseLocations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/categories"
|
||||
element={<WarehouseCategories />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
<MuiProvider>
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
{import.meta.env.DEV && UiKit && (
|
||||
<Route path="ui-kit" element={<UiKit />} />
|
||||
)}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/admin"
|
||||
element={<AttendanceAdmin />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/balances"
|
||||
element={<AttendanceBalances />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/requests"
|
||||
element={<LeaveRequests />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/approval"
|
||||
element={<LeaveApproval />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="offers" element={<Offers />} />
|
||||
<Route path="offers/new" element={<OfferDetail />} />
|
||||
<Route path="offers/:id" element={<OfferDetail />} />
|
||||
<Route
|
||||
path="offers/customers"
|
||||
element={<OffersCustomers />}
|
||||
/>
|
||||
<Route
|
||||
path="offers/templates"
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route
|
||||
path="warehouse/items"
|
||||
element={<WarehouseItems />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts"
|
||||
element={<WarehouseReceipts />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/new"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id"
|
||||
element={<WarehouseReceiptDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id/edit"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues"
|
||||
element={<WarehouseIssues />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/new"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id"
|
||||
element={<WarehouseIssueDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id/edit"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reservations"
|
||||
element={<WarehouseReservations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory"
|
||||
element={<WarehouseInventory />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/new"
|
||||
element={<WarehouseInventoryForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/:id"
|
||||
element={<WarehouseInventoryDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reports"
|
||||
element={<WarehouseReports />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/suppliers"
|
||||
element={<WarehouseSuppliers />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/locations"
|
||||
element={<WarehouseLocations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/categories"
|
||||
element={<WarehouseCategories />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
</MuiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
162
src/admin/GlobalStyles.tsx
Normal file
162
src/admin/GlobalStyles.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||
import { fonts } from "./theme";
|
||||
|
||||
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* App-wide global styles, theme-aware via MUI's CSS variables.
|
||||
*
|
||||
* This replaces the former hand-written `base.css` (reset, typography,
|
||||
* scrollbar, ::selection, the theme cross-fade timing, utility classes).
|
||||
* It is rendered once inside MuiProvider so every rule resolves against the
|
||||
* active color scheme (`theme.vars.*`). The pre-React bootstrap spinner lives
|
||||
* inline in App.tsx instead, because it renders before MUI mounts.
|
||||
*/
|
||||
export default function AppGlobalStyles() {
|
||||
return (
|
||||
<GlobalStyles
|
||||
styles={(theme) => ({
|
||||
// ---- Reset ----
|
||||
"*": { margin: 0, padding: 0, boxSizing: "border-box" },
|
||||
html: { scrollBehavior: "smooth", overflowX: "hidden" },
|
||||
"html, body, #root": { minHeight: "100dvh", maxWidth: "100vw" },
|
||||
body: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: "16px",
|
||||
lineHeight: 1.6,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
background: theme.vars!.palette.background.default,
|
||||
overflowX: "hidden",
|
||||
overscrollBehaviorX: "none",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
MozOsxFontSmoothing: "grayscale",
|
||||
transition: "background-color 0.3s ease, color 0.3s ease",
|
||||
},
|
||||
"#root": { overflowX: "hidden", touchAction: "pan-y pinch-zoom" },
|
||||
|
||||
// ---- Theme cross-fade (View Transitions API, see ThemeContext) ----
|
||||
"::view-transition-old(root), ::view-transition-new(root)": {
|
||||
animationDuration: "0.3s",
|
||||
animationTimingFunction: EASE,
|
||||
},
|
||||
|
||||
// ---- Typography ----
|
||||
"h1, h2, h3, h4, h5, h6": {
|
||||
fontFamily: fonts.heading,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
},
|
||||
h1: { fontSize: "clamp(2.5rem, 5vw, 4rem)" },
|
||||
h2: { fontSize: "clamp(2rem, 4vw, 3rem)" },
|
||||
h3: { fontSize: "clamp(1.25rem, 2vw, 1.5rem)" },
|
||||
p: { color: theme.vars!.palette.text.secondary, lineHeight: 1.6 },
|
||||
a: {
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
transition: `all 0.3s ${EASE}`,
|
||||
},
|
||||
img: { maxWidth: "100%", height: "auto" },
|
||||
|
||||
// ---- Scrollbar & selection ----
|
||||
"::-webkit-scrollbar": { width: "8px" },
|
||||
"::-webkit-scrollbar-track": {
|
||||
background: theme.vars!.palette.background.paper,
|
||||
},
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
background: theme.vars!.palette.divider,
|
||||
borderRadius: "4px",
|
||||
},
|
||||
"::-webkit-scrollbar-thumb:hover": {
|
||||
background: theme.vars!.palette.text.secondary,
|
||||
},
|
||||
"::selection": {
|
||||
background: theme.vars!.palette.primary.main,
|
||||
color: "#fff",
|
||||
},
|
||||
|
||||
// ---- Utility classes (legacy, still used across pages) ----
|
||||
".text-warning": {
|
||||
color: `${theme.vars!.palette.warning.main} !important`,
|
||||
},
|
||||
".text-danger": {
|
||||
color: `${theme.vars!.palette.error.main} !important`,
|
||||
},
|
||||
".text-success": {
|
||||
color: `${theme.vars!.palette.success.main} !important`,
|
||||
},
|
||||
".text-muted": {
|
||||
color: `${theme.vars!.palette.text.secondary} !important`,
|
||||
},
|
||||
".text-secondary": {
|
||||
color: `${theme.vars!.palette.text.secondary} !important`,
|
||||
},
|
||||
".text-tertiary": {
|
||||
color: `${theme.vars!.palette.text.disabled} !important`,
|
||||
},
|
||||
".text-accent": {
|
||||
color: `${theme.vars!.palette.primary.main} !important`,
|
||||
},
|
||||
".fw-500": { fontWeight: 500 },
|
||||
".fw-600": { fontWeight: "600 !important" },
|
||||
".link-accent": {
|
||||
color: theme.vars!.palette.primary.main,
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
},
|
||||
".link-accent:hover": { textDecoration: "underline" },
|
||||
".flex-1": { flex: 1 },
|
||||
".flex-row": { display: "flex", alignItems: "center" },
|
||||
".flex-row-gap": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
".flex-between": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
".inline-flex": { display: "inline-flex", alignItems: "center" },
|
||||
".mb-1": { marginBottom: "0.25rem" },
|
||||
".mb-2": { marginBottom: "0.5rem" },
|
||||
".mb-3": { marginBottom: "0.75rem" },
|
||||
".mb-4": { marginBottom: "1rem" },
|
||||
".mb-6": { marginBottom: "1.5rem" },
|
||||
".mt-1": { marginTop: "0.25rem" },
|
||||
".mt-2": { marginTop: "0.5rem" },
|
||||
".mt-3": { marginTop: "0.75rem" },
|
||||
".mt-6": { marginTop: "1.5rem" },
|
||||
".gap-1": { gap: "0.25rem" },
|
||||
".gap-2": { gap: "0.5rem" },
|
||||
".gap-3": { gap: "0.75rem" },
|
||||
".gap-4": { gap: "1rem" },
|
||||
".gap-5": { gap: "1.25rem" },
|
||||
".gap-6": { gap: "1.5rem" },
|
||||
".text-right": { textAlign: "right" },
|
||||
".text-center": { textAlign: "center" },
|
||||
".text-xs": { fontSize: "0.75rem" },
|
||||
".text-sm": { fontSize: "0.8125rem" },
|
||||
".text-md": { fontSize: "0.875rem" },
|
||||
".text-base": { fontSize: "1rem" },
|
||||
".w-full": { width: "100%" },
|
||||
".max-w-xs": { maxWidth: "120px" },
|
||||
".max-w-sm": { maxWidth: "200px" },
|
||||
".whitespace-nowrap": { whiteSpace: "nowrap" },
|
||||
|
||||
// ---- Reduced motion ----
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
"::view-transition-old(root), ::view-transition-new(root)": {
|
||||
animation: "none",
|
||||
},
|
||||
"*, *::before, *::after": {
|
||||
animationDuration: "0.01ms !important",
|
||||
animationIterationCount: "1 !important",
|
||||
transitionDuration: "0.01ms !important",
|
||||
scrollBehavior: "auto !important",
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,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
|
||||
key={alert.id}
|
||||
className={`admin-toast admin-toast-${alert.type}`}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<>
|
||||
{alerts.map((alert, index) => (
|
||||
<Snackbar
|
||||
key={alert.id}
|
||||
open
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
sx={{ bottom: { xs: 16 + index * 72 } }}
|
||||
>
|
||||
<MuiAlert
|
||||
severity={alert.type}
|
||||
onClose={() => removeAlert(alert.id)}
|
||||
variant="filled"
|
||||
sx={{ width: "100%", minWidth: 280 }}
|
||||
>
|
||||
<span className="admin-toast-icon">{icons[alert.type]}</span>
|
||||
<span className="admin-toast-message">{alert.message}</span>
|
||||
<button
|
||||
className="admin-toast-close"
|
||||
onClick={() => removeAlert(alert.id)}
|
||||
aria-label="Zavřít"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{alert.message}
|
||||
</MuiAlert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
formatDate,
|
||||
formatDatetime,
|
||||
@@ -6,8 +8,8 @@ import {
|
||||
calculateWorkMinutes,
|
||||
formatMinutes,
|
||||
getLeaveTypeName,
|
||||
getLeaveTypeBadgeClass,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
|
||||
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
||||
|
||||
interface AttendanceRecord extends HookAttendanceRecord {
|
||||
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
|
||||
onDelete: (record: AttendanceRecord) => void;
|
||||
}
|
||||
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||
* sick→error(red), unpaid→default(gray). */
|
||||
function leaveTypeChipColor(
|
||||
type: string,
|
||||
): "default" | "success" | "error" | "warning" | "info" {
|
||||
switch (type) {
|
||||
case "vacation":
|
||||
return "info";
|
||||
case "sick":
|
||||
return "error";
|
||||
case "unpaid":
|
||||
return "default";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
function formatBreak(record: AttendanceRecord): string {
|
||||
if (record.break_start && record.break_end) {
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||
@@ -36,9 +88,7 @@ function formatBreak(record: AttendanceRecord): string {
|
||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
if (record.project_logs && record.project_logs.length > 0) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||
{record.project_logs.map((log, i) => {
|
||||
let h: number,
|
||||
m: number,
|
||||
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span
|
||||
<StatusChip
|
||||
key={log.id ?? i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`}{" "}
|
||||
{durationValid
|
||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||
: "—"}
|
||||
</span>
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} ${
|
||||
durationValid
|
||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||
: "—"
|
||||
}`}
|
||||
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return (
|
||||
<span
|
||||
className="admin-badge admin-badge-wrap"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{record.project_name}
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={record.project_name}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
height: "auto",
|
||||
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: AttendanceShiftTableProps) {
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const columns: DataColumn<AttendanceRecord>[] = [
|
||||
{
|
||||
key: "date",
|
||||
header: "Datum",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => formatDate(record.shift_date),
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
header: "Zaměstnanec",
|
||||
width: "14%",
|
||||
render: (record) => record.user_name,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "10%",
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
return (
|
||||
<StatusChip
|
||||
color={leaveTypeChipColor(leaveType)}
|
||||
label={getLeaveTypeName(leaveType)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "arrival",
|
||||
header: "Příchod",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "break",
|
||||
header: "Pauza",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatBreak(record);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "departure",
|
||||
header: "Odchod",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.departure_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "hours",
|
||||
header: "Hodiny",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
|
||||
: calculateWorkMinutes({
|
||||
...record,
|
||||
notes: record.notes ?? undefined,
|
||||
});
|
||||
return workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "project",
|
||||
header: "Projekt",
|
||||
width: "13%",
|
||||
render: (record) => renderProjectCell(record),
|
||||
},
|
||||
{
|
||||
key: "gps",
|
||||
header: "GPS",
|
||||
width: "5%",
|
||||
render: (record) => {
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
return hasLocation ? (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/attendance/location/${record.id}`}
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
sx={{ textDecoration: "none" }}
|
||||
>
|
||||
<span aria-hidden="true">{"📍"}</span>
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "9%",
|
||||
render: (record) => (
|
||||
<Box
|
||||
component="span"
|
||||
title={record.notes || ""}
|
||||
sx={{
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "8%",
|
||||
align: "right",
|
||||
render: (record) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEdit(record)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => onDelete(record)}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekt</th>
|
||||
<th>GPS</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
||||
60
|
||||
: calculateWorkMinutes({
|
||||
...record,
|
||||
notes: record.notes ?? undefined,
|
||||
});
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
||||
<td>{record.user_name}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreak(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td>
|
||||
{hasLocation ? (
|
||||
<Link
|
||||
to={`/attendance/location/${record.id}`}
|
||||
className="attendance-gps-link"
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
>
|
||||
<span aria-hidden="true">{"📍"}</span>
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={record.notes || ""}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
onClick={() => onEdit(record)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(record)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable<AttendanceRecord>
|
||||
columns={columns}
|
||||
rows={records}
|
||||
rowKey={(record) => record.id}
|
||||
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,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"
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Měsíc">
|
||||
<MonthField
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</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
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent-color)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius-sm)",
|
||||
border: "1px solid var(--border-color)",
|
||||
</Typography>
|
||||
<Typography
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
variant="caption"
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<label key={user.id} className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<small className="admin-form-hint">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</small>
|
||||
</div>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.25,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<CheckboxField
|
||||
key={user.id}
|
||||
label={user.name}
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h2>Něco se pokazilo</h2>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Typography variant="h5">Něco se pokazilo</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{this.state.error?.message}
|
||||
</Typography>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Obnovit stránku
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import { EmptyState, Button } from "../ui";
|
||||
|
||||
export default function Forbidden() {
|
||||
return (
|
||||
<div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<h2>403</h2>
|
||||
<p>Nemáte oprávnění pro přístup k této stránce.</p>
|
||||
<Link to="/" className="admin-btn admin-btn-primary">
|
||||
Zpět na Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="403"
|
||||
description="Nemáte oprávnění pro přístup k této stránce."
|
||||
action={
|
||||
<Button component={Link} to="/">
|
||||
Zpět na Dashboard
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
useId,
|
||||
} from "react";
|
||||
|
||||
interface FormFieldProps {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
label,
|
||||
children,
|
||||
error,
|
||||
required,
|
||||
style,
|
||||
}: FormFieldProps) {
|
||||
const generatedId = useId();
|
||||
const childProps = isValidElement(children)
|
||||
? (children.props as Record<string, unknown>)
|
||||
: null;
|
||||
const childId = childProps?.id ? String(childProps.id) : generatedId;
|
||||
const childWithId = isValidElement(children)
|
||||
? cloneElement(children, { id: childId } as React.Attributes)
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div className="admin-form-group" style={style}>
|
||||
<label className="admin-form-label" htmlFor={childId}>
|
||||
{label}
|
||||
{required && <span className="admin-form-required"> *</span>}
|
||||
</label>
|
||||
{childWithId}
|
||||
{error && <span className="admin-form-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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,250 +110,320 @@ 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"
|
||||
disabled={loading}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<div className="admin-form">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ minWidth: "200px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "80px" }}
|
||||
step="0.001"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(i, "unit", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "100px" }}
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "70px" }}
|
||||
step="1"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat položku
|
||||
</button>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
<Box>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
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}
|
||||
</Button>
|
||||
</Box>
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||
>
|
||||
Položka {i + 1}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(i)}
|
||||
title="Odstranit"
|
||||
aria-label="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Popis"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(i, "unit", e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Cena"
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit_price", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="%DPH"
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
"& th": {
|
||||
textAlign: "left",
|
||||
fontSize: ".7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: ".05em",
|
||||
fontWeight: 700,
|
||||
color: "text.secondary",
|
||||
pb: 1,
|
||||
},
|
||||
"& td": { py: 0.5, pr: 1, verticalAlign: "middle" },
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<TextField
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 90 }}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit", e.target.value)
|
||||
}
|
||||
sx={{ width: 70 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 110 }}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(i)}
|
||||
title="Odstranit"
|
||||
aria-label="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={addItem}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Vygenerovat PDF"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+ Přidat položku
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</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"
|
||||
checked={isRange}
|
||||
onChange={(e) => setIsRange(e.target.checked)}
|
||||
/>
|
||||
<span>Více dní</span>
|
||||
</label>
|
||||
</FormField>
|
||||
<Field label="Rozsah dnů">
|
||||
<CheckboxField
|
||||
label="Více dní"
|
||||
checked={isRange}
|
||||
onChange={setIsRange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!isOverride && isRange && (
|
||||
<FormField label="Datum do">
|
||||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
||||
</FormField>
|
||||
<Field label="Datum do">
|
||||
<DateField value={dateTo} onChange={setDateTo} />
|
||||
</Field>
|
||||
)}
|
||||
<FormField label="Projekt">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={projectId ?? ""}
|
||||
onChange={(e) =>
|
||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
>
|
||||
<option value="">— bez projektu —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Kategorie">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
<Field label="Projekt">
|
||||
<Select
|
||||
value={projectId === null ? "" : String(projectId)}
|
||||
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kategorie">
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{/* If the row's current category was retired/hidden, still show it
|
||||
(marked) so the select reflects reality instead of silently
|
||||
snapping to the first active option. The server only re-checks
|
||||
the category if it changes, so keeping it saves fine. */}
|
||||
{category && !activeCategories.some((c) => c.key === category) && (
|
||||
<option value={category}>
|
||||
{planCategoryLabel(
|
||||
category,
|
||||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
||||
)}{" "}
|
||||
(skrytá)
|
||||
</option>
|
||||
)}
|
||||
{activeCategories.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Text poznámky (volitelný)">
|
||||
<textarea
|
||||
className="admin-form-textarea"
|
||||
onChange={(val) => setCategory(val)}
|
||||
options={[
|
||||
...(showRetiredCategory
|
||||
? [
|
||||
{
|
||||
value: category,
|
||||
label: `${planCategoryLabel(
|
||||
category,
|
||||
Object.fromEntries(
|
||||
props.categories.map((x) => [x.key, x]),
|
||||
),
|
||||
)} (skrytá)`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...activeCategories.map((c) => ({
|
||||
value: c.key,
|
||||
label: c.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Text poznámky (volitelný)">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
inputProps={{ maxLength: 500 }}
|
||||
/>
|
||||
</FormField>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDelete}
|
||||
title="Smazat záznam plánu?"
|
||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||
confirmText="Smazat"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={submitting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
@@ -376,26 +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"
|
||||
}
|
||||
onClick={c.onClick}
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor:
|
||||
c.tone === "primary" ? "primary.main" : "divider",
|
||||
}}
|
||||
>
|
||||
{c.cta}
|
||||
</button>
|
||||
</motion.li>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography sx={{ fontWeight: 600 }}>{c.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{c.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant={c.tone === "primary" ? "contained" : "outlined"}
|
||||
color={c.tone === "primary" ? "primary" : "inherit"}
|
||||
onClick={c.onClick}
|
||||
>
|
||||
{c.cta}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zavřít — ponechat rozsah beze změny
|
||||
</button>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -454,29 +473,35 @@ 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"
|
||||
>
|
||||
<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,76 +518,271 @@ export default function ProjectFileManager({
|
||||
};
|
||||
|
||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
<div className="fm-empty">
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Soubory
|
||||
</Typography>
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--text-tertiary)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={errorMessage}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="fm-toolbar">
|
||||
<div className="fm-breadcrumb">
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<span key={i} className="fm-breadcrumb-segment">
|
||||
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? "active" : ""}`}
|
||||
onClick={() => handleBreadcrumbClick(i)}
|
||||
>
|
||||
{i === 0 ? projectNumber : segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{fullPath && (
|
||||
<span className="fm-full-path" title={fullPath}>
|
||||
{fullPath}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="fm-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => {
|
||||
setNewFolderMode(!newFolderMode);
|
||||
setNewFolderName("");
|
||||
const columns: DataColumn<FileItem>[] = [
|
||||
{
|
||||
key: "icon",
|
||||
header: "",
|
||||
width: 30,
|
||||
align: "left",
|
||||
render: (item) => (
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
{getFileIcon(item.type, item.extension)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
render: (item) =>
|
||||
renamingItem === item.name ? (
|
||||
<TextField
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
autoFocus
|
||||
sx={{ maxWidth: 300 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRename(item);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isCancelling.current = true;
|
||||
setRenamingItem(null);
|
||||
setRenameValue(item.name);
|
||||
setTimeout(() => {
|
||||
isCancelling.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isCancelling.current) {
|
||||
return;
|
||||
}
|
||||
handleRename(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FileNameCell item={item} onFolderClick={handleFolderClick} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "Velikost",
|
||||
width: 90,
|
||||
render: (item) => (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.type === "file" ? item.size_formatted : "—"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "modified",
|
||||
header: "Změněno",
|
||||
width: 120,
|
||||
render: (item) => (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.modified || "—"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: 100,
|
||||
align: "right" as const,
|
||||
render: (item: FileItem) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{item.type === "file" && (
|
||||
<IconButton
|
||||
size="small"
|
||||
title="Stáhnout"
|
||||
aria-label="Stáhnout"
|
||||
onClick={() => handleDownload(item)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
title="Přejmenovat"
|
||||
aria-label="Přejmenovat"
|
||||
onClick={() => startRename(item)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Soubory
|
||||
</Typography>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<Box
|
||||
component="span"
|
||||
key={i}
|
||||
sx={{ display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
{i > 0 && (
|
||||
<Box component="span" sx={{ mx: 0.25, color: "text.disabled" }}>
|
||||
/
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => handleBreadcrumbClick(i)}
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
font: "inherit",
|
||||
cursor: "pointer",
|
||||
color:
|
||||
i === breadcrumb.length - 1
|
||||
? "text.primary"
|
||||
: "primary.main",
|
||||
fontWeight: i === breadcrumb.length - 1 ? 600 : 400,
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{i === 0 ? projectNumber : segment}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{fullPath && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
title={fullPath}
|
||||
sx={{
|
||||
maxWidth: 320,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{fullPath}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<Box sx={{ display: "flex", gap: 1, ml: "auto" }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setNewFolderMode(!newFolderMode);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
startIcon={
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -551,101 +795,148 @@ export default function ProjectFileManager({
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
Složka
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Nahrávání...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Nahrát
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New folder input */}
|
||||
{newFolderMode && (
|
||||
<div className="fm-new-folder">
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Název složky..."
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateFolder();
|
||||
if (e.key === "Escape") {
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: "12px", padding: "6px 10px" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={creatingFolder || !newFolderName.trim()}
|
||||
}
|
||||
>
|
||||
{creatingFolder ? (
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
) : (
|
||||
"Vytvořit"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => {
|
||||
Složka
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
startIcon={
|
||||
uploading ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
>
|
||||
{uploading ? "Nahrávání..." : "Nahrát"}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* New folder input */}
|
||||
{newFolderMode && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Název složky..."
|
||||
autoFocus
|
||||
sx={{ maxWidth: 320 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateFolder();
|
||||
if (e.key === "Escape") {
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={creatingFolder || !newFolderName.trim()}
|
||||
>
|
||||
{creatingFolder ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
"Vytvořit"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Drop zone + table */}
|
||||
<Box
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
sx={{
|
||||
position: "relative",
|
||||
borderRadius: 2,
|
||||
...(dragOver
|
||||
? {
|
||||
outline: "2px dashed",
|
||||
outlineColor: "primary.main",
|
||||
outlineOffset: -4,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{dragOver && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 1,
|
||||
bgcolor: "action.hover",
|
||||
color: "primary.main",
|
||||
borderRadius: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</div>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span>Přetáhněte soubory sem</span>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Drop zone + table */}
|
||||
<div
|
||||
className={`fm-content ${dragOver ? "fm-drag-over" : ""}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="fm-dropzone-overlay">
|
||||
{items.length === 0 && !filesLoading ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
@@ -653,180 +944,31 @@ export default function ProjectFileManager({
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span>Přetáhněte soubory sem</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && !filesLoading ? (
|
||||
<div className="fm-empty">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--text-tertiary)"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>
|
||||
{hasNasFolder
|
||||
? "Složka je prázdná"
|
||||
: "Složka projektu zatím neexistuje"}
|
||||
</span>
|
||||
{canManage && !hasNasFolder && (
|
||||
<span style={{ fontSize: "11px" }}>
|
||||
Nahrání souboru ji automaticky vytvoří
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "30px" }}></th>
|
||||
<th>Název</th>
|
||||
<th style={{ width: "90px" }}>Velikost</th>
|
||||
<th style={{ width: "120px" }}>Změněno</th>
|
||||
{canManage && (
|
||||
<th style={{ width: "100px", textAlign: "right" }}>
|
||||
Akce
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td style={{ textAlign: "center" }}>
|
||||
{getFileIcon(item.type, item.extension)}
|
||||
</td>
|
||||
<td>
|
||||
{renamingItem === item.name ? (
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "3px 8px",
|
||||
maxWidth: "300px",
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRename(item);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isCancelling.current = true;
|
||||
setRenamingItem(null);
|
||||
setRenameValue(item.name);
|
||||
setTimeout(() => {
|
||||
isCancelling.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isCancelling.current) {
|
||||
return;
|
||||
}
|
||||
handleRename(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FileNameCell
|
||||
item={item}
|
||||
onFolderClick={handleFolderClick}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="fm-meta">
|
||||
{item.type === "file" ? item.size_formatted : "\u2014"}
|
||||
</td>
|
||||
<td className="fm-meta">{item.modified || "\u2014"}</td>
|
||||
{canManage && (
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<div className="fm-actions">
|
||||
{item.type === "file" && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
title="Stáhnout"
|
||||
onClick={() => handleDownload(item)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
title="Přejmenovat"
|
||||
onClick={() => startRename(item)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
hasNasFolder
|
||||
? "Složka je prázdná"
|
||||
: "Složka projektu zatím neexistuje"
|
||||
}
|
||||
description={
|
||||
canManage && !hasNasFolder
|
||||
? "Nahrání souboru ji automaticky vytvoří"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={items}
|
||||
rowKey={(item) => item.name}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
@@ -836,9 +978,9 @@ export default function ProjectFileManager({
|
||||
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import { RichEditorRoot } from "../ui/RichText";
|
||||
|
||||
const COLORS = [
|
||||
"#000000",
|
||||
@@ -104,8 +105,7 @@ export default function RichEditor({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-rich-editor"
|
||||
<RichEditorRoot
|
||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||
>
|
||||
<ReactQuill
|
||||
@@ -118,6 +118,6 @@ export default function RichEditor({
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</RichEditorRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,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 }}
|
||||
>
|
||||
<option value="">— Projekt —</option>
|
||||
{projectList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.project_number} – {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
value={log.hours}
|
||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="h"
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 3, minWidth: 0 }}>
|
||||
<Select
|
||||
value={String(log.project_id)}
|
||||
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "— Projekt —" },
|
||||
...projectList.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
value={log.hours}
|
||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||
placeholder="h"
|
||||
slotProps={{
|
||||
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
h
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={log.minutes}
|
||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="m"
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
</Typography>
|
||||
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
value={log.minutes}
|
||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||
placeholder="m"
|
||||
slotProps={{
|
||||
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
m
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemove(index)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
style={{ padding: "0.375rem", flexShrink: 0 }}
|
||||
aria-label="Odebrat"
|
||||
title="Odebrat"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,200 +262,202 @@ 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"
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
{isCreate ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<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) => updateField("shift_date", val)}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ záznamu</label>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!isWorkType && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Počet hodin</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
/>
|
||||
{isCreate && (
|
||||
<small className="admin-form-hint">8 hodin = celý den</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val) => updateField("break_start_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => updateField("break_start_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - datum</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Projekty</label>
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProjectLog}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat projekt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámka</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<Field label="Datum směny">
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Typ záznamu">
|
||||
<Select
|
||||
value={form.leave_type}
|
||||
onChange={(val) => updateField("leave_type", val)}
|
||||
options={[
|
||||
{ value: "work", label: "Práce" },
|
||||
{ value: "vacation", label: "Dovolená" },
|
||||
{ value: "sick", label: "Nemoc" },
|
||||
{ value: "unpaid", label: "Neplacené volno" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isWorkType && (
|
||||
<Field
|
||||
label="Počet hodin"
|
||||
hint={isCreate ? "8 hodin = celý den" : undefined}
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod - datum">
|
||||
<DateField
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Příchod - čas">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break start */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_start_date}
|
||||
onChange={(val) => updateField("break_start_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Začátek pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => updateField("break_start_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break end */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Konec pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Odchod - datum">
|
||||
<DateField
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod - čas">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<Field label="Projekty">
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={addProjectLog}
|
||||
>
|
||||
+ Přidat projekt
|
||||
</Button>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
interface SortIconProps {
|
||||
column: string;
|
||||
sort: string | null;
|
||||
order: string;
|
||||
}
|
||||
|
||||
export default function SortIcon({ column, sort, order }: SortIconProps) {
|
||||
if (sort !== column) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ opacity: 0.3, marginLeft: 4 }}
|
||||
>
|
||||
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
{order === "asc" ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Card, Button, EmptyState } from "../../ui";
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
getActivityIconClass,
|
||||
@@ -18,6 +21,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 +116,92 @@ export default function DashActivityFeed({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-activity-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Audit log</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Audit log</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/audit-log"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{activities.map((act) => (
|
||||
<div key={act.id} className="dash-activity-row">
|
||||
<div
|
||||
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</div>
|
||||
<div className="dash-activity-main">
|
||||
<div className="dash-activity-text">{act.description}</div>
|
||||
<div className="dash-activity-sub">
|
||||
{act.username || "Systém"} ·{" "}
|
||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-activity-time admin-mono">
|
||||
{formatActivityTime(act.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<EmptyState title="Žádná aktivita" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{activities.map((act) => {
|
||||
const badgeColor =
|
||||
ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ??
|
||||
"default";
|
||||
const isDefault = badgeColor === "default";
|
||||
return (
|
||||
<Box
|
||||
key={act.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
bgcolor: isDefault ? "grey.600" : `${badgeColor}.main`,
|
||||
color: "common.white",
|
||||
}}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500 }}
|
||||
noWrap
|
||||
title={act.description}
|
||||
>
|
||||
{act.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{act.username || "Systém"} ·{" "}
|
||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatActivityTime(act.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LEAVE_TYPE_LABELS,
|
||||
STATUS_DOT_CLASS,
|
||||
STATUS_LABELS,
|
||||
} from "../../utils/dashboardHelpers";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import { Card, Button, StatusChip, EmptyState } from "../../ui";
|
||||
import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
|
||||
|
||||
interface AttendanceUser {
|
||||
user_id: number | string;
|
||||
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
|
||||
attendance: AttendanceData | null;
|
||||
}
|
||||
|
||||
// Maps the attendance status to the StatusChip / avatar tint color.
|
||||
// Mirrors the legacy dash-status-* dot classes: in→success, away→warning,
|
||||
// out→default, leave→error.
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"default" | "success" | "warning" | "error"
|
||||
> = {
|
||||
in: "success",
|
||||
away: "warning",
|
||||
out: "default",
|
||||
leave: "error",
|
||||
};
|
||||
|
||||
export default function DashAttendanceToday({
|
||||
attendance,
|
||||
}: DashAttendanceTodayProps) {
|
||||
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-attendance-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Docházka dnes</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Docházka dnes</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{attendance.users.map((u, i) => (
|
||||
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
|
||||
<div
|
||||
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</div>
|
||||
<div className="dash-presence-name">{u.name}</div>
|
||||
<div className="dash-presence-end">
|
||||
<span
|
||||
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{attendance.users.length === 0 ? (
|
||||
<EmptyState title="Žádná docházka" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{attendance.users.map((u, i) => {
|
||||
const color = STATUS_COLOR[u.status] ?? "default";
|
||||
const isDefault = color === "default";
|
||||
return (
|
||||
<Box
|
||||
key={`${u.user_id}-${i}`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
{u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]}
|
||||
</span>
|
||||
{u.arrived_at && (
|
||||
<span className="admin-mono dash-presence-time">
|
||||
{u.arrived_at}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
bgcolor: isDefault ? "grey.600" : `${color}.main`,
|
||||
color: "common.white",
|
||||
}}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</Avatar>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500, flex: 1, minWidth: 0 }}
|
||||
noWrap
|
||||
>
|
||||
{u.name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<StatusChip
|
||||
color={color}
|
||||
label={
|
||||
u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]
|
||||
}
|
||||
/>
|
||||
{u.arrived_at && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{u.arrived_at}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { StatCard, type StatCardColor } from "../../ui";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
interface KpiCard {
|
||||
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
||||
};
|
||||
}
|
||||
|
||||
const KPI_CLASS_MAP: Record<number, string> = {
|
||||
4: "admin-kpi-4",
|
||||
3: "admin-kpi-3",
|
||||
2: "admin-kpi-2",
|
||||
1: "admin-kpi-1",
|
||||
// Maps the legacy KPI color tokens to the StatCard color palette
|
||||
// (danger → error; the rest are 1:1).
|
||||
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
danger: "error",
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`admin-kpi-grid ${kpiClass}`}
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{kpiCards.map((kpi) => (
|
||||
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
|
||||
<div className="admin-stat-label">{kpi.label}</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{kpi.value}
|
||||
{kpi.sub && (
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
marginLeft: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
||||
</div>
|
||||
<StatCard
|
||||
key={kpi.label}
|
||||
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
|
||||
label={kpi.label}
|
||||
value={
|
||||
<>
|
||||
{kpi.value}
|
||||
{kpi.sub && (
|
||||
<Typography
|
||||
component="span"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
ml: 0.5,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={kpi.footer ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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 { Card, Button, Modal, Field, TextField } from "../../ui";
|
||||
import useDialogScrollLock from "../../ui/useDialogScrollLock";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -37,6 +45,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 +97,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,80 +173,94 @@ 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,
|
||||
bgcolor: totpEnabled ? "success.main" : "grey.600",
|
||||
color: "common.white",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
@@ -220,406 +274,390 @@ 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"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
value={formData.username}
|
||||
<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, username: 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">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
</Field>
|
||||
<Field label="Příjmení">
|
||||
<TextField
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
setFormData({ ...formData, last_name: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Aktuální heslo</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
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
|
||||
type="password"
|
||||
value={formData.new_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
new_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Box>
|
||||
<Field label="Uživatelské jméno">
|
||||
<TextField
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="E-mail">
|
||||
<TextField
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Aktuální heslo">
|
||||
<TextField
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, current_password: e.target.value })
|
||||
}
|
||||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Nové heslo">
|
||||
<TextField
|
||||
type="password"
|
||||
value={formData.new_password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, new_password: e.target.value })
|
||||
}
|
||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||
/>
|
||||
</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).
|
||||
setShow2FASetup(false);
|
||||
setTotpCode("");
|
||||
}}
|
||||
title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||||
size="md"
|
||||
loading={backupCodes ? false : totpSubmitting}
|
||||
onSubmit={
|
||||
{/* 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);
|
||||
setBackupCodes(null);
|
||||
setTotpCode("");
|
||||
}
|
||||
: 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 } } }}
|
||||
>
|
||||
{backupCodes ? (
|
||||
<div>
|
||||
<div className="admin-role-locked-notice mb-4">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
<DialogTitle>
|
||||
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{backupCodes ? (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 1,
|
||||
p: 1.5,
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: "warning.light",
|
||||
color: "warning.main",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
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={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "0.5rem",
|
||||
padding: "1rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{backupCodes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
style={{
|
||||
padding: "0.25rem 0.5rem",
|
||||
textAlign: "center",
|
||||
color: "var(--text-primary)",
|
||||
<Box sx={{ flexShrink: 0, mt: "2px" }}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
</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: 1,
|
||||
p: 2,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{backupCodes.map((code) => (
|
||||
<Box
|
||||
key={code}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
textAlign: "center",
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(backupCodes.join("\n"));
|
||||
alert.success("Kódy zkopírovány");
|
||||
}}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
startIcon={CopyIcon}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(backupCodes.join("\n"));
|
||||
alert.success("Kódy zkopírovány");
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<div>
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
|
||||
>
|
||||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||||
Authy, Microsoft Authenticator apod.)
|
||||
</p>
|
||||
{totpQrUri && (
|
||||
<div style={{ textAlign: "center", marginBottom: "1rem" }}>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
||||
alt="TOTP QR Code"
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--border-color)",
|
||||
Kopírovat kódy
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||||
Authy, Microsoft Authenticator apod.)
|
||||
</Typography>
|
||||
{totpQrUri && (
|
||||
<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"
|
||||
sx={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{totpSecret && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
component="label"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 0.5 }}
|
||||
>
|
||||
Nebo zadejte klíč ručně:
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
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>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(totpSecret);
|
||||
alert.success("Klíč zkopírován");
|
||||
}}
|
||||
size="small"
|
||||
title="Kopírovat"
|
||||
aria-label="Kopírovat"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{CopyIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Field label="Ověřovací kód z aplikace">
|
||||
<TextField
|
||||
inputRef={totpSetupRef}
|
||||
inputMode="numeric"
|
||||
value={totpCode}
|
||||
onChange={(e) =>
|
||||
setTotpCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder="000000"
|
||||
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||||
sx={{
|
||||
"& input": {
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
},
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
onConfirm2FA();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{totpSecret && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
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)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span>{totpSecret}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(totpSecret);
|
||||
alert.success("Klíč zkopírován");
|
||||
}}
|
||||
className="admin-btn-icon"
|
||||
title="Kopírovat"
|
||||
aria-label="Kopírovat"
|
||||
style={{ 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>
|
||||
)}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Ověřovací kód z aplikace
|
||||
</label>
|
||||
<input
|
||||
ref={totpSetupRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="000000"
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "monospace",
|
||||
</Field>
|
||||
</Box>
|
||||
)}
|
||||
</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("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
onConfirm2FA();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormModal>
|
||||
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"
|
||||
submitText="Deaktivovat 2FA"
|
||||
cancelText="Zrušit"
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
||||
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={{
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "monospace",
|
||||
autoFocus
|
||||
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||||
sx={{
|
||||
"& input": {
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
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,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte vozidlo</option>
|
||||
{tripVehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.spz} - {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tripErrors.vehicle_id && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.vehicle_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">Datum jízdy</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val: string) => {
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
trip_date: undefined,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{tripErrors.trip_date && (
|
||||
<span className="admin-form-error">{tripErrors.trip_date}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Počáteční stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
start_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
start_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.start_km && (
|
||||
<span className="admin-form-error">{tripErrors.start_km}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Konečný stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
end_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
end_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.end_km && (
|
||||
<span className="admin-form-error">{tripErrors.end_km}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Vzdálenost</label>
|
||||
<input
|
||||
type="text"
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">Místo odjezdu</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_from: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
{tripErrors.route_from && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_from}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo příjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_to: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_to: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
{tripErrors.route_to && (
|
||||
<span className="admin-form-error">{tripErrors.route_to}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ jízdy</label>
|
||||
<select
|
||||
value={tripForm.is_business}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
is_business: parseInt(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
<option value={0}>Soukromá</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámky</label>
|
||||
<textarea
|
||||
value={tripForm.notes}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
notes: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Vozidlo" required error={tripErrors.vehicle_id}>
|
||||
<Select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(val) => {
|
||||
handleTripVehicleChange(val);
|
||||
setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte vozidlo" },
|
||||
...tripVehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
|
||||
<DateField
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val) => {
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konečný stav km" required error={tripErrors.end_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vzdálenost">
|
||||
<TextField
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
InputProps={{ readOnly: true }}
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
|
||||
<TextField
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
|
||||
}}
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
|
||||
<TextField
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
|
||||
}}
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Field label="Typ jízdy">
|
||||
<Select
|
||||
value={String(tripForm.is_business)}
|
||||
onChange={(val) =>
|
||||
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
|
||||
}
|
||||
options={[
|
||||
{ value: "1", label: "Služební" },
|
||||
{ value: "0", label: "Soukromá" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={tripForm.notes}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||
}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import ConfirmModal from "../ConfirmModal";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
||||
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
||||
@@ -76,8 +79,6 @@ export default function DashSessions() {
|
||||
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useModalLock(deleteAllModal);
|
||||
|
||||
const handleDeleteSession = async () => {
|
||||
if (!deleteModal.session) {
|
||||
return;
|
||||
@@ -127,113 +128,140 @@ export default function DashSessions() {
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div
|
||||
className="admin-card-header"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h2 className="admin-card-title">Přihlášená zařízení</h2>
|
||||
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||
<button
|
||||
onClick={() => setDeleteAllModal(true)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
Odhlásit ostatní
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Přihlášená zařízení</Typography>
|
||||
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||
<Button
|
||||
onClick={() => setDeleteAllModal(true)}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Odhlásit ostatní
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sessionsLoading ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : sessions.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center", py: 3 }}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{sessions.length === 0 && (
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: "0.875rem",
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{sessions.map((session) => (
|
||||
<Box
|
||||
key={session.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
...(session.is_current
|
||||
? { bgcolor: "action.hover", borderRadius: 2, px: 1 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</div>
|
||||
)}
|
||||
{sessions.length > 0 && (
|
||||
<div className="dash-sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "text.secondary",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div className="dash-session-icon">
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</div>
|
||||
<div className="dash-session-info">
|
||||
<div className="dash-session-device">
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
{session.is_current && (
|
||||
<span
|
||||
className="admin-badge admin-badge-success"
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Aktuální
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dash-session-meta">
|
||||
<span>{session.ip_address}</span>
|
||||
<span className="dash-session-meta-separator">|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-session-actions">
|
||||
{!session.is_current && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
</Typography>
|
||||
{session.is_current && (
|
||||
<StatusChip color="success" label="Aktuální" />
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<span>{session.ip_address}</span>
|
||||
<span>|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
{!session.is_current && (
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
color="error"
|
||||
size="small"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
||||
onConfirm={handleDeleteSession}
|
||||
@@ -241,10 +269,10 @@ export default function DashSessions() {
|
||||
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
|
||||
confirmText="Ukončit"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteAllModal}
|
||||
onClose={() => setDeleteAllModal(false)}
|
||||
onConfirm={handleDeleteAllSessions}
|
||||
@@ -252,7 +280,7 @@ export default function DashSessions() {
|
||||
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
||||
confirmText="Odhlásit vše"
|
||||
cancelText="Zrušit"
|
||||
type="warning"
|
||||
confirmVariant="primary"
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../../lib/apiAdapter";
|
||||
|
||||
interface Batch {
|
||||
id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
received_at: string;
|
||||
is_consumed: boolean;
|
||||
}
|
||||
|
||||
interface BatchesResponse {
|
||||
batches: Batch[];
|
||||
total_stock: number;
|
||||
reserved_quantity: number;
|
||||
available_quantity: number;
|
||||
}
|
||||
|
||||
interface BatchPickerProps {
|
||||
itemId: number | null;
|
||||
value: number | null;
|
||||
onChange: (batchId: number, unitPrice: number) => void;
|
||||
}
|
||||
|
||||
export default function BatchPicker({
|
||||
itemId,
|
||||
value,
|
||||
onChange,
|
||||
}: BatchPickerProps) {
|
||||
const { data: response } = useQuery({
|
||||
queryKey: ["warehouse", "batches", itemId],
|
||||
queryFn: () =>
|
||||
jsonQuery<BatchesResponse>(
|
||||
`/api/admin/warehouse/items/${itemId}/batches`,
|
||||
),
|
||||
enabled: !!itemId,
|
||||
});
|
||||
|
||||
const batches = response?.batches ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{response && response.reserved_quantity > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-tertiary)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
|
||||
{response.reserved_quantity} ks)
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const batchId = Number(e.target.value);
|
||||
const batch = batches.find((b) => b.id === batchId);
|
||||
if (batch) onChange(batch.id, Number(batch.unit_price));
|
||||
}}
|
||||
>
|
||||
<option value="">-- auto FIFO --</option>
|
||||
{batches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
|
||||
ks | {Number(b.unit_price).toFixed(2)} Kč
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
warehouseItemListOptions,
|
||||
type WarehouseItem,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface ItemPickerProps {
|
||||
value: number | null;
|
||||
@@ -9,220 +16,88 @@ interface ItemPickerProps {
|
||||
itemName?: string;
|
||||
}
|
||||
|
||||
/** Minimal option shape used to display a selected value that isn't in the
|
||||
* current search results (edit/prefill via `itemName`). */
|
||||
type ItemOption = Pick<WarehouseItem, "id" | "name"> & Partial<WarehouseItem>;
|
||||
|
||||
export default function ItemPicker({
|
||||
value,
|
||||
onChange,
|
||||
itemName,
|
||||
}: ItemPickerProps) {
|
||||
const [search, setSearch] = useState(itemName ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
|
||||
const items = data?.data ?? [];
|
||||
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeIndexRef = useRef<number>(-1);
|
||||
const listboxId = useId();
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const { data, isFetching } = useQuery(
|
||||
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||
);
|
||||
const options: ItemOption[] = data?.data ?? [];
|
||||
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
||||
position: "fixed";
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
zIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 2,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 100,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset active index when items change
|
||||
useEffect(() => {
|
||||
if (activeIndex >= items.length) {
|
||||
activeIndexRef.current = -1;
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [items, activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
updatePosition();
|
||||
const onScroll = () => updatePosition();
|
||||
const onClose = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
} else {
|
||||
setDropdownStyle(null);
|
||||
activeIndexRef.current = -1;
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [open, updatePosition]);
|
||||
|
||||
// Close on click-outside via mousedown on document
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (containerRef.current && target instanceof Node) {
|
||||
if (!containerRef.current.contains(target)) {
|
||||
// Don't close if click landed on an option in the portal
|
||||
const listEl = document.getElementById(listboxId);
|
||||
if (listEl && listEl.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
||||
}, [open, listboxId]);
|
||||
|
||||
const handleSelect = (itemId: number, name: string) => {
|
||||
onChange(itemId);
|
||||
setOpen(false);
|
||||
setSearch(name);
|
||||
};
|
||||
|
||||
const setActive = (index: number) => {
|
||||
activeIndexRef.current = index;
|
||||
setActiveIndex(index);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
if (items.length > 0) setActive(0);
|
||||
return;
|
||||
}
|
||||
const next =
|
||||
activeIndexRef.current < items.length - 1
|
||||
? activeIndexRef.current + 1
|
||||
: 0;
|
||||
setActive(next);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
if (items.length > 0) setActive(items.length - 1);
|
||||
return;
|
||||
}
|
||||
const prev =
|
||||
activeIndexRef.current > 0
|
||||
? activeIndexRef.current - 1
|
||||
: items.length - 1;
|
||||
setActive(prev);
|
||||
} else if (e.key === "Enter") {
|
||||
if (
|
||||
open &&
|
||||
activeIndexRef.current >= 0 &&
|
||||
activeIndexRef.current < items.length
|
||||
) {
|
||||
e.preventDefault();
|
||||
const item = items[activeIndexRef.current];
|
||||
handleSelect(item.id, item.name);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
} else if (e.key === "Home") {
|
||||
if (open && items.length > 0) {
|
||||
e.preventDefault();
|
||||
setActive(0);
|
||||
}
|
||||
} else if (e.key === "End") {
|
||||
if (open && items.length > 0) {
|
||||
e.preventDefault();
|
||||
setActive(items.length - 1);
|
||||
}
|
||||
} else if (e.key === "Tab") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeId =
|
||||
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
||||
// Show the selected value even when it isn't in the current search results
|
||||
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
|
||||
const selectedOption: ItemOption | null =
|
||||
value == null
|
||||
? null
|
||||
: (options.find((o) => o.id === value) ?? {
|
||||
id: value,
|
||||
name: itemName ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "relative" }}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-owns={listboxId}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat položku..."
|
||||
value={search}
|
||||
role="searchbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeId}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
updatePosition();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{open &&
|
||||
items.length > 0 &&
|
||||
dropdownStyle &&
|
||||
createPortal(
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="admin-item-picker-list"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={value === item.id}
|
||||
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(item.id, item.name);
|
||||
}}
|
||||
>
|
||||
<span className="admin-item-picker-name">{item.name}</span>
|
||||
{item.item_number && (
|
||||
<span className="admin-item-picker-number">
|
||||
{item.item_number}
|
||||
</span>
|
||||
)}
|
||||
{item.available_quantity !== undefined && (
|
||||
<span className="admin-item-picker-qty">
|
||||
{item.available_quantity} {item.unit}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
<Autocomplete<ItemOption>
|
||||
value={selectedOption}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, newInput) => setInputValue(newInput)}
|
||||
onChange={(_, opt) => {
|
||||
if (opt) onChange(opt.id);
|
||||
}}
|
||||
options={options}
|
||||
loading={isFetching}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(o) => o.name}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
noOptionsText="Žádné položky"
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...rest } = props;
|
||||
return (
|
||||
<Box component="li" key={key} {...rest}>
|
||||
<Box>
|
||||
<Typography variant="body2">{option.name}</Typography>
|
||||
{(option.item_number ||
|
||||
option.available_quantity !== undefined) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.item_number}
|
||||
{option.item_number && option.available_quantity !== undefined
|
||||
? " · "
|
||||
: ""}
|
||||
{option.available_quantity !== undefined
|
||||
? `${option.available_quantity} ${option.unit ?? ""}`
|
||||
: ""}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
placeholder="Hledat položku..."
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<CircularProgress color="inherit" size={18} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
warehouseLocationListOptions,
|
||||
type WarehouseLocation,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface LocationSelectProps {
|
||||
value: number | null;
|
||||
onChange: (locationId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function LocationSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: LocationSelectProps) {
|
||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- bez lokace --</option>
|
||||
{locations?.map((loc: WarehouseLocation) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{loc.code} - {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { Select } from "../../ui";
|
||||
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
||||
|
||||
interface ReservationPickerProps {
|
||||
@@ -26,11 +28,9 @@ export default function ReservationPicker({
|
||||
const reservations = result?.data ?? [];
|
||||
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
<Select
|
||||
value={value == null ? "" : String(value)}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
onChange(null, 0);
|
||||
return;
|
||||
@@ -42,13 +42,13 @@ export default function ReservationPicker({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">— žádná rezervace —</option>
|
||||
<MenuItem value="">— žádná rezervace —</MenuItem>
|
||||
{reservations.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
<MenuItem key={r.id} value={String(r.id)}>
|
||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||
</option>
|
||||
</MenuItem>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
warehouseSupplierListOptions,
|
||||
type WarehouseSupplier,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface SupplierSelectProps {
|
||||
value: number | null;
|
||||
onChange: (supplierId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function SupplierSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: SupplierSelectProps) {
|
||||
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
|
||||
const suppliers = data?.data ?? [];
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- bez dodavatele --</option>
|
||||
{suppliers.map((s: WarehouseSupplier) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
{s.ico ? ` (${s.ico})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { ReactNode, useId, useRef } from "react";
|
||||
import ItemPicker from "./ItemPicker";
|
||||
import LocationSelect from "./LocationSelect";
|
||||
import BatchPicker from "./BatchPicker";
|
||||
|
||||
export interface MovementItem {
|
||||
key: string;
|
||||
item_id: number | null;
|
||||
item_name?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
location_id: number | null;
|
||||
batch_id: number | null;
|
||||
reservation_id: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export type MovementRowRenderer<T> = (
|
||||
item: T,
|
||||
index: number,
|
||||
updateItem: (field: string, value: unknown) => void,
|
||||
removeItem: () => void,
|
||||
) => ReactNode;
|
||||
|
||||
interface WarehouseMovementTableProps<T extends { key: string }> {
|
||||
items: T[];
|
||||
onChange: (items: T[]) => void;
|
||||
mode: "receipt" | "issue" | "inventory";
|
||||
defaultItem?: () => Omit<T, "key">;
|
||||
renderRow?: MovementRowRenderer<T>;
|
||||
renderHeader?: () => ReactNode;
|
||||
}
|
||||
|
||||
function parseDecimal(raw: string): number {
|
||||
// Strip leading minus if you want negative support; here we want only positive
|
||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
|
||||
item_id: null,
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
location_id: null,
|
||||
batch_id: null,
|
||||
reservation_id: null,
|
||||
notes: null,
|
||||
});
|
||||
|
||||
export default function WarehouseMovementTable<T extends { key: string }>({
|
||||
items,
|
||||
onChange,
|
||||
mode,
|
||||
defaultItem,
|
||||
renderRow,
|
||||
renderHeader,
|
||||
}: WarehouseMovementTableProps<T>) {
|
||||
const baseId = useId();
|
||||
const counterRef = useRef(0);
|
||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
||||
|
||||
const addItem = () => {
|
||||
const factory =
|
||||
defaultItem ??
|
||||
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
|
||||
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
|
||||
};
|
||||
const removeItem = (index: number) => {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
const updateItem = (index: number, field: string, value: unknown) => {
|
||||
const updated = [...items];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
// If a custom row renderer is supplied, the caller is fully in charge of
|
||||
// the row layout (and typically the column headers too — see inventory mode).
|
||||
if (renderRow) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-warehouse-movement-table">
|
||||
<table className="admin-table">
|
||||
{renderHeader && (
|
||||
<thead>
|
||||
<tr>{renderHeader()}</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.key}>
|
||||
{renderRow(
|
||||
item,
|
||||
index,
|
||||
(field, value) => updateItem(index, field, value),
|
||||
() => removeItem(index),
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat řádek
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
|
||||
const movementItems = items as unknown as MovementItem[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-warehouse-movement-table">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="admin-warehouse-col-item">Položka</th>
|
||||
{mode === "issue" && (
|
||||
<th className="admin-warehouse-col-batch">Šarže</th>
|
||||
)}
|
||||
<th className="admin-warehouse-col-qty">Množství</th>
|
||||
<th className="admin-warehouse-col-price">Cena/ks</th>
|
||||
<th className="admin-warehouse-col-location">Lokace</th>
|
||||
<th className="admin-warehouse-col-notes">Poznámka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movementItems.map((item, index) => (
|
||||
<tr key={item.key}>
|
||||
<td className="admin-warehouse-col-item">
|
||||
<ItemPicker
|
||||
value={item.item_id}
|
||||
itemName={item.item_name}
|
||||
onChange={(id) => updateItem(index, "item_id", id)}
|
||||
/>
|
||||
</td>
|
||||
{mode === "issue" && (
|
||||
<td className="admin-warehouse-col-batch">
|
||||
<BatchPicker
|
||||
itemId={item.item_id}
|
||||
value={item.batch_id}
|
||||
onChange={(batchId, unitPrice) => {
|
||||
updateItem(index, "batch_id", batchId);
|
||||
updateItem(index, "unit_price", unitPrice);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="admin-warehouse-col-qty">
|
||||
<input
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.quantity || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
"quantity",
|
||||
parseDecimal(e.target.value),
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
step="0.001"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+([\.,][0-9]+)?"
|
||||
aria-label={`Množství, řádek ${index + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-price">
|
||||
<input
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.unit_price || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
"unit_price",
|
||||
parseDecimal(e.target.value),
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={mode === "issue"}
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+([\.,][0-9]+)?"
|
||||
aria-label={`Cena za kus, řádek ${index + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-location">
|
||||
<LocationSelect
|
||||
value={item.location_id}
|
||||
onChange={(id) => updateItem(index, "location_id", id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-notes">
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
value={item.notes ?? ""}
|
||||
onChange={(e) => updateItem(index, "notes", e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
onClick={() => removeItem(index)}
|
||||
title="Odebrat řádek"
|
||||
aria-label="Odebrat řádek"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat řádek
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
/* ============================================================================
|
||||
Dashboard
|
||||
============================================================================ */
|
||||
|
||||
/* Dashboard layout */
|
||||
.dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.dash .admin-page-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.dash-quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.dash-quick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-quick-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.dash-quick-btn-success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-quick-btn-info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.dash-quick-btn-warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-quick-btn-danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dash-quick-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
[data-theme="light"] .dash-quick-btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-info {
|
||||
background: var(--info);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-warning {
|
||||
background: var(--warning);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Main content 3-col grid */
|
||||
.dash-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Card link */
|
||||
.dash-card-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.dash-card-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Activity rows */
|
||||
.dash-activity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dash-activity-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-activity-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-activity-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-activity-icon.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-activity-icon.info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.dash-activity-icon.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-activity-icon.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.dash-activity-icon.accent {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.dash-activity-icon.muted {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dash-activity-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-activity-text {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-activity-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.dash-activity-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Presence rows */
|
||||
.dash-presence-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dash-presence-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-presence-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dash-presence-avatar.dash-status-in {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-away {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-out {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-leave {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.dash-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-status-dot.dash-status-in {
|
||||
background: var(--success);
|
||||
}
|
||||
.dash-status-dot.dash-status-away {
|
||||
background: var(--warning);
|
||||
}
|
||||
.dash-status-dot.dash-status-out {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.dash-status-dot.dash-status-leave {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.dash-presence-label.dash-status-in {
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-presence-label.dash-status-away {
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-presence-label.dash-status-out {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dash-presence-label.dash-status-leave {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.dash-presence-name {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dash-presence-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-presence-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dash-presence-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Right column */
|
||||
.dash-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Project rows */
|
||||
.dash-project-row {
|
||||
display: block;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-decoration: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dash-project-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-project-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-project-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dash-project-customer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Stat mini rows */
|
||||
.dash-stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dash-stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Empty row */
|
||||
.dash-empty-row {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Bottom stacked layout (profile + sessions) */
|
||||
.dash-bottom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dash .admin-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Profile grid inside account card */
|
||||
.dash-profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.dash-profile-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.dash-profile-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dash-profile-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Responsive
|
||||
============================================================================ */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dash-main-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.dash-right-col {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dash-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-right-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.dash-profile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Sessions / Devices
|
||||
============================================================================ */
|
||||
|
||||
.dash-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dash-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.dash-session-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-session-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-session-item-current {
|
||||
background: var(--row-current);
|
||||
}
|
||||
|
||||
.dash-session-item-current:hover {
|
||||
background: var(--row-current-hover);
|
||||
}
|
||||
|
||||
.dash-session-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-session-item-current .dash-session-icon {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.dash-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-session-device {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dash-session-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dash-session-meta-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
.dash-session-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-session-item {
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dash-session-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.dash-session-device {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dash-session-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -1,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
|
||||
value={filterUserId}
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všichni</option>
|
||||
{data.users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filterUserId}
|
||||
onChange={setFilterUserId}
|
||||
options={[
|
||||
{ value: "", label: "Všichni zaměstnanci" },
|
||||
...data.users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: user.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{/* User Totals */}
|
||||
{Object.keys(data.user_totals).length > 0 && (
|
||||
<motion.div
|
||||
className="admin-grid admin-grid-3 mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.09 }}
|
||||
{/* User Totals (KPI cards) */}
|
||||
{hasTotals && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||
const ut = userData as UserTotalData;
|
||||
const balance = data.leave_balances[uid];
|
||||
const statColor: StatCardColor = ut.working ? "success" : "default";
|
||||
return (
|
||||
<div key={uid} className="admin-card" style={{ display: "flex" }}>
|
||||
<div
|
||||
className="admin-card-body"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex-row gap-2 mb-2">
|
||||
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
||||
<span
|
||||
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
|
||||
>
|
||||
{ut.working ? "\u2713" : "\u2717"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-stat-value">
|
||||
{formatMinutes(ut.minutes)}
|
||||
</div>
|
||||
<div className="admin-stat-label">odpracováno</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.75rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
minHeight: "2rem",
|
||||
<StatCard
|
||||
key={uid}
|
||||
label={ut.name}
|
||||
value={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{ut.vacation_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-vacation">
|
||||
Dov: {ut.vacation_hours}h
|
||||
</span>
|
||||
)}
|
||||
{ut.sick_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-sick">
|
||||
Nem: {ut.sick_hours}h
|
||||
</span>
|
||||
)}
|
||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||
<span className="attendance-leave-badge badge-holiday">
|
||||
Sv: {Math.round(ut.svatek)}h
|
||||
</span>
|
||||
)}
|
||||
{ut.unpaid_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-unpaid">
|
||||
Nep: {ut.unpaid_hours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ut.fund !== null &&
|
||||
(() => {
|
||||
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
|
||||
// (everything the user actually got paid for this month:
|
||||
// raw worked time with sub-day remainders, plus
|
||||
// vacation days, plus hours worked on holidays, plus
|
||||
// the 8h per unworked holiday from the fund).
|
||||
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
|
||||
const fondUsed =
|
||||
realWorked +
|
||||
(ut.vacation_hours ?? 0) +
|
||||
(ut.worked_holiday_hours ?? 0) +
|
||||
(ut.svatek ?? 0);
|
||||
const fundVal = ut.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
return (
|
||||
<div
|
||||
className="kpi-card-footer"
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
paddingTop: "0.75rem",
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||
{formatHoursDecimal(fundVal * 60)}h
|
||||
</span>
|
||||
{delta > 0 && (
|
||||
<span className="text-warning fw-600">
|
||||
+{formatHoursDecimal(delta * 60)}h
|
||||
</span>
|
||||
)}
|
||||
{delta <= 0 && delta < 0 && (
|
||||
<span className="text-danger fw-600">
|
||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.375rem",
|
||||
height: "4px",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "2px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(fondUsed / (ut.fund || 1)) * 100,
|
||||
)}%`,
|
||||
background: getFundBarBackground(ut),
|
||||
borderRadius: "2px",
|
||||
transition: "width 0.3s ease",
|
||||
{formatMinutes(ut.minutes)}
|
||||
<StatusChip
|
||||
color={ut.working ? "success" : "default"}
|
||||
label={ut.working ? "✓" : "✗"}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
color={statColor}
|
||||
footer={
|
||||
<Box>
|
||||
{/* Leave-type badges */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
minHeight: "1.75rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{ut.vacation_hours > 0 && (
|
||||
<StatusChip
|
||||
color="info"
|
||||
label={`Dov: ${ut.vacation_hours}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.sick_hours > 0 && (
|
||||
<StatusChip
|
||||
color="error"
|
||||
label={`Nem: ${ut.sick_hours}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||
<StatusChip
|
||||
color="warning"
|
||||
label={`Sv: ${Math.round(ut.svatek)}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.unpaid_hours > 0 && (
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={`Nep: ${ut.unpaid_hours}h`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Fond usage */}
|
||||
{ut.fund !== null &&
|
||||
(() => {
|
||||
// Fond "used" = real_worked + vacation +
|
||||
// worked_holiday + free_holiday (everything the user
|
||||
// actually got paid for this month).
|
||||
const fondUsed = getFondUsed(ut);
|
||||
const fundVal = ut.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
const pct = Math.min(
|
||||
100,
|
||||
(fondUsed / (ut.fund || 1)) * 100,
|
||||
);
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||
{formatHoursDecimal(fundVal * 60)}h
|
||||
</Typography>
|
||||
{delta > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "warning.main",
|
||||
}}
|
||||
>
|
||||
+{formatHoursDecimal(delta * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
{delta < 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "error.main",
|
||||
}}
|
||||
>
|
||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<ProgressBar
|
||||
value={pct}
|
||||
color={getFundBarColor(ut)}
|
||||
height={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
|
||||
>
|
||||
{data.leave_balances[uid] ? (
|
||||
<>
|
||||
Zbývá dovolené:{" "}
|
||||
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
|
||||
h / {data.leave_balances[uid].vacation_total}h
|
||||
</>
|
||||
) : (
|
||||
<span style={{ visibility: "hidden" }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Remaining vacation */}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
{balance ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Zbývá dovolené:{" "}
|
||||
{balance.vacation_remaining.toFixed(1)}h /{" "}
|
||||
{balance.vacation_total}h
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ visibility: "hidden" }}
|
||||
>
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Records Table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<AttendanceShiftTable
|
||||
records={data.records}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
<Card>
|
||||
<AttendanceShiftTable
|
||||
records={data.records}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
<BulkAttendanceModal
|
||||
@@ -435,7 +441,7 @@ export default function AttendanceAdmin() {
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -444,6 +450,6 @@ export default function AttendanceAdmin() {
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import { userListOptions } from "../lib/queries/users";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import FormField from "../components/FormField";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { todayLocalStr } from "../utils/formatters";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DateField,
|
||||
Field,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
PageHeader,
|
||||
Select,
|
||||
TextField,
|
||||
TimeField,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface CreateForm {
|
||||
@@ -102,223 +114,218 @@ export default function AttendanceCreate() {
|
||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Přidat záznam docházky</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Přidat záznam docházky"
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
← Zpět na správu
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
style={{ maxWidth: "600px" }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<form onSubmit={handleSubmit} className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Zaměstnanec" required>
|
||||
<select
|
||||
value={form.user_id}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, user_id: e.target.value })
|
||||
}
|
||||
className="admin-form-select"
|
||||
required
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Datum směny" required>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val: string) => handleShiftDateChange(val)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Typ záznamu" required>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, leave_type: e.target.value })
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{!isWorkType && (
|
||||
<FormField label="Počet hodin">
|
||||
<input
|
||||
type="number"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Výchozí 8 hodin pro celý den
|
||||
</small>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Příchod - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Příchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Začátek pauzy - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_start_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Začátek pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Konec pauzy - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_end_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Konec pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Odchod - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, departure_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Odchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
<Card sx={{ maxWidth: 640 }}>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
{/* Employee + Shift date */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Zaměstnanec" required>
|
||||
<Select
|
||||
value={form.user_id}
|
||||
onChange={(val) => setForm({ ...form, user_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...users.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
<Field label="Datum směny" required>
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => handleShiftDateChange(val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<Link
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
{/* Record type */}
|
||||
<Field label="Typ záznamu" required>
|
||||
<Select
|
||||
value={form.leave_type}
|
||||
onChange={(val) => setForm({ ...form, leave_type: val })}
|
||||
options={[
|
||||
{ value: "work", label: "Práce" },
|
||||
{ value: "vacation", label: "Dovolená" },
|
||||
{ value: "sick", label: "Nemoc" },
|
||||
{ value: "unpaid", label: "Neplacené volno" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Leave hours — shown only for non-work types */}
|
||||
{!isWorkType && (
|
||||
<Field label="Počet hodin" hint="Výchozí 8 hodin pro celý den">
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Work-type time fields */}
|
||||
{isWorkType && (
|
||||
<>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
Zrušit
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
<Field label="Příchod - datum">
|
||||
<DateField
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => setForm({ ...form, arrival_date: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Příchod - čas">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break start */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{submitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<Field label="Začátek pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_start_date}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_date: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Začátek pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break end */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Konec pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_end_date}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_date: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Odchod - datum">
|
||||
<DateField
|
||||
value={form.departure_date}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_date: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod - čas">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: "flex", gap: 1.5, justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Ukládám..." : "Uložit"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 {
|
||||
attendanceHistoryOptions,
|
||||
@@ -25,7 +25,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 +55,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 +150,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 +171,28 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
||||
m = mins % 60;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
<StatusChip
|
||||
key={log.id || i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:
|
||||
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
|
||||
</span>
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
|
||||
sx={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return (
|
||||
<span
|
||||
className="admin-badge admin-badge-wrap"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{record.project_name}
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={record.project_name}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
height: "auto",
|
||||
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
@@ -373,325 +396,264 @@ export default function AttendanceHistory() {
|
||||
};
|
||||
};
|
||||
|
||||
const fund = computed.monthlyFund;
|
||||
const progressValue =
|
||||
fund.fund > 0 ? Math.min(100, (fund.worked / fund.fund) * 100) : 0;
|
||||
const progressColor =
|
||||
computed.delta > 0
|
||||
? "warning"
|
||||
: computed.delta >= 0
|
||||
? "success"
|
||||
: "primary";
|
||||
const deltaColor =
|
||||
computed.delta > 0
|
||||
? "warning.main"
|
||||
: computed.delta < 0
|
||||
? "error.main"
|
||||
: "success.main";
|
||||
|
||||
const columns: DataColumn<AttendanceRecord>[] = [
|
||||
{
|
||||
key: "date",
|
||||
header: "Datum",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => formatDate(record.shift_date),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "11%",
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
return (
|
||||
<StatusChip
|
||||
color={leaveTypeChipColor(leaveType)}
|
||||
label={getLeaveTypeName(leaveType)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "arrival",
|
||||
header: "Příchod",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "break",
|
||||
header: "Pauza",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatBreakRange(record);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "departure",
|
||||
header: "Odchod",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.departure_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "hours",
|
||||
header: "Hodiny",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
return workMinutes > 0 ? formatMinutes(workMinutes, true) : "—";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "projects",
|
||||
header: "Projekty",
|
||||
width: "18%",
|
||||
render: (record) => renderProjectCell(record),
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "11%",
|
||||
render: (record) => record.notes || "",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Historie docházky</h1>
|
||||
<p className="admin-page-subtitle">{computed.monthName}</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
{records.length > 0 && (
|
||||
<button
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Historie docházky"
|
||||
subtitle={computed.monthName}
|
||||
actions={
|
||||
records.length > 0 ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PrintIcon}
|
||||
onClick={handlePrint}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk docházky"
|
||||
>
|
||||
Tisk
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{/* Monthly Fund Card */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
bgcolor: "info.main",
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginRight: "0.5rem" }}
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
Tisk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Měsíc">
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={month}
|
||||
onChange={(val: string) => setMonth(val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Monthly Fund Card */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{computed.monthlyFund && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: "200px" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
|
||||
Fond: {fund.worked}h / {fund.fund}h
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
<div className="admin-stat-icon info">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
||||
{computed.monthlyFund.fund}h
|
||||
</span>
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
{computed.monthlyFund.business_days} prac. dnů
|
||||
{computed.monthlyFund.holiday_count > 0 &&
|
||||
` + ${computed.monthlyFund.holiday_count} svátky`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-balance-bar">
|
||||
<div
|
||||
className="attendance-balance-progress"
|
||||
style={{
|
||||
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
|
||||
background:
|
||||
computed.delta > 0
|
||||
? "linear-gradient(135deg, var(--warning), #d97706)"
|
||||
: computed.delta >= 0
|
||||
? "linear-gradient(135deg, var(--success), #059669)"
|
||||
: "var(--gradient)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
marginTop: "0.5rem",
|
||||
minHeight: "1.5rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{computed.totalMinutes > 0 && (
|
||||
<span
|
||||
className="attendance-leave-badge"
|
||||
title="Odpracováno (reálně)"
|
||||
>
|
||||
Práce: {formatHoursDecimal(computed.totalMinutes)}h
|
||||
</span>
|
||||
)}
|
||||
{computed.vacationHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-vacation">
|
||||
Dov: {computed.vacationHours}h
|
||||
</span>
|
||||
)}
|
||||
{computed.sickHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-sick">
|
||||
Nem: {computed.sickHours}h
|
||||
</span>
|
||||
)}
|
||||
{computed.freeHolidayHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-holiday">
|
||||
Sv: {Math.round(computed.freeHolidayHours)}h
|
||||
</span>
|
||||
)}
|
||||
{computed.unpaidHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-unpaid">
|
||||
Nep: {computed.unpaidHours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
className={
|
||||
computed.delta > 0
|
||||
? "text-warning fw-600"
|
||||
: computed.delta < 0
|
||||
? "text-danger fw-600"
|
||||
: "text-success fw-600"
|
||||
}
|
||||
>
|
||||
{computed.delta > 0
|
||||
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
||||
: computed.delta < 0
|
||||
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
||||
: "Fond splněn"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!computed.monthlyFund && (
|
||||
<div
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "0.5rem 0",
|
||||
}}
|
||||
{fund.business_days} prac. dnů
|
||||
{fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProgressBar value={progressValue} color={progressColor} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.5,
|
||||
mt: 1,
|
||||
minHeight: "1.5rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{computed.totalMinutes > 0 && (
|
||||
<StatusChip
|
||||
color="info"
|
||||
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
|
||||
title="Odpracováno (reálně)"
|
||||
/>
|
||||
)}
|
||||
{computed.vacationHours > 0 && (
|
||||
<StatusChip
|
||||
color="success"
|
||||
label={`Dov: ${computed.vacationHours}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.sickHours > 0 && (
|
||||
<StatusChip
|
||||
color="error"
|
||||
label={`Nem: ${computed.sickHours}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.freeHolidayHours > 0 && (
|
||||
<StatusChip
|
||||
color="warning"
|
||||
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.unpaidHours > 0 && (
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={`Nep: ${computed.unpaidHours}h`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, color: deltaColor }}
|
||||
>
|
||||
Fond měsíce není k dispozici
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
{computed.delta > 0
|
||||
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
||||
: computed.delta < 0
|
||||
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
||||
: "Fond splněn"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Records Table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
)}
|
||||
{records.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDate(record.shift_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreakRange(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? formatMinutes(workMinutes, true)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "150px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<AttendanceRecord>
|
||||
columns={columns}
|
||||
rows={records}
|
||||
rowKey={(record) => record.id}
|
||||
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Hidden Print Content */}
|
||||
{records.length > 0 && (
|
||||
@@ -896,6 +858,24 @@ export default function AttendanceHistory() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||
* sick→error(red), unpaid→default(gray). */
|
||||
function leaveTypeChipColor(
|
||||
type: string,
|
||||
): "default" | "success" | "error" | "warning" | "info" {
|
||||
switch (type) {
|
||||
case "vacation":
|
||||
return "info";
|
||||
case "sick":
|
||||
return "error";
|
||||
case "unpaid":
|
||||
return "default";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,47 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { styled } from "@mui/material/styles";
|
||||
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet map container (was the .attendance-location-map rule). zIndex:0
|
||||
// creates a stacking context so Leaflet's internal z-indexes stay contained.
|
||||
const LocationMap = styled("div")(({ theme }) => ({
|
||||
height: "400px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1.5rem",
|
||||
background: theme.vars!.palette.background.paper,
|
||||
position: "relative",
|
||||
zIndex: 0,
|
||||
"@media (max-width:640px)": {
|
||||
height: "clamp(220px, 45vh, 400px)",
|
||||
},
|
||||
}));
|
||||
|
||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
attendanceLocationOptions,
|
||||
type LocationRecord,
|
||||
} from "../lib/queries/attendance";
|
||||
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
||||
|
||||
const BackIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AttendanceLocation() {
|
||||
const alert = useAlert();
|
||||
@@ -167,127 +197,164 @@ export default function AttendanceLocation() {
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Poloha záznamu"
|
||||
actions={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/attendance/admin?month=${month}`}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
Zpět na správu
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-header">
|
||||
<h2 className="admin-card-title">
|
||||
{record.user_name} — {formatDate(record.shift_date)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{hasAnyLocation && (
|
||||
<div ref={mapRef} className="attendance-location-map" />
|
||||
)}
|
||||
{/* Info + map card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{record.user_name} — {formatDate(record.shift_date)}
|
||||
</Typography>
|
||||
|
||||
<div className="attendance-location-grid">
|
||||
{/* Arrival */}
|
||||
<div
|
||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
||||
{/* Leaflet map */}
|
||||
{hasAnyLocation && <LocationMap ref={mapRef} />}
|
||||
|
||||
{/* Location detail grid */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
mt: hasAnyLocation ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
opacity: hasArrivalLocation ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Příchod
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
|
||||
>
|
||||
<h3 className="attendance-location-title">Příchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
: "—"}
|
||||
</Typography>
|
||||
{hasArrivalLocation ? (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: "text.secondary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
{(hasDepartureLocation || record.departure_time) && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
opacity: hasDepartureLocation ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Odchod
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
|
||||
>
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasArrivalLocation ? (
|
||||
</Typography>
|
||||
{hasDepartureLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: "text.secondary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Departure */}
|
||||
{(hasDepartureLocation || record.departure_time) && (
|
||||
<div
|
||||
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
|
||||
>
|
||||
<h3 className="attendance-location-title">Odchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasDepartureLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import Pagination from "../components/Pagination";
|
||||
import FormField from "../components/FormField";
|
||||
import FormModal from "../components/FormModal";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { czechPlural } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
Modal,
|
||||
Select,
|
||||
DateField,
|
||||
TextField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -30,19 +44,23 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
access_denied: "Přístup odepřen",
|
||||
};
|
||||
|
||||
const ACTION_BADGE_CLASS: Record<string, string> = {
|
||||
create: "admin-badge-success",
|
||||
update: "admin-badge-info",
|
||||
delete: "admin-badge-danger",
|
||||
login: "admin-badge-secondary",
|
||||
login_failed: "admin-badge-danger",
|
||||
logout: "admin-badge-secondary",
|
||||
view: "admin-badge-info",
|
||||
activate: "admin-badge-success",
|
||||
deactivate: "admin-badge-warning",
|
||||
password_change: "admin-badge-info",
|
||||
permission_change: "admin-badge-warning",
|
||||
access_denied: "admin-badge-danger",
|
||||
// Maps legacy admin-badge-* CSS class suffixes → StatusChip color
|
||||
const ACTION_CHIP_COLOR: Record<
|
||||
string,
|
||||
"success" | "info" | "warning" | "error" | "default"
|
||||
> = {
|
||||
create: "success",
|
||||
update: "info",
|
||||
delete: "error",
|
||||
login: "default",
|
||||
login_failed: "error",
|
||||
logout: "default",
|
||||
view: "info",
|
||||
activate: "success",
|
||||
deactivate: "warning",
|
||||
password_change: "info",
|
||||
permission_change: "warning",
|
||||
access_denied: "error",
|
||||
};
|
||||
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
||||
@@ -53,6 +71,29 @@ const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
|
||||
const CLEANUP_DAYS_OPTIONS = [
|
||||
{ value: "30", label: "30 dní" },
|
||||
{ value: "60", label: "60 dní" },
|
||||
{ value: "90", label: "90 dní" },
|
||||
{ value: "180", label: "180 dní" },
|
||||
{ value: "365", label: "1 rok" },
|
||||
{ value: "0", label: "Vše" },
|
||||
];
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
@@ -82,11 +123,16 @@ export default function AuditLog() {
|
||||
date_to: "",
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(50);
|
||||
const [perPage] = useState(50);
|
||||
const [showCleanup, setShowCleanup] = useState(false);
|
||||
const [cleanupDays, setCleanupDays] = useState(90);
|
||||
// cleanupDays stored as string for the kit Select; converted to number at the boundary
|
||||
const [cleanupDaysStr, setCleanupDaysStr] = useState("90");
|
||||
|
||||
const { data: logsData, isPending } = useQuery({
|
||||
const {
|
||||
data: logsData,
|
||||
isPending,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"audit-log",
|
||||
{
|
||||
@@ -127,6 +173,10 @@ export default function AuditLog() {
|
||||
},
|
||||
};
|
||||
},
|
||||
// Keep the current rows on screen while a filter refetches, so the page
|
||||
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
||||
// whole entrance animation on every filter change).
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||
@@ -154,16 +204,8 @@ export default function AuditLog() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPage(1);
|
||||
setPerPage(newPerPage);
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const cleanupDays = Number(cleanupDaysStr);
|
||||
try {
|
||||
// Backend requires this literal confirmation token when wiping everything.
|
||||
await cleanupMutation.mutateAsync({
|
||||
@@ -181,229 +223,156 @@ export default function AuditLog() {
|
||||
};
|
||||
|
||||
if (isPending && logs.length === 0) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Audit log</h1>
|
||||
{pagination && (
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination.total}{" "}
|
||||
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => setShowCleanup(true)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
Vyčistit
|
||||
</button>
|
||||
</motion.div>
|
||||
const columns: DataColumn<AuditLogEntry>[] = [
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Čas",
|
||||
width: "16%",
|
||||
mono: true,
|
||||
render: (log) => formatDatetime(log.created_at),
|
||||
},
|
||||
{
|
||||
key: "username",
|
||||
header: "Uživatel",
|
||||
width: "14%",
|
||||
bold: true,
|
||||
render: (log) => log.username || "-",
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: "Akce",
|
||||
width: "14%",
|
||||
render: (log) => (
|
||||
<StatusChip
|
||||
label={ACTION_LABELS[log.action] || log.action}
|
||||
color={ACTION_CHIP_COLOR[log.action] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "entity_type",
|
||||
header: "Typ entity",
|
||||
width: "14%",
|
||||
render: (log) =>
|
||||
ENTITY_TYPE_LABELS[log.entity_type || ""] || log.entity_type || "-",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "Popis",
|
||||
width: "30%",
|
||||
render: (log) => log.description || "-",
|
||||
},
|
||||
{
|
||||
key: "user_ip",
|
||||
header: "IP",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (log) => log.user_ip || "-",
|
||||
},
|
||||
];
|
||||
|
||||
<FormModal
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Audit log"
|
||||
subtitle={
|
||||
pagination
|
||||
? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}`
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={TrashIcon}
|
||||
onClick={() => setShowCleanup(true)}
|
||||
>
|
||||
Vyčistit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Cleanup Modal */}
|
||||
<Modal
|
||||
isOpen={showCleanup}
|
||||
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
||||
onSubmit={handleCleanup}
|
||||
title="Vyčistit audit log"
|
||||
submitLabel="Smazat"
|
||||
submitText="Smazat"
|
||||
loading={cleanupMutation.isPending}
|
||||
>
|
||||
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
|
||||
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={cleanupDays}
|
||||
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
|
||||
>
|
||||
<option value={30}>30 dní</option>
|
||||
<option value={60}>60 dní</option>
|
||||
<option value={90}>90 dní</option>
|
||||
<option value={180}>180 dní</option>
|
||||
<option value={365}>1 rok</option>
|
||||
<option value={0}>Vše</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
className="admin-confirm-message"
|
||||
style={{ fontSize: "12px", opacity: 0.6 }}
|
||||
>
|
||||
Tato akce je nevratná.
|
||||
</p>
|
||||
</FormModal>
|
||||
|
||||
<motion.div
|
||||
className="admin-card mb-4"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row admin-form-row-5">
|
||||
<FormField label="Hledat">
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Popis, uživatel..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Akce">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange("action", e.target.value)}
|
||||
>
|
||||
<option value="">Všechny</option>
|
||||
{ACTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Typ entity">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={filters.entity_type}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("entity_type", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Všechny</option>
|
||||
{ENTITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Od">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_from}
|
||||
onChange={(val: string) => handleFilterChange("date_from", val)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_to}
|
||||
onChange={(val: string) => handleFilterChange("date_to", val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-table-responsive">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Čas</th>
|
||||
<th>Uživatel</th>
|
||||
<th>Akce</th>
|
||||
<th>Typ entity</th>
|
||||
<th>Popis</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Žádné záznamy k zobrazení</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{logs.length > 0 &&
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDatetime(log.created_at)}
|
||||
</td>
|
||||
<td className="fw-500">{log.username || "-"}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
|
||||
>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
|
||||
log.entity_type ||
|
||||
"-"}
|
||||
</td>
|
||||
<td>{log.description || "-"}</td>
|
||||
<td className="admin-mono">{log.user_ip || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Smazat záznamy starší než:
|
||||
</Typography>
|
||||
<Box sx={{ maxWidth: 200, mb: 1 }}>
|
||||
<Select
|
||||
value={cleanupDaysStr}
|
||||
onChange={setCleanupDaysStr}
|
||||
options={CLEANUP_DAYS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ opacity: 0.6, fontSize: "0.75rem" }}>
|
||||
Tato akce je nevratná.
|
||||
</Typography>
|
||||
</Modal>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
placeholder="Popis, uživatel..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 160px" }}>
|
||||
<Select
|
||||
value={filters.action}
|
||||
onChange={(val) => handleFilterChange("action", val)}
|
||||
options={[{ value: "", label: "Všechny akce" }, ...ACTION_OPTIONS]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filters.entity_type}
|
||||
onChange={(val) => handleFilterChange("entity_type", val)}
|
||||
options={[
|
||||
{ value: "", label: "Všechny entity" },
|
||||
...ENTITY_OPTIONS,
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={filters.date_from}
|
||||
onChange={(val) => handleFilterChange("date_from", val)}
|
||||
label="Od"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={filters.date_to}
|
||||
onChange={(val) => handleFilterChange("date_to", val)}
|
||||
label="Do"
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<AuditLogEntry>
|
||||
columns={columns}
|
||||
rows={logs}
|
||||
rowKey={(log) => log.id}
|
||||
empty={<EmptyState title="Žádné záznamy k zobrazení" />}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
||||
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 DashKpiCards from "../components/dashboard/DashKpiCards";
|
||||
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
||||
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
||||
@@ -102,9 +104,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,55 +220,48 @@ 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">
|
||||
Vítejte zpět, {user?.fullName || user?.username}
|
||||
</h1>
|
||||
<p className="admin-page-subtitle">{getCzechDate()}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h4">
|
||||
Vítejte zpět, {user?.fullName || user?.username}
|
||||
</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)",
|
||||
bgcolor: "error.main",
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
@@ -285,35 +277,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 +326,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 +344,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 }}>
|
||||
{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"
|
||||
>
|
||||
<div className="dash-project-name">{p.name}</div>
|
||||
{p.customer_name && (
|
||||
<div className="dash-project-customer">
|
||||
{p.customer_name}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
{dashData.projects.active_projects.length === 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
Žá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 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{p.customer_name}
|
||||
</Typography>
|
||||
)}
|
||||
</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 +505,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 }}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{pendingRequests.map((req) => (
|
||||
<div key={req.id} className="admin-card">
|
||||
<div
|
||||
className="admin-card-body"
|
||||
style={{ padding: "1.25rem" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
<TabPanel value="pending" current={activeTab}>
|
||||
{pendingRequests.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState title="Žádné čekající žádosti" />
|
||||
</Card>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{pendingRequests.map((req) => (
|
||||
<Card key={req.id}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
gap: "1rem",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<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={{
|
||||
display: "flex",
|
||||
gap: "1.5rem",
|
||||
flexWrap: "wrap",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{formatDate(req.date_from)}</strong> —{" "}
|
||||
<strong>{formatDate(req.date_to)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{req.total_days}{" "}
|
||||
{czechPlural(req.total_days, "den", "dny", "dnů")} (
|
||||
{req.total_hours}h)
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
Podáno: {formatDatetime(req.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{req.notes && (
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{req.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexShrink: 0,
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<Box component="span">
|
||||
<strong>{formatDate(req.date_from)}</strong> —{" "}
|
||||
<strong>{formatDate(req.date_to)}</strong>
|
||||
</Box>
|
||||
<Box component="span">
|
||||
{req.total_days}{" "}
|
||||
{czechPlural(req.total_days, "den", "dny", "dnů")} (
|
||||
{req.total_hours}h)
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||
Podáno: {formatDatetime(req.created_at)}
|
||||
</Box>
|
||||
</Box>
|
||||
{req.notes && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
fontSize: "0.875rem",
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
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
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
{req.notes}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexShrink: 0 }}>
|
||||
<Button
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
setApproveModal({ open: true, request: req })
|
||||
}
|
||||
>
|
||||
Schválit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
setRejectModal({ open: true, request: req })
|
||||
}
|
||||
>
|
||||
Zamítnout
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</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) {
|
||||
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" }}
|
||||
title={req.reviewer_note}
|
||||
>
|
||||
{truncate(req.reviewer_note)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (req.notes) {
|
||||
return (
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
title={req.notes}
|
||||
>
|
||||
{truncate(req.notes)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-muted">—</span>;
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Box
|
||||
component="span"
|
||||
title={req.reviewer_note}
|
||||
sx={{
|
||||
color: "error.main",
|
||||
fontSize: "0.875rem",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{truncate(req.reviewer_note)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (req.notes) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
title={req.notes}
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
fontSize: "0.875rem",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{truncate(req.notes)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||
—
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Podáno",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
render: (req) => formatDatetime(req.created_at),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "8%",
|
||||
align: "right",
|
||||
render: (req) =>
|
||||
req.status === "pending" ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={CancelIcon}
|
||||
onClick={() => setCancelModal({ open: true, id: req.id })}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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>
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Moje žádosti"
|
||||
subtitle="Přehled žádostí o nepřítomnost"
|
||||
/>
|
||||
|
||||
<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"
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<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>
|
||||
|
||||
<ConfirmModal
|
||||
<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 }
|
||||
}
|
||||
>
|
||||
<div className="admin-login-header">
|
||||
<div className="admin-login-2fa-icon">
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<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
|
||||
? {}
|
||||
: {
|
||||
textAlign: "center",
|
||||
fontSize: "1.5rem",
|
||||
letterSpacing: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
}
|
||||
}
|
||||
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 }}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "60vh",
|
||||
gap: 2,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
<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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,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 }}
|
||||
>
|
||||
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
||||
</motion.span>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
||||
</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,20 +666,14 @@ 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}
|
||||
categories={categories}
|
||||
canEdit={canEdit}
|
||||
pulseKey={lastMutated}
|
||||
onCellClick={openCell}
|
||||
/>
|
||||
</motion.div>
|
||||
<PlanGrid
|
||||
data={grid}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
canEdit={canEdit}
|
||||
pulseKey={lastMutated}
|
||||
onCellClick={openCell}
|
||||
/>
|
||||
) : 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,20 @@ 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,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -27,6 +39,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 +72,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 +277,369 @@ 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 }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
to="/projects"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět"
|
||||
aria-label="Zpět"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/projects"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
Projekt {project.project_number}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={saving}
|
||||
Zpět
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Typography variant="h4">
|
||||
Projekt {project.project_number}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[project.status] || project.status}
|
||||
color={STATUS_COLORS[project.status] || "default"}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{canEdit && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
</>
|
||||
) : (
|
||||
"Uložit"
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Ukládání..." : "Uložit"}
|
||||
</Button>
|
||||
{!project.order_id && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
{!project.order_id && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Smazat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
</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",
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Název">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Název projektu"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Zákazník">
|
||||
<input
|
||||
type="text"
|
||||
value={project.customer_name || "—"}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
cursor: "default",
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Zodpovědná osoba">
|
||||
<select
|
||||
value={form.responsible_user_id}
|
||||
onChange={(e) =>
|
||||
updateForm("responsible_user_id", e.target.value)
|
||||
}
|
||||
className="admin-form-select"
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<option value="">— Nevybráno —</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<FormField label="Stav">
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => updateForm("status", e.target.value)}
|
||||
className="admin-form-select"
|
||||
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"
|
||||
value={form.start_date}
|
||||
onChange={(val: string) => updateForm("start_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Datum ukončení">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.end_date}
|
||||
onChange={(val: string) => updateForm("end_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
<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)}
|
||||
placeholder="Název projektu"
|
||||
fullWidth
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zákazník">
|
||||
<TextField
|
||||
value={project.customer_name || "—"}
|
||||
fullWidth
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zodpovědná osoba">
|
||||
<Select
|
||||
value={form.responsible_user_id}
|
||||
onChange={(v) => updateForm("responsible_user_id", v)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<MenuItem value="">— Nevybráno —</MenuItem>
|
||||
{users.map((u) => (
|
||||
<MenuItem key={u.id} value={String(u.id)}>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Stav">
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(v) => updateForm("status", v)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<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) => updateForm("start_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Datum ukončení">
|
||||
<DateField
|
||||
value={form.end_date}
|
||||
onChange={(val) => updateForm("end_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</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
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
placeholder="Napište poznámku..."
|
||||
style={{ resize: "vertical", width: "100%" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
||||
handleAddNote();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
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>
|
||||
|
||||
{/* 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)",
|
||||
}}
|
||||
{/* Add note */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
placeholder="Napište poznámku..."
|
||||
fullWidth
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
||||
handleAddNote();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleAddNote}
|
||||
disabled={addingNote || !newNote.trim()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-tertiary)",
|
||||
marginBottom: "0.25rem",
|
||||
{addingNote ? "Přidávání..." : "Přidat poznámku"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legacy notes (read-only) */}
|
||||
{project.notes && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 0.5 }}
|
||||
>
|
||||
Starší poznámka (před zavedením systému)
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{project.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Notes list */}
|
||||
{notes.length === 0 && !project.notes && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center", py: 2 }}
|
||||
>
|
||||
Zatím žádné poznámky
|
||||
</Typography>
|
||||
)}
|
||||
{(notes.length > 0 || project.notes) && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{notes.map((note) => (
|
||||
<Box
|
||||
key={note.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
Starší poznámka (před zavedením systému)
|
||||
</div>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes list */}
|
||||
{notes.length === 0 && !project.notes && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "1rem 0",
|
||||
}}
|
||||
>
|
||||
Zatím žádné poznámky
|
||||
</div>
|
||||
)}
|
||||
{(notes.length > 0 || project.notes) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
position: "relative",
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
|
||||
{note.user_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{formatNoteDate(note.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{note.content}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
className="admin-btn-icon"
|
||||
title="Smazat poznámku"
|
||||
disabled={deletingNoteId === note.id}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
opacity: deletingNoteId === note.id ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{deletingNoteId === note.id ? (
|
||||
<div
|
||||
className="admin-spinner"
|
||||
style={{ width: 14, height: 14, borderWidth: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{note.user_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNoteDate(note.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||
>
|
||||
{note.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
title="Smazat poznámku"
|
||||
aria-label="Smazat poznámku"
|
||||
disabled={deletingNoteId === note.id}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{TrashIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</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 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Propojení</h3>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Objednávka">
|
||||
<div>
|
||||
{project.order_id ? (
|
||||
<Link
|
||||
to={`/orders/${project.order_id}`}
|
||||
className="link-accent"
|
||||
>
|
||||
{project.order_number}
|
||||
{project.order_status && (
|
||||
<span
|
||||
className="text-tertiary"
|
||||
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
|
||||
>
|
||||
(
|
||||
{STATUS_LABELS[project.order_status] ||
|
||||
project.order_status}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Nabídka">
|
||||
<div>
|
||||
{project.quotation_id ? (
|
||||
<Link
|
||||
to={`/offers/${project.quotation_id}`}
|
||||
className="link-accent"
|
||||
>
|
||||
{project.quotation_number}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Objednávka
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{project.order_id ? (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/orders/${project.order_id}`}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{project.order_number}
|
||||
{project.order_status && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1 }}
|
||||
>
|
||||
(
|
||||
{STATUS_LABELS[project.order_status] ||
|
||||
project.order_status}
|
||||
)
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Nabídka
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{project.quotation_id ? (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/offers/${project.quotation_id}`}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{project.quotation_number}
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm}
|
||||
onClose={() => {
|
||||
setDeleteConfirm(false);
|
||||
@@ -651,30 +647,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}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
{project.has_nas_folder && (
|
||||
<CheckboxField
|
||||
label="Smazat i soubory na disku"
|
||||
checked={deleteFiles}
|
||||
onChange={setDeleteFiles}
|
||||
/>
|
||||
)}
|
||||
</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 }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{hasPermission("projects.create") && (
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="admin-btn admin-btn-primary"
|
||||
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" },
|
||||
}}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
{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 }}
|
||||
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"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||
/>
|
||||
</div>
|
||||
<Typography variant="h4">Projekty</Typography>
|
||||
{hasPermission("projects.create") && (
|
||||
<Button startIcon={PlusIcon} onClick={openCreate}>
|
||||
Přidat projekt
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{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",
|
||||
}}
|
||||
>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<Project>
|
||||
columns={columns}
|
||||
rows={projects}
|
||||
rowKey={(p) => p.id}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Zatím nejsou žádné projekty.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
|
||||
tlačítkem „Přidat projekt".
|
||||
</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
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
value={createForm.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 label="Název" required error={createErrors.name}>
|
||||
<TextField
|
||||
value={createForm.name}
|
||||
placeholder="Název projektu"
|
||||
error={!!createErrors.name}
|
||||
onChange={(e) => {
|
||||
setCreateForm({ ...createForm, name: e.target.value });
|
||||
setCreateErrors((p) => ({ ...p, name: "" }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<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 ?? [])
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.first_name} {u.last_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
options={[
|
||||
{ value: "", label: "— nepřiřazeno —" },
|
||||
...(usersQuery.data ?? [])
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: `${u.first_name} ${u.last_name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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
|
||||
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 label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={createForm.notes}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, notes: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { 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 },
|
||||
);
|
||||
|
||||
const recentTrips = trips.slice(0, 10);
|
||||
|
||||
const columns: DataColumn<BackendTrip>[] = [
|
||||
{
|
||||
key: "trip_date",
|
||||
header: "Datum",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (trip) => formatDate(trip.trip_date),
|
||||
},
|
||||
{
|
||||
key: "vehicle",
|
||||
header: "Vozidlo",
|
||||
width: "12%",
|
||||
render: (trip) => (
|
||||
<StatusChip label={trip.vehicles?.spz ?? ""} color="default" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "driver",
|
||||
header: "Řidič",
|
||||
width: "16%",
|
||||
render: (trip) =>
|
||||
trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : "",
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
header: "Trasa",
|
||||
width: "22%",
|
||||
render: (trip) => (
|
||||
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "distance",
|
||||
header: "Vzdálenost",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (trip) =>
|
||||
`${formatKm(trip.distance ?? trip.end_km - trip.start_km)} km`,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "12%",
|
||||
render: (trip) => (
|
||||
<StatusChip
|
||||
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||
color={trip.is_business ? "success" : "warning"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "10%",
|
||||
align: "right",
|
||||
render: (trip) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEditModal(trip)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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">Kniha jízd</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
<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>
|
||||
Přidat jízdu
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat jízdu
|
||||
</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 }}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Poslední jízdy</h2>
|
||||
<Link
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<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 })
|
||||
}
|
||||
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>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</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
|
||||
value={form.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleVehicleChange(e.target.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>
|
||||
|
||||
<FormField label="Datum jízdy" error={errors.trip_date} required>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.trip_date}
|
||||
onChange={(val: string) => {
|
||||
setForm({ ...form, trip_date: val });
|
||||
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<FormField
|
||||
label="Počáteční stav km"
|
||||
error={errors.start_km}
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.start_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, start_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, start_km: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Konečný stav km" error={errors.end_km} required>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.end_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, end_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, end_km: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Vzdálenost">
|
||||
<input
|
||||
type="text"
|
||||
value={`${formatKm(calculateDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Místo odjezdu" error={errors.route_from} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.route_from}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_from: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_from: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Místo příjezdu" error={errors.route_to} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.route_to}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_to: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_to: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Typ jízdy">
|
||||
<select
|
||||
value={form.is_business}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
is_business: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
<option value={0}>Soukromá</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
<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={(value) => {
|
||||
handleVehicleChange(value);
|
||||
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
|
||||
}}
|
||||
error={!!errors.vehicle_id}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte vozidlo" },
|
||||
...vehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Field>
|
||||
|
||||
<ConfirmModal
|
||||
<Field label="Datum jízdy" required error={errors.trip_date}>
|
||||
<DateField
|
||||
value={form.trip_date}
|
||||
onChange={(val) => {
|
||||
setForm({ ...form, trip_date: val });
|
||||
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Počáteční stav km" required error={errors.start_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.start_km}
|
||||
error={!!errors.start_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, start_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, start_km: "" }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Konečný stav km" required error={errors.end_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.end_km}
|
||||
error={!!errors.end_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, end_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, end_km: "" }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Vzdálenost">
|
||||
<TextField
|
||||
type="text"
|
||||
value={`${formatKm(calculateDistance())} km`}
|
||||
disabled
|
||||
slotProps={{ htmlInput: { readOnly: true } }}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Místo odjezdu" required error={errors.route_from}>
|
||||
<TextField
|
||||
type="text"
|
||||
value={form.route_from}
|
||||
error={!!errors.route_from}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_from: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_from: "" }));
|
||||
}}
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Místo příjezdu" required error={errors.route_to}>
|
||||
<TextField
|
||||
type="text"
|
||||
value={form.route_to}
|
||||
error={!!errors.route_to}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_to: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_to: "" }));
|
||||
}}
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Field label="Typ jízdy">
|
||||
<Select
|
||||
value={String(form.is_business)}
|
||||
onChange={(value) =>
|
||||
setForm({ ...form, is_business: parseInt(value) })
|
||||
}
|
||||
options={[
|
||||
{ value: "1", label: "Služební" },
|
||||
{ value: "0", label: "Soukromá" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
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),
|
||||
};
|
||||
|
||||
const columns: DataColumn<Trip>[] = [
|
||||
{
|
||||
key: "trip_date",
|
||||
header: "Datum",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (trip) => formatDate(trip.trip_date),
|
||||
},
|
||||
{
|
||||
key: "driver",
|
||||
header: "Řidič",
|
||||
width: "16%",
|
||||
render: (trip) => trip.driver_name,
|
||||
},
|
||||
{
|
||||
key: "vehicle",
|
||||
header: "Vozidlo",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (trip) => <StatusChip label={trip.spz} color="default" />,
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
header: "Trasa",
|
||||
width: "20%",
|
||||
render: (trip) => (
|
||||
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "km",
|
||||
header: "Stav km",
|
||||
width: "14%",
|
||||
mono: true,
|
||||
render: (trip) => (
|
||||
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "distance",
|
||||
header: "Vzdálenost",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (trip) => `${formatKm(trip.distance)} km`,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "10%",
|
||||
render: (trip) => (
|
||||
<StatusChip
|
||||
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||
color={trip.is_business ? "success" : "warning"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "10%",
|
||||
align: "right",
|
||||
render: (trip) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEditModal(trip)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, trip })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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 knihy jízd</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<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
|
||||
value={filterVehicleId}
|
||||
onChange={(e) => setFilterVehicleId(e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všechna vozidla</option>
|
||||
{vehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.spz} - {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Řidič" style={{ marginBottom: 0 }}>
|
||||
<select
|
||||
value={filterUserId}
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všichni řidiči</option>
|
||||
{tripUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filterVehicleId}
|
||||
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={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">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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 })
|
||||
}
|
||||
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 sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<Trip>
|
||||
columns={columns}
|
||||
rows={trips}
|
||||
rowKey={(trip) => trip.id}
|
||||
empty={
|
||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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" });
|
||||
};
|
||||
|
||||
const columns: DataColumn<Trip>[] = [
|
||||
{
|
||||
key: "trip_date",
|
||||
header: "Datum",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (trip) => formatDate(trip.trip_date),
|
||||
},
|
||||
{
|
||||
key: "vehicle",
|
||||
header: "Vozidlo",
|
||||
width: "10%",
|
||||
render: (trip) => <StatusChip label={trip.spz} color="default" />,
|
||||
},
|
||||
{
|
||||
key: "driver",
|
||||
header: "Řidič",
|
||||
width: "14%",
|
||||
render: (trip) => (
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
{trip.driver_name}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
header: "Trasa",
|
||||
width: "18%",
|
||||
render: (trip) => (
|
||||
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "km",
|
||||
header: "Stav km",
|
||||
width: "13%",
|
||||
mono: true,
|
||||
render: (trip) => (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ whiteSpace: "nowrap", color: "text.secondary" }}
|
||||
>
|
||||
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "distance",
|
||||
header: "Vzdálenost",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (trip) => `${formatKm(trip.distance)} km`,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Typ",
|
||||
width: "10%",
|
||||
render: (trip) => (
|
||||
<StatusChip
|
||||
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||
color={trip.is_business ? "success" : "warning"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "14%",
|
||||
render: (trip) => (
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
{trip.notes || "—"}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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 jízd</h1>
|
||||
<p className="admin-page-subtitle">{getMonthName(month)}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
<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
|
||||
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>
|
||||
<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={(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">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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>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 sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<Trip>
|
||||
columns={columns}
|
||||
rows={trips}
|
||||
rowKey={(trip) => trip.id}
|
||||
empty={
|
||||
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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 }}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
{u.id !== currentUser?.id && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteModal({ isOpen: true, user: u })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{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 }}
|
||||
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-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",
|
||||
}}
|
||||
>
|
||||
{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"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<Field label="Uživatelské jméno" required error={errors.username}>
|
||||
<TextField
|
||||
value={formData.username}
|
||||
error={!!errors.username}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, username: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, username: "" }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField label="E-mail">
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<Field label="E-mail" required error={errors.email}>
|
||||
<TextField
|
||||
type="email"
|
||||
value={formData.email}
|
||||
error={!!errors.email}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, email: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, email: "" }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField
|
||||
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
required={!editingUser}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
<Field
|
||||
label={`Heslo${editingUser ? " (ponechte prázdné pro zachování stávajícího)" : ""}`}
|
||||
required={!editingUser}
|
||||
error={errors.password}
|
||||
>
|
||||
<TextField
|
||||
type="password"
|
||||
value={formData.password}
|
||||
error={!!errors.password}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, password: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, password: "" }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField 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>
|
||||
<Field label="Role">
|
||||
<Select
|
||||
value={formData.role_id}
|
||||
onChange={(value) => setFormData({ ...formData, role_id: value })}
|
||||
options={roles.map((role) => ({
|
||||
value: String(role.id),
|
||||
label: role.display_name || role.name,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
is_active: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>Účet je aktivní</span>
|
||||
</label>
|
||||
</div>
|
||||
</FormModal>
|
||||
<SwitchField
|
||||
label="Účet je aktivní"
|
||||
checked={formData.is_active}
|
||||
onChange={(v) => setFormData({ ...formData, is_active: v })}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
<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"
|
||||
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="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>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, vehicle: v })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{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 }}
|
||||
return (
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 3,
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, vehicle })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Typography variant="h4">Správa vozidel</Typography>
|
||||
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||
Přidat vozidlo
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<FormModal
|
||||
<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
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.initial_km}
|
||||
onChange={(e) =>
|
||||
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="Počáteční stav km"
|
||||
hint="Stav tachometru při přidání vozidla"
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.initial_km}
|
||||
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_active}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, is_active: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>Vozidlo je aktivní</span>
|
||||
</label>
|
||||
</div>
|
||||
</FormModal>
|
||||
<SwitchField
|
||||
label="Vozidlo je aktivní"
|
||||
checked={form.is_active}
|
||||
onChange={(v) => setForm({ ...form, is_active: v })}
|
||||
/>
|
||||
</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">
|
||||
{hasPermission("warehouse.operate") && (
|
||||
<>
|
||||
<Link
|
||||
to="/warehouse/receipts/new"
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Sklad"
|
||||
subtitle="Přehled skladových zásob"
|
||||
actions={
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||
{hasPermission("warehouse.operate") && (
|
||||
<>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/receipts/new"
|
||||
startIcon={ReceiptIcon}
|
||||
>
|
||||
<polyline points="17 11 12 6 7 11" />
|
||||
<line x1="12" y1="6" x2="12" y2="18" />
|
||||
</svg>
|
||||
Nový příjem
|
||||
</Link>
|
||||
<Link
|
||||
to="/warehouse/issues/new"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
Nový příjem
|
||||
</Button>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/issues/new"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={IssueIcon}
|
||||
>
|
||||
<polyline points="7 13 12 18 17 13" />
|
||||
<line x1="12" y1="18" x2="12" y2="6" />
|
||||
</svg>
|
||||
Nový výdej
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{hasPermission("warehouse.manage") && (
|
||||
<Link
|
||||
to="/warehouse/categories"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
Nový výdej
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasPermission("warehouse.manage") && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/categories"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Kategorie
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/items"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Kategorie
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary">
|
||||
Položky
|
||||
</Link>
|
||||
<Link
|
||||
to="/warehouse/reports"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
Reporty
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
Položky
|
||||
</Button>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/reports"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Reporty
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
|
||||
Položky pod minimem
|
||||
</h2>
|
||||
<Link
|
||||
to="/warehouse/reports"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
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>
|
||||
<Typography variant="h6" sx={{ color: "error.main" }}>
|
||||
Položky pod minimem
|
||||
</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/reports"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Reporty →
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Poslední pohyby</h2>
|
||||
<Link
|
||||
to="/warehouse/reports"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
<Box>
|
||||
<Card>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Vše →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<Typography variant="h6">Poslední pohyby</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/reports"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Vše →
|
||||
</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"
|
||||
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, category })
|
||||
}
|
||||
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>
|
||||
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"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteConfirm({ show: true, category: c })}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{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"
|
||||
value={form.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 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: "" }));
|
||||
}}
|
||||
placeholder="Název kategorie"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, description: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
placeholder="Volitelný popis kategorie"
|
||||
/>
|
||||
</FormField>
|
||||
<Field label="Popis">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Volitelný popis kategorie"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FormField label="Pořadí řazení">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.sort_order}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
sort_order: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Nižší hodnota = výše v seznamu
|
||||
</small>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormModal>
|
||||
<Field label="Pořadí řazení">
|
||||
<TextField
|
||||
type="number"
|
||||
value={String(form.sort_order)}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
sort_order: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</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 />;
|
||||
}
|
||||
|
||||
const total = pagination?.total ?? items.length;
|
||||
const subtitle = `${total} ${
|
||||
total === 1
|
||||
? "inventura"
|
||||
: total >= 2 && total <= 4
|
||||
? "inventury"
|
||||
: "inventur"
|
||||
}`;
|
||||
|
||||
const columns: DataColumn<WarehouseInventorySession>[] = [
|
||||
{
|
||||
key: "session_number",
|
||||
header: "Číslo inventury",
|
||||
width: "30%",
|
||||
mono: true,
|
||||
bold: true,
|
||||
render: (s) => s.session_number || "—",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
width: "20%",
|
||||
render: (s) => (
|
||||
<StatusChip
|
||||
label={STATUS_LABEL[s.status] ?? s.status}
|
||||
color={STATUS_COLOR[s.status] ?? "default"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Vytvořeno",
|
||||
width: "30%",
|
||||
mono: true,
|
||||
render: (s) => formatDate(s.created_at),
|
||||
},
|
||||
{
|
||||
key: "items",
|
||||
header: "Položky",
|
||||
width: "20%",
|
||||
mono: true,
|
||||
render: (s) => String(s.items?.length ?? 0),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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
|
||||
? "inventura"
|
||||
: pagination?.total !== undefined &&
|
||||
pagination.total >= 2 &&
|
||||
pagination.total <= 4
|
||||
? "inventury"
|
||||
: "inventur"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Inventury"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
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>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všechny stavy</option>
|
||||
<option value="DRAFT">Návrh</option>
|
||||
<option value="CONFIRMED">Potvrzeno</option>
|
||||
</select>
|
||||
{statusFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={resetFilters}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Zrušit filtry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 160px" }}>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všechny stavy" },
|
||||
{ value: "DRAFT", label: "Návrh" },
|
||||
{ value: "CONFIRMED", label: "Potvrzeno" },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{statusFilter && (
|
||||
<Button variant="outlined" onClick={resetFilters}>
|
||||
Zrušit filtry
|
||||
</Button>
|
||||
)}
|
||||
</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
|
||||
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>
|
||||
<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")}
|
||||
>
|
||||
Vytvořit první inventuru
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
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,
|
||||
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 +40,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 +93,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 (
|
||||
<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 (
|
||||
<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
|
||||
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"
|
||||
<PageHeader
|
||||
title={s.session_number || "Inventura"}
|
||||
actions={
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/inventory"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<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">
|
||||
{s.status === "DRAFT" && (
|
||||
<button
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={confirming}
|
||||
>
|
||||
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
Zpět
|
||||
</Button>
|
||||
<StatusChip
|
||||
label={STATUS_LABEL[s.status] ?? s.status}
|
||||
color={STATUS_COLOR[s.status] ?? "default"}
|
||||
/>
|
||||
{s.status === "DRAFT" && (
|
||||
<Button
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
disabled={confirming}
|
||||
>
|
||||
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
|
||||
</Button>
|
||||
)}
|
||||
</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 }}
|
||||
>
|
||||
<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>
|
||||
{s.notes && (
|
||||
<FormField label="Poznámky">
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{s.notes}</div>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
{s.session_number || "—"}
|
||||
</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 && (
|
||||
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Poznámky
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{s.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Lines table */}
|
||||
<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
|
||||
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>
|
||||
<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)}
|
||||
placeholder="Volitelné poznámky k inventuře"
|
||||
/>
|
||||
</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>
|
||||
<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)}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-location">
|
||||
<LocationSelect
|
||||
value={item.location_id}
|
||||
onChange={(locId) => updateItem("location_id", locId)}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-qty">
|
||||
<input
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.actual_qty || ""}
|
||||
onChange={(e) =>
|
||||
updateItem("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}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
onClick={removeItem}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<ItemPicker
|
||||
value={item.item_id}
|
||||
onChange={(id) => updateItem(index, "item_id", id)}
|
||||
/>
|
||||
<Select
|
||||
value={
|
||||
item.location_id === null ? "" : String(item.location_id)
|
||||
}
|
||||
onChange={(val) =>
|
||||
updateItem(index, "location_id", val ? Number(val) : null)
|
||||
}
|
||||
options={locationOptions}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.actual_qty || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(index, "actual_qty", Number(e.target.value))
|
||||
}
|
||||
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í"
|
||||
/>
|
||||
<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,24 +1,39 @@
|
||||
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,
|
||||
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 +42,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 +120,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
|
||||
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"
|
||||
<PageHeader
|
||||
title={iss.issue_number || "Nová výdejka"}
|
||||
subtitle={
|
||||
iss.project
|
||||
? `${iss.project.project_number} – ${iss.project.name}`
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/warehouse/issues"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
<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 }}
|
||||
>
|
||||
<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>
|
||||
{iss.project ? (
|
||||
<a
|
||||
className="link-accent admin-mono fw-500"
|
||||
href={`/projects/${iss.project.id}`}
|
||||
>
|
||||
{iss.project.project_number} – {iss.project.name}
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Vydal">
|
||||
<div className="admin-mono fw-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>
|
||||
{iss.notes && (
|
||||
<FormField label="Poznámky">
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{iss.notes}</div>
|
||||
</FormField>
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
{iss.issue_number || "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Projekt
|
||||
</Typography>
|
||||
{iss.project ? (
|
||||
<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}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2">—</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</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}`
|
||||
: "—"}
|
||||
</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 && (
|
||||
<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>
|
||||
)}
|
||||
</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 +395,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">
|
||||
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<button
|
||||
type="button"
|
||||
{BackIcon}
|
||||
</IconButton>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
|
||||
</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) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
project_id:
|
||||
e.target.value === "" ? null : Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
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
|
||||
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>
|
||||
<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: val === "" ? null : Number(val),
|
||||
}))
|
||||
}
|
||||
options={projectOptions}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||
}
|
||||
placeholder="Volitelné poznámky"
|
||||
/>
|
||||
</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>
|
||||
{errors.items && (
|
||||
<div
|
||||
className="admin-form-error"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Položky
|
||||
</Typography>
|
||||
{errors.items && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ display: "block", mb: 1.5 }}
|
||||
>
|
||||
{errors.items}
|
||||
</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(160px, 1.6fr) minmax(160px, 1.6fr) 100px 100px minmax(130px, 1.1fr) minmax(120px, 1.2fr) auto",
|
||||
},
|
||||
gap: 1,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
{errors.items}
|
||||
</div>
|
||||
)}
|
||||
<WarehouseMovementTable
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
mode="issue"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<ItemPicker
|
||||
value={item.item_id}
|
||||
itemName={item.item_name}
|
||||
onChange={(id) => updateItem(index, "item_id", id)}
|
||||
/>
|
||||
<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 />;
|
||||
}
|
||||
|
||||
const total = pagination?.total ?? items.length;
|
||||
const subtitle = `${total} ${
|
||||
total === 1 ? "doklad" : total >= 2 && total <= 4 ? "doklady" : "dokladů"
|
||||
}`;
|
||||
|
||||
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 (
|
||||
<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
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Výdejky"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
canOperate ? (
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
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>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<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"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle čísla dokladu..."
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
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>
|
||||
{projects.length > 0 && (
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => {
|
||||
setProjectId(
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
);
|
||||
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>
|
||||
)}
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={dateFrom}
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle čísla dokladu..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 160px" }}>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všechny stavy" },
|
||||
{ value: "DRAFT", label: "Návrh" },
|
||||
{ value: "CONFIRMED", label: "Potvrzeno" },
|
||||
{ value: "CANCELLED", label: "Zrušeno" },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{projects.length > 0 && (
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={projectId === "" ? "" : String(projectId)}
|
||||
onChange={(val) => {
|
||||
setDateFrom(val);
|
||||
setProjectId(val === "" ? "" : Number(val));
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Od"
|
||||
options={[
|
||||
{ value: "", label: "Všechny projekty" },
|
||||
...projects.map((p: Project) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={dateTo}
|
||||
onChange={(val) => {
|
||||
setDateTo(val);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Do"
|
||||
/>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={resetFilters}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Zrušit filtry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={dateFrom}
|
||||
onChange={(val) => {
|
||||
setDateFrom(val);
|
||||
setPage(1);
|
||||
}}
|
||||
label="Od"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={dateTo}
|
||||
onChange={(val) => {
|
||||
setDateTo(val);
|
||||
setPage(1);
|
||||
}}
|
||||
label="Do"
|
||||
/>
|
||||
</Box>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="outlined" onClick={resetFilters}>
|
||||
Zrušit filtry
|
||||
</Button>
|
||||
)}
|
||||
</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,21 @@ 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,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
interface Batch {
|
||||
id: number;
|
||||
@@ -52,6 +68,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 +114,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,425 +222,429 @@ 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"
|
||||
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={{ display: "flex", gap: 1 }}>
|
||||
{editing || isNew ? (
|
||||
<>
|
||||
{!isNew && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setErrors({});
|
||||
if (item) {
|
||||
setForm({
|
||||
item_number: item.item_number || "",
|
||||
name: item.name,
|
||||
description: item.description || "",
|
||||
category_id: item.category_id
|
||||
? String(item.category_id)
|
||||
: "",
|
||||
unit: item.unit,
|
||||
min_quantity:
|
||||
item.min_quantity != null
|
||||
? String(item.min_quantity)
|
||||
: "",
|
||||
notes: item.notes || "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<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">
|
||||
{editing || isNew ? (
|
||||
<>
|
||||
{!isNew && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setErrors({});
|
||||
if (item) {
|
||||
setForm({
|
||||
item_number: item.item_number || "",
|
||||
name: item.name,
|
||||
description: item.description || "",
|
||||
category_id: item.category_id
|
||||
? String(item.category_id)
|
||||
: "",
|
||||
unit: item.unit,
|
||||
min_quantity:
|
||||
item.min_quantity != null
|
||||
? String(item.min_quantity)
|
||||
: "",
|
||||
notes: item.notes || "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={saving}
|
||||
>
|
||||
Zrušit
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{item && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
Smazat
|
||||
</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>
|
||||
Upravit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
Zrušit
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Ukládání..." : isNew ? "Vytvořit" : "Uložit"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{item && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
)}
|
||||
<Button startIcon={EditIcon} onClick={() => setEditing(true)}>
|
||||
Upravit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</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={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<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 }}
|
||||
>
|
||||
<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"
|
||||
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"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Název" error={errors.name} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.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}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => updateForm("description", e.target.value)}
|
||||
className="admin-form-input"
|
||||
rows={3}
|
||||
placeholder="Volitelný popis položky"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<option value="">— Bez kategorie —</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Jednotka" error={errors.unit} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.unit}
|
||||
onChange={(e) => {
|
||||
updateForm("unit", e.target.value);
|
||||
setErrors((prev) => ({ ...prev, unit: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="ks, m, kg..."
|
||||
aria-invalid={!!errors.unit}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Minimální množství">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.min_quantity}
|
||||
onChange={(e) =>
|
||||
updateForm("min_quantity", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Upozornění při poklesu pod tuto hranici
|
||||
</small>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => updateForm("notes", e.target.value)}
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Číslo položky">
|
||||
<div style={{ fontWeight: 500 }} className="admin-mono">
|
||||
{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">
|
||||
{item?.min_quantity != null ? item.min_quantity : "—"}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
{item?.notes && (
|
||||
<FormField label="Poznámky">
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{item.notes}</div>
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<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: "" }));
|
||||
}}
|
||||
placeholder="Např. SKL-001"
|
||||
fullWidth
|
||||
/>
|
||||
</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: "" }));
|
||||
}}
|
||||
placeholder="Název položky"
|
||||
fullWidth
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
<Field label="Popis">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => updateForm("description", e.target.value)}
|
||||
placeholder="Volitelný popis položky"
|
||||
fullWidth
|
||||
/>
|
||||
</Field>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Kategorie">
|
||||
<Select
|
||||
value={form.category_id}
|
||||
onChange={(v) => updateForm("category_id", v)}
|
||||
>
|
||||
<MenuItem value="">— Bez kategorie —</MenuItem>
|
||||
{categories.map((cat) => (
|
||||
<MenuItem key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</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: "" }));
|
||||
}}
|
||||
placeholder="ks, m, kg..."
|
||||
fullWidth
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Minimální množství"
|
||||
hint="Upozornění při poklesu pod tuto hranici"
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
inputProps={{ inputMode: "numeric", min: 0 }}
|
||||
value={form.min_quantity}
|
||||
onChange={(e) => updateForm("min_quantity", e.target.value)}
|
||||
placeholder="0"
|
||||
fullWidth
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateForm("notes", e.target.value)}
|
||||
placeholder="Volitelné poznámky"
|
||||
fullWidth
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<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 || "—"}
|
||||
</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 : "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Poznámky */}
|
||||
{item?.notes && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
</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
|
||||
? 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>
|
||||
<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č"
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</motion.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>
|
||||
|
||||
{/* 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 />;
|
||||
}
|
||||
|
||||
const total = pagination?.total ?? items.length;
|
||||
const subtitle = `${total} ${czechPlural(total, "položka", "položky", "položek")}`;
|
||||
|
||||
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 (
|
||||
<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
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Skladové položky"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button
|
||||
startIcon={PlusIcon}
|
||||
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>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<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"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle názvu nebo čísla položky..."
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => {
|
||||
setCategoryId(
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
);
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
<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..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
{categories.length > 0 && (
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={categoryId === "" ? "" : String(categoryId)}
|
||||
onChange={(val) => {
|
||||
setCategoryId(val === "" ? "" : Number(val));
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Všechny kategorie" },
|
||||
...categories.map((cat) => ({
|
||||
value: String(cat.id),
|
||||
label: cat.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</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" : ""
|
||||
}
|
||||
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>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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