Compare commits
309 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df83eb2091 | ||
|
|
9c862034ca | ||
|
|
67d62a6df0 | ||
|
|
512f1fd92b | ||
|
|
941caf9d85 | ||
|
|
b00d18d0b3 | ||
|
|
decadd895e | ||
|
|
54f3c414f5 | ||
|
|
ca092c6166 | ||
|
|
c7f9d9aa36 | ||
|
|
5aaac88b56 | ||
|
|
671323b9ac | ||
|
|
2e4fe2a8fe | ||
|
|
27c690285a | ||
|
|
8e7ab9158c | ||
|
|
03285c6ff6 | ||
|
|
d424ef8c15 | ||
|
|
2a228ea659 | ||
|
|
96e35df535 | ||
|
|
fa90bcc93b | ||
|
|
3117e80027 | ||
|
|
2f7ca0f5a8 | ||
|
|
2367e8a0ff | ||
|
|
b168c68264 | ||
|
|
aeec0b703c | ||
|
|
2ff26241d7 | ||
|
|
f0b5fb8a21 | ||
|
|
1e155f6ffb | ||
|
|
8cee828212 | ||
|
|
b23d15453d | ||
|
|
c4e71475ce | ||
|
|
bf5d38df14 | ||
|
|
9278d48a6d | ||
|
|
4062029939 | ||
|
|
f017867d5f | ||
|
|
f1c533488a | ||
|
|
f9bb655f6a | ||
|
|
c1bfabb033 | ||
|
|
ff3df907f4 | ||
|
|
39fe84ce99 | ||
|
|
b273614128 | ||
|
|
1a0f57a247 | ||
|
|
c67dff4725 | ||
|
|
58da09cdc0 | ||
|
|
ccdf0b68ea | ||
|
|
3b072ceefa | ||
|
|
68a8dae0dc | ||
|
|
e13f977c4d | ||
|
|
6f428b2297 | ||
|
|
67f98c1f66 | ||
|
|
85d7ffaa14 | ||
|
|
088fe39bab | ||
|
|
9b43d4e827 | ||
|
|
5630beee7c | ||
|
|
90a27b893e | ||
|
|
2878041687 | ||
|
|
262f4c6ca5 | ||
|
|
063ffd15ef | ||
|
|
fa11d3e7e0 | ||
|
|
d46827dfdf | ||
|
|
601db41a09 | ||
|
|
8259c48f9e | ||
|
|
1163407440 | ||
|
|
1cefcab697 | ||
|
|
c228d7a8ca | ||
|
|
c997a22a3c | ||
|
|
4d016cdab5 | ||
|
|
177d3f1902 | ||
|
|
ef0bb9f27e | ||
|
|
126b567b7b | ||
|
|
e4038051e9 | ||
|
|
c7e42a923a | ||
|
|
ebda3ecf23 | ||
|
|
cc4b14f862 | ||
|
|
5459d8c325 | ||
|
|
5ca03bd662 | ||
|
|
734e4266e6 | ||
|
|
c690bd9a24 | ||
|
|
3eb364fba5 | ||
|
|
1f85a84e06 | ||
|
|
abbff0bfbf | ||
|
|
19eda2ffa3 | ||
|
|
9e5038c553 | ||
|
|
9ab2bd7ef2 | ||
|
|
3d1bd49ad6 | ||
|
|
f51d5fcba4 | ||
|
|
5d919c3c90 | ||
|
|
4b94efbb43 | ||
|
|
c8c40d80d6 | ||
|
|
acc5434aca | ||
|
|
ff527ca577 | ||
|
|
b54fa84ac7 | ||
|
|
ca386c9d98 | ||
|
|
a05f0f1665 | ||
|
|
1d0793d2bb | ||
|
|
74c2f9aa30 | ||
|
|
1074adee0d | ||
|
|
a5103ee738 | ||
|
|
4fb52e9626 | ||
|
|
06c6e242e7 | ||
|
|
91686f5999 | ||
|
|
03015bbe9d | ||
|
|
af07703e7f | ||
|
|
0dbe78bfe6 | ||
|
|
b22cd6b4da | ||
|
|
ba18cb07f0 | ||
|
|
26f12b01a0 | ||
|
|
1b372b93ea | ||
|
|
56bad52de5 | ||
|
|
a80905755d | ||
|
|
ea6c943a51 | ||
|
|
c0e1b718e6 | ||
|
|
fbcd5a8939 | ||
|
|
f5ec9fa0f2 | ||
|
|
fa8f0efc65 | ||
|
|
2efd1c6df4 | ||
|
|
305c3fd97b | ||
|
|
6c186acc94 | ||
|
|
c055bf8267 | ||
|
|
094c190ae1 | ||
|
|
55a2de3a04 | ||
|
|
1398d4b4dc | ||
|
|
9aa90ff5f4 | ||
|
|
225cf56433 | ||
|
|
58a3bcad70 | ||
|
|
ca55529ff6 | ||
|
|
b76731d790 | ||
|
|
05f25931c9 | ||
|
|
5855c94b50 | ||
|
|
81adebd63a | ||
|
|
16db585022 | ||
|
|
645d0127ec | ||
|
|
c550763f2a | ||
|
|
bd658cd00c | ||
|
|
efaf49e9df | ||
|
|
cc5ffb27b9 | ||
|
|
9dbb5dbaab | ||
|
|
ab7e1eda41 | ||
|
|
7a21c85cce | ||
|
|
a353178c2d | ||
|
|
eeac0c6b8b | ||
|
|
e511e203b9 | ||
|
|
9791166ed6 | ||
|
|
a853d12d9c | ||
|
|
6bc4a22338 | ||
|
|
859ae1a458 | ||
|
|
090cfd7400 | ||
|
|
bb7e53f47a | ||
|
|
d2e368a05e | ||
|
|
b5df88b62d | ||
|
|
116797ca17 | ||
|
|
ed4f1efd03 | ||
|
|
6b76d4336e | ||
|
|
a8c0e413f4 | ||
|
|
596d21712a | ||
|
|
b504d9d0b2 | ||
|
|
e082a6ef06 | ||
|
|
1662453d9c | ||
|
|
9dd2e07f19 | ||
|
|
18064a972c | ||
|
|
996835dae5 | ||
|
|
c73b980ce7 | ||
|
|
dafa6d6aab | ||
|
|
80803cc868 | ||
|
|
55fa4045ad | ||
|
|
7f156cc721 | ||
|
|
e0d2fccf50 | ||
|
|
a1c251650b | ||
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 | ||
|
|
fdf180809d | ||
|
|
52f3da73ad | ||
|
|
83aba93a98 | ||
|
|
744baec2ec | ||
|
|
1f05ef6a55 | ||
|
|
94a9fb8b5a | ||
|
|
9c582e1a43 | ||
|
|
9ae49c0d6a | ||
|
|
522afefc80 | ||
|
|
c5f986adc1 | ||
|
|
379725a096 | ||
|
|
05596642bc | ||
|
|
58bf0348d7 | ||
|
|
55a2c50f79 | ||
|
|
86e4cbf345 | ||
|
|
852c127c00 | ||
|
|
71a0016a4a | ||
|
|
88c9b7c2b8 | ||
|
|
4a78d31a7f | ||
|
|
e845400fca | ||
|
|
fce27633a9 | ||
|
|
4d47897e27 | ||
|
|
aef4ab92ec | ||
|
|
5f36c34d48 | ||
|
|
b94f4879e3 | ||
|
|
dab5ad8aa5 | ||
|
|
8b77a0a905 | ||
|
|
4b1c42b17f | ||
|
|
a55356d417 | ||
|
|
c983747418 | ||
|
|
3db257faa9 | ||
|
|
d3d7f7e199 | ||
|
|
084ec9289b | ||
|
|
4a9c283789 | ||
|
|
2f76fe0bc1 | ||
|
|
9a11a8f001 | ||
|
|
f0816b126b | ||
|
|
f31797fb5b | ||
|
|
9c110abf46 | ||
|
|
a0f351b0aa | ||
|
|
916cf34efd | ||
|
|
003807a074 | ||
|
|
ca911bf96c | ||
|
|
9c10ff9f22 | ||
|
|
38a58b55c0 | ||
|
|
4d8fd62961 | ||
|
|
ef404e4362 | ||
|
|
fb022802ae | ||
|
|
795d4d5af9 | ||
|
|
74113035ae | ||
|
|
42f9561337 | ||
|
|
f79967d433 | ||
|
|
d410e7433e | ||
|
|
0711bafac0 | ||
|
|
5273f88272 | ||
|
|
182d4ca4b3 | ||
|
|
4773b5be22 | ||
|
|
6074d6163b | ||
|
|
652e3f91fc | ||
|
|
5233db2002 | ||
|
|
dac45baaa8 | ||
|
|
f389c4b3cf | ||
|
|
fdc7037e60 | ||
|
|
b02f6afe93 | ||
|
|
1b2ab7b4eb | ||
|
|
29975ecad9 | ||
|
|
e846f15126 | ||
|
|
86bebf9776 | ||
|
|
7b7c6bde68 | ||
|
|
935fb235a6 | ||
|
|
d2d1cdbb92 | ||
|
|
5f08b0e086 | ||
|
|
637499a8ec | ||
|
|
f413bae348 | ||
|
|
2b1de583fd | ||
|
|
5500cdb118 | ||
|
|
2254a13ad0 | ||
|
|
1f5885de84 | ||
|
|
705f58e3d1 | ||
|
|
0330453ad6 | ||
|
|
66b98e2765 | ||
|
|
55648c9a30 | ||
|
|
cd6d3daf43 | ||
|
|
1db5060c42 | ||
|
|
4cabba395d | ||
|
|
59b478f262 | ||
|
|
e4f14a24b7 | ||
|
|
3bd0d055d9 | ||
|
|
746d17e182 | ||
|
|
e96e51598a | ||
|
|
9abec36f07 | ||
|
|
ecd8e3679f | ||
|
|
ba95723b61 | ||
|
|
12289bdce3 | ||
|
|
d1c5234a03 | ||
|
|
27cc876e82 | ||
|
|
82919d39f6 | ||
|
|
3481b97d47 | ||
|
|
d7c7fbad88 | ||
|
|
7f07032bf2 | ||
|
|
d873c96ae3 | ||
|
|
c4f6723042 | ||
|
|
9e699c4dd4 | ||
|
|
ea81380225 | ||
|
|
a9bc82fac5 | ||
|
|
8c278be941 | ||
|
|
aa6c1b5094 | ||
|
|
4f4b12f039 | ||
|
|
528e55991b | ||
|
|
122eee175e | ||
|
|
5a28f75303 | ||
|
|
07cb428287 | ||
|
|
b197017644 | ||
|
|
e9f07a4a39 | ||
|
|
44d389201c | ||
|
|
3106aaf314 | ||
|
|
90e797b8fa | ||
|
|
1f7362c8af | ||
|
|
fe44a2b12d | ||
|
|
8a9239311d | ||
|
|
cd25cd6ee4 | ||
|
|
967fbba2a4 | ||
|
|
41fe65c7fc | ||
|
|
09d345a312 | ||
|
|
1a13d745f1 | ||
|
|
ce184771a6 | ||
|
|
7b6365f6b3 | ||
|
|
44867c79f8 | ||
|
|
09a9e8c2f0 | ||
|
|
b26a6f40b9 |
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: compare-php
|
||||
description: Compare a feature between PHP boha-app and TS boha-app-ts to verify migration parity
|
||||
---
|
||||
|
||||
# Compare PHP vs TS Implementation
|
||||
|
||||
Compare a specific feature, component, or endpoint between the original PHP project (D:\cortex\boha-app) and the TypeScript migration (D:\cortex\boha-app-ts).
|
||||
|
||||
## Usage
|
||||
|
||||
The user will specify what to compare. Examples:
|
||||
- `/compare-php attendance print`
|
||||
- `/compare-php invoice PDF generation`
|
||||
- `/compare-php offer numbering logic`
|
||||
|
||||
## Process
|
||||
|
||||
1. Search the PHP codebase (D:\cortex\boha-app) for the relevant implementation
|
||||
2. Search the TS codebase (D:\cortex\boha-app-ts) for the same feature
|
||||
3. Compare:
|
||||
- API endpoints and request/response shape
|
||||
- Business logic and calculations
|
||||
- Database queries
|
||||
- Frontend behavior
|
||||
4. Report differences found — what's missing, what's different, what's extra
|
||||
|
||||
## Key directories
|
||||
|
||||
**PHP project:**
|
||||
- API handlers: `D:\cortex\boha-app\api\admin\handlers\`
|
||||
- API routes: `D:\cortex\boha-app\api\admin\`
|
||||
- Frontend pages: `D:\cortex\boha-app\src\admin\pages\`
|
||||
- Frontend components: `D:\cortex\boha-app\src\admin\components\`
|
||||
- Frontend hooks: `D:\cortex\boha-app\src\admin\hooks\`
|
||||
- Includes/utils: `D:\cortex\boha-app\api\includes\`
|
||||
|
||||
**TS project:**
|
||||
- API routes: `D:\cortex\boha-app-ts\src\routes\admin\`
|
||||
- Services: `D:\cortex\boha-app-ts\src\services\`
|
||||
- Frontend pages: `D:\cortex\boha-app-ts\src\admin\pages\`
|
||||
- Frontend components: `D:\cortex\boha-app-ts\src\admin\components\`
|
||||
- Frontend hooks: `D:\cortex\boha-app-ts\src\admin\hooks\`
|
||||
@@ -7,13 +7,13 @@ HOST=127.0.0.1
|
||||
APP_ENV=local
|
||||
|
||||
# Auth — MUST regenerate for production: openssl rand -hex 32
|
||||
JWT_SECRET=generate-with-openssl-rand-hex-32
|
||||
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
ACCESS_TOKEN_EXPIRY=900
|
||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
||||
|
||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
|
||||
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||
|
||||
# File storage
|
||||
NAS_PATH=Z:/02_PROJEKTY
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ dist-client/
|
||||
# Claude worktrees
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Superpowers brainstorm mockups
|
||||
.superpowers/
|
||||
|
||||
508
CLAUDE.md
Normal file
508
CLAUDE.md
Normal file
@@ -0,0 +1,508 @@
|
||||
# CLAUDE.md — boha-app-ts
|
||||
|
||||
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
|
||||
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| -------------- | ------------------------------------------------------------- |
|
||||
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
|
||||
| HTTP Framework | Fastify 5.8.2 |
|
||||
| ORM | Prisma 6.19.2 → MySQL |
|
||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||
| Validation | Zod 4.3.6 |
|
||||
| Frontend | React 18.3.1 + Vite 8.0.0 + Material UI v7 (Emotion) |
|
||||
| Testing | Vitest 4.1.0 + Supertest |
|
||||
| PDF | Puppeteer 24.x |
|
||||
| Email | nodemailer 8.x |
|
||||
| Cron | node-cron 4.x |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.ts # Fastify server entry point — plugins, routes, error handler
|
||||
├── routes/admin/ # HTTP route handlers (one file per entity)
|
||||
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
|
||||
├── schemas/ # Zod validation schemas (one file per entity)
|
||||
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
|
||||
│ # security.ts (CSP, HSTS, security headers)
|
||||
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
||||
├── config/ # env.ts (config singleton, Date.toJSON override)
|
||||
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
|
||||
├── admin/ # React 18 + Material UI frontend (~105 .tsx files)
|
||||
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
|
||||
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
|
||||
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
|
||||
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
||||
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets
|
||||
│ ├── pages/ # One file per page/feature
|
||||
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
|
||||
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
|
||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
||||
└── __tests__/ # Vitest tests (~14 files: auth, numbering, warehouse, plan, invoices, …)
|
||||
|
||||
prisma/
|
||||
├── schema.prisma # 49 models, MySQL, snake_case columns
|
||||
└── migrations/ # Applied migrations
|
||||
|
||||
dist/ # Compiled server (CommonJS, ES2022)
|
||||
dist-client/ # Built frontend (Vite, ES2020)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Starts server in watch mode (manage frontend separately)
|
||||
npm run dev:server # tsx watch src/server.ts
|
||||
npm run dev:client # Vite dev server
|
||||
|
||||
# Build
|
||||
npm run build # Build server + client
|
||||
npm run build:server # tsc -p tsconfig.server.json → dist/
|
||||
npm run build:client # vite build → dist-client/
|
||||
|
||||
# Run (production)
|
||||
npm start # node dist/server.js
|
||||
|
||||
# Tests
|
||||
npm test # vitest run (single pass)
|
||||
npm run test:watch # vitest watch
|
||||
|
||||
# Database
|
||||
npx prisma migrate dev # Create migration from schema changes + apply to dev
|
||||
npx prisma migrate dev --name <descriptive_name> # Named migration
|
||||
npx prisma migrate deploy # Apply pending migrations to production
|
||||
npx prisma generate # Regenerate Prisma client after schema changes
|
||||
npx prisma studio # DB browser GUI
|
||||
npx prisma db seed # (Re)seed the dev database
|
||||
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
|
||||
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
|
||||
```
|
||||
|
||||
**Do not start the dev server.** The user manages it separately.
|
||||
|
||||
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required:
|
||||
|
||||
```
|
||||
DATABASE_URL=mysql://user:pass@host:3306/dbname
|
||||
JWT_SECRET=<64-char hex string>
|
||||
TOTP_ENCRYPTION_KEY=<64-char hex string>
|
||||
```
|
||||
|
||||
Optional (with defaults):
|
||||
|
||||
```
|
||||
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
|
||||
HOST=127.0.0.1
|
||||
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
||||
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
||||
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
|
||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
|
||||
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
|
||||
MAX_UPLOAD_SIZE=52428800 # 50MB
|
||||
CONTACT_EMAIL_TO=
|
||||
CONTACT_EMAIL_FROM=
|
||||
SMTP_FROM=
|
||||
LEAVE_NOTIFY_EMAIL=
|
||||
APP_URL= # Used in email links
|
||||
CORS_ORIGINS= # Comma-separated, production only
|
||||
```
|
||||
|
||||
Use `.env` for dev, `.env.test` for tests.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Key Patterns
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
Request → CORS → Cookie → Rate-limit → Security headers
|
||||
→ requirePermission() or requireAuth()
|
||||
→ Zod schema validation (parseBody helper)
|
||||
→ Route handler
|
||||
→ Service function
|
||||
→ Prisma
|
||||
→ success(reply, data) or error(reply, message, status)
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
All responses use this shape:
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
{ success: true, data: T, message?: string, pagination?: {...} }
|
||||
|
||||
// Error
|
||||
{ success: false, error: string }
|
||||
```
|
||||
|
||||
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
|
||||
|
||||
### Service Pattern
|
||||
|
||||
Services are plain exported async functions, no classes:
|
||||
|
||||
```typescript
|
||||
// src/services/foo.service.ts
|
||||
export async function getFoo(id: number) {
|
||||
const result = await prisma.foo.findUnique({ where: { id } });
|
||||
if (!result) return { error: "Not found", status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
|
||||
// src/routes/admin/foo.ts
|
||||
const result = await getFoo(id);
|
||||
if ("error" in result) return error(reply, result.error, result.status ?? 400);
|
||||
return success(reply, result.data);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Routes map service errors to HTTP responses using the pattern above.
|
||||
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
|
||||
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
|
||||
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
|
||||
|
||||
### Permissions
|
||||
|
||||
```typescript
|
||||
// Route-level guard
|
||||
fastify.addHook("preHandler", requirePermission("invoices.view"));
|
||||
// or multiple
|
||||
fastify.addHook(
|
||||
"preHandler",
|
||||
requirePermission("invoices.view", "invoices.edit"),
|
||||
);
|
||||
|
||||
// Admin role bypasses all permission checks
|
||||
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
|
||||
```
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
|
||||
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
|
||||
|
||||
### Validation
|
||||
|
||||
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
|
||||
|
||||
```typescript
|
||||
const body = parseBody(FooSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Date & Timezone Handling (Critical Gotcha)
|
||||
|
||||
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
|
||||
`Date.prototype.toJSON()` to return local time (not UTC). This means:
|
||||
|
||||
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
|
||||
- All API responses with Date fields will contain local time strings.
|
||||
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
|
||||
- **Never assume UTC** when working with Date objects in this codebase.
|
||||
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
|
||||
- The override exists for PHP migration compatibility and Czech date display.
|
||||
|
||||
---
|
||||
|
||||
## TOTP / 2FA
|
||||
|
||||
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
|
||||
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
|
||||
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
|
||||
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
|
||||
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
|
||||
|
||||
- The suite spans ~14 files / 152 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create. Server-side only (no component tests yet).
|
||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
|
||||
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
|
||||
|
||||
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Conventions
|
||||
|
||||
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
||||
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
|
||||
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
|
||||
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
|
||||
|
||||
### Query Invalidation Convention
|
||||
|
||||
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
|
||||
|
||||
- `["users"]` over `["users", "list"]`
|
||||
- `["trips"]` over `["trips", "vehicles"]`
|
||||
|
||||
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
|
||||
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
|
||||
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
|
||||
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
|
||||
|
||||
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
|
||||
|
||||
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
|
||||
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
|
||||
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
|
||||
|
||||
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
|
||||
|
||||
**Where styling lives**
|
||||
|
||||
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
|
||||
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
|
||||
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
|
||||
|
||||
**Building / editing pages**
|
||||
|
||||
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
|
||||
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
|
||||
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
|
||||
|
||||
**Conventions learned — don't regress**
|
||||
|
||||
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
|
||||
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
|
||||
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
|
||||
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
|
||||
|
||||
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
|
||||
|
||||
---
|
||||
|
||||
## Database Conventions
|
||||
|
||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
||||
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
|
||||
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
|
||||
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
|
||||
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations (Critical Workflow)
|
||||
|
||||
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
|
||||
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
|
||||
|
||||
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
|
||||
|
||||
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
|
||||
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
|
||||
- Lookup data, default settings, or any other row-level baseline that production needs
|
||||
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
|
||||
|
||||
**What is NOT acceptable:**
|
||||
|
||||
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
|
||||
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
|
||||
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
|
||||
|
||||
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
|
||||
|
||||
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
|
||||
|
||||
### Making schema changes
|
||||
|
||||
```
|
||||
1. Edit prisma/schema.prisma
|
||||
2. npx prisma migrate dev --name descriptive_name
|
||||
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
|
||||
3. npx prisma generate
|
||||
4. Commit BOTH schema.prisma AND the new migration folder
|
||||
git add prisma/schema.prisma prisma/migrations/
|
||||
```
|
||||
|
||||
### Verifying before production deploy
|
||||
|
||||
```bash
|
||||
# Preview what SQL will run on production (no changes applied)
|
||||
npx prisma migrate diff \
|
||||
--from-url "mysql://user:pass@prod:3306/app" \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script
|
||||
|
||||
# Empty output = no diff. If it shows SQL, review it before deploying.
|
||||
```
|
||||
|
||||
### Deploying migrations to production
|
||||
|
||||
The release process runs `prisma migrate deploy` on production.
|
||||
This applies only pending migrations — safe, idempotent.
|
||||
|
||||
### If migrations get out of sync (drift)
|
||||
|
||||
```bash
|
||||
# Diff production DB against local schema to find drift
|
||||
npx prisma migrate diff \
|
||||
--from-url "mysql://prod" \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script
|
||||
|
||||
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
|
||||
# If drift includes DROPs: investigate before touching production
|
||||
```
|
||||
|
||||
### Baselinining a database that has no migrations
|
||||
|
||||
If production was synced with `db push` and has no `_prisma_migrations` table:
|
||||
|
||||
```bash
|
||||
# 1. Create initial migration locally
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 2. Copy to production and mark as applied (no SQL runs)
|
||||
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
|
||||
ssh user@prod
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate resolve --applied <migration_name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conventions (enforced — verified by the 2026-06-06 audit)
|
||||
|
||||
These are the unified rules across the codebase. Follow them for new code; the
|
||||
audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-06.md`.
|
||||
|
||||
**Routes**
|
||||
|
||||
- **Responses:** always `success(reply, data[, status, message])` / `error(reply, msg, status)` / the paginated helper. Never raw `reply.send({ success: true, ... })`. (Some legacy files still do — convert when you touch them.)
|
||||
- **Route ids:** parse numeric params with `parseId((request.params as any).id, reply)` then `if (id === null) return;`. Do not use raw `parseInt` (it yields `NaN`, not a 400).
|
||||
- **Bodies:** validate every body with `parseBody(Schema, request.body)` → `if ("error" in body) return error(reply, body.error, 400)`.
|
||||
- **Permissions:** guard with `requirePermission` / `requireAnyPermission`. The admin shortcut is `authData.roleName === "admin"`. ⚠️ `AuthData` has **`roleName`**, not `role` (`role` only exists on `JwtPayload`). Don't type `authData` as `any` — it hides exactly this bug.
|
||||
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`.
|
||||
|
||||
**Services**
|
||||
|
||||
- Plain exported async functions; return `{ data }` or `{ error, status }` (preferred over discriminated unions). Services own Prisma; routes stay thin.
|
||||
- Respect soft-delete (`is_deleted: false`) in reads where the model has it.
|
||||
|
||||
**Schemas (Zod 4)**
|
||||
|
||||
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`).
|
||||
- Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in `src/schemas/common.ts` — don't copy-paste the `z.union([z.number(), z.string()]).transform(Number)` idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against `NaN`.
|
||||
- User-facing messages in **Czech**; identifiers/keys in English.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- **Query invalidation:** invalidate the **broad domain key** (`["offers"]`, not `["offers","list"]`). Prefix-matching covers sub-queries.
|
||||
- **Single source of truth for shared maps:** audit `entity_type` → Czech label lives in `src/admin/lib/entityTypeLabels.ts` and MUST be keyed to the server's `EntityType` values (add the new key there when you add an entity type). Plan categories come from the DB via `lib/queries/plan.ts`. Don't duplicate label maps across files or across server/client.
|
||||
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects.
|
||||
|
||||
**Dates (two deliberate regimes — don't "fix" either)**
|
||||
|
||||
- **Plan module** does all date-only math in **UTC** (`setUTCDate`, `toISOString().slice(0,10)`) because its columns are `@db.Date` (UTC-midnight). Correct and stable.
|
||||
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`).
|
||||
- **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Gotchas
|
||||
|
||||
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
|
||||
|
||||
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
|
||||
|
||||
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
|
||||
|
||||
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so.
|
||||
|
||||
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.
|
||||
|
||||
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
|
||||
|
||||
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
|
||||
|
||||
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
|
||||
|
||||
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
|
||||
|
||||
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
|
||||
|
||||
11. **Seed file is dev-only** — `prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
|
||||
1. Bump version in `package.json`
|
||||
2. `npm run build`
|
||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
|
||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
||||
- Path: `/var/www/app-ts`
|
||||
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
|
||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
||||
- Install dependencies: `npm install --omit=dev`
|
||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
||||
- Restart: `pm2 restart app-ts --update-env`
|
||||
|
||||
### Hotfixing a migration that was added after the tarball was built
|
||||
|
||||
If you write a new `prisma/migrations/<timestamp>_*` folder **after** the
|
||||
release tarball has already been built and shipped, that migration is
|
||||
NOT in the tarball and `prisma migrate deploy` on prod will report
|
||||
"No pending migrations". The release tarball re-build re-runs the whole
|
||||
release, but for a one-off hotfix you can ship just the new migration
|
||||
folder:
|
||||
|
||||
```bash
|
||||
# Local
|
||||
cd prisma/migrations
|
||||
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
|
||||
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||
|
||||
# On prod
|
||||
ssh boha_admin@192.168.50.100
|
||||
cd /var/www/app-ts/prisma/migrations
|
||||
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
|
||||
cd /var/www/app-ts
|
||||
npx prisma migrate deploy
|
||||
pm2 restart app-ts --update-env
|
||||
```
|
||||
|
||||
The migration is still tracked in git and applied via `prisma migrate
|
||||
deploy` — the only thing the tarball is doing is delivering the
|
||||
`migration.sql` to prod, which is the same mechanism the main release
|
||||
uses. This is preferred over running raw SQL on prod (which the policy
|
||||
in "Database Migrations" forbids).
|
||||
|
||||
Do not push directly to production or restart services without confirmation.
|
||||
37
boneyard.config.json
Normal file
37
boneyard.config.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"breakpoints": [375, 768, 1280],
|
||||
"out": "./src/admin/bones",
|
||||
"color": "#e0e0e0",
|
||||
"animate": "shimmer",
|
||||
"shimmerColor": "#f0f0f0",
|
||||
"speed": "1.2s",
|
||||
"shimmerAngle": 110,
|
||||
"wait": 3000,
|
||||
"routes": [
|
||||
"/",
|
||||
"/users",
|
||||
"/attendance",
|
||||
"/attendance/history",
|
||||
"/attendance/admin",
|
||||
"/attendance/balances",
|
||||
"/attendance/requests",
|
||||
"/attendance/approval",
|
||||
"/attendance/create",
|
||||
"/trips",
|
||||
"/trips/history",
|
||||
"/trips/admin",
|
||||
"/vehicles",
|
||||
"/offers",
|
||||
"/offers/new",
|
||||
"/offers/customers",
|
||||
"/offers/templates",
|
||||
"/orders",
|
||||
"/orders/1",
|
||||
"/projects",
|
||||
"/projects/80",
|
||||
"/invoices",
|
||||
"/invoices/new",
|
||||
"/settings",
|
||||
"/audit-log"
|
||||
]
|
||||
}
|
||||
90
docs/codebase-audit-2026-06-06.md
Normal file
90
docs/codebase-audit-2026-06-06.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Codebase Consistency & Best-Practices Audit — 2026-06-06 (overnight)
|
||||
|
||||
**Branch:** `chore/codebase-consistency-audit` (off `master` @ v1.9.0). Nothing here is pushed or deployed; for user review in the morning.
|
||||
|
||||
**Mandate (from user):** scan the whole codebase, find inconsistencies, unify manners/conventions, apply best practices (use context7 for library docs). Edit CLAUDE.md to codify rules. Work only in the project dir. Use many agents.
|
||||
|
||||
**Operating rules I'm following (self-imposed for safe autonomous work):**
|
||||
|
||||
- Isolated branch only. Never touch `master`, never push, never deploy, never touch prod/DB.
|
||||
- Keep `tsc` (server+client) at 0 and `vitest` green after every change set.
|
||||
- Prefer documenting risky/large refactors over auto-applying them. Auto-apply only safe, verified, mechanical unifications.
|
||||
- Don't `git add -A`; stage explicit paths.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL FINDINGS (need user action)
|
||||
|
||||
### C1 — v1.9.0 production regression: plan edit-modal delete button missing
|
||||
|
||||
- **Cause:** `FormModal`'s `footerLeft` / `hideCloseButton` props + modal animations lived only in the user's _uncommitted_ working tree. Committed `PlanCellModal.tsx:225` (and the modal system) depend on `footerLeft`. Releases build from committed code (WIP stashed), so v1.9.0 shipped a `FormModal` without the footer-left slot → the **delete button in the plan entry/override edit modal does not render in production**. Also caused a committed-code `tsc` error (TS2322).
|
||||
- **Impact:** plan entry/override deletion via the edit modal is broken in prod v1.9.0. Category management is NOT affected (it uses `hideFooter` + in-body delete). Minor cosmetic: modal entry animation, `hideCloseButton`.
|
||||
- **Fix:** committed the modal enhancements (`86e4cbf` on this branch). **Needs a patch release (v1.9.1) to fix prod.**
|
||||
- **Status:** fixed on branch; prod patch pending user.
|
||||
|
||||
### C2 — test suite was red on master/v1.9.0 (2 failing auth tests)
|
||||
|
||||
- **Cause:** the JWT log-noise fix (`88c9b7c`) stopped logging expected invalid/expired tokens, but `auth.service.test.ts` still asserted it logged. The wrong auth test file (`auth.test.ts`) was run when that change landed, so 2 failing tests shipped on master.
|
||||
- **Fix:** updated the tests to assert null + no-log for `JsonWebTokenError` cases (`55a2c50`). Suite back to **134/134**.
|
||||
- **Status:** fixed on branch. (No runtime impact; CI/test hygiene + would also be cleared by the v1.9.1 patch.)
|
||||
|
||||
---
|
||||
|
||||
## Findings log
|
||||
|
||||
(Discovery sweep results appended below as agents report. Severity: critical/high/medium/low. Fix class: safe-auto / needs-judgment / manual.)
|
||||
|
||||
## Changes applied on this branch (all verified: server+client tsc 0, vitest 134, build 0)
|
||||
|
||||
Correctness / bugs:
|
||||
|
||||
- `86e4cbf` **C1** modal `footerLeft`/`hideCloseButton` enhancements committed (fixes v1.9.0 regression + tsc error).
|
||||
- `55a2c50` **C2** fix 2 failing `verifyAccessToken` tests (red on master).
|
||||
- `379725a` **H1/H4** `isAdminLike` reads `roleName` not `role` (admins were self-scoped on /entries,/overrides); dropped 4 stale `as any` category casts.
|
||||
- `c5f986a` **H5** corrected + unified audit `entity_type` label map (was showing raw English for invoice/order/quotation/trip/vehicle + all warehouse\_\*).
|
||||
- `1f05ef6` date: `todayLocalStr()` replaces 11 UTC-`today` defaults (off-by-one in post-midnight Prague).
|
||||
- `744baec` security: audit-log session termination (single + all-others); new `session` EntityType.
|
||||
|
||||
Consistency / cleanup:
|
||||
|
||||
- `9ae49c0` parseId (trips/sessions), broad `["offers"]` invalidation, Czech messages (attendance/orders/offers/project-files), Zod 4 `z.strictObject`.
|
||||
- `522afef` undefined `--danger-color`→`--danger`, dedup `formatDate` + `require2FAOptions`, removed dead `systemSettingsOptions`, dropped double `plan.css` import.
|
||||
- `9c582e1` **H2/H3** log the 30 silent NAS filesystem catches (kept 3 ENOENT-expected as deliberate no-logs).
|
||||
|
||||
Docs:
|
||||
|
||||
- `94a9fb8` CLAUDE.md: codified "Conventions (enforced)" section; corrected stale known-issues #4 (NAS) and #5 (sanitization is in place, not a gap).
|
||||
- `58bf034`/`0559664` this report + full 84-finding catalog (`codebase-audit-findings-2026-06-06.md`).
|
||||
|
||||
## Remaining recommendations (NOT auto-applied — for your review)
|
||||
|
||||
Deliberately left for review (too broad/behavior-risky to apply unattended, or low value):
|
||||
|
||||
- **`reply.send` → helpers** (medium): ~1/3 of route files use raw `reply.send({success:true,...})` instead of `success()/paginated()`. Mechanical but touches many files + response-shape sensitive — convert per-file when touched. (List in findings catalog, dimension `routes`.)
|
||||
- **Zod coercion helpers** (medium): the number/nullable-number/boolean-from-form coercions are copy-pasted ~150×; extract to `src/schemas/common.ts` (with a `NaN` guard — current coercion silently yields `NaN`). Big mechanical sweep — do with review.
|
||||
- **console.\* → request logger** (medium): services log via `console.*` (bypasses pino/log-level). Architectural; needs a service-logging strategy.
|
||||
- **`markOverdueInvoices`** (medium): swallows the DB-update failure and returns void, so the route treats a failed update as success — surface the error. Also it compares a `@db.Date` due_date against full `new Date()` (flips "overdue" on the due day) — compare date-only.
|
||||
- **Dead exports** (low): `previewPattern()` (numbering.service), `usersQuery`/`planKeys.users()` (lib/queries/plan) — unused API surface; remove or wire up.
|
||||
- **`ShiftFormModal`** (low): inline styles use `--danger-color`/`--success-color`/etc. (undefined; rely on fallbacks) — switch to the `--danger`/`--success` design tokens.
|
||||
- **`.env.example` drift** (low): out of sync with vars read in `config/env.ts` — regenerate.
|
||||
- **`@/*` path alias** (low): declared in tsconfig but not wired into Vite/vitest — either wire it up or remove.
|
||||
- **warehouse soft-delete test** (low): a test named "soft delete" asserts a hard delete its own comment flags as buggy — clarify intent.
|
||||
- **TOTP verify** doesn't increment failed-login counter (relies on rate limit); **HSTS** lacks `preload` — security hardening, low.
|
||||
|
||||
Full detail + file:line for every item: `docs/codebase-audit-findings-2026-06-06.md`.
|
||||
|
||||
## Progress tracker
|
||||
|
||||
- [x] Branch + clean baseline (tsc server 0 / client 0; vitest 134).
|
||||
- [x] C1 modal regression + C2 red tests found & fixed (134/134).
|
||||
- [x] Discovery sweep (16 parallel dimensions, 84 findings).
|
||||
- [x] Synthesis + prioritization (high + safe-auto).
|
||||
- [x] Safe unifications applied (12 commits, each verified green).
|
||||
- [x] CLAUDE.md updated with codified conventions.
|
||||
- [x] Remaining items documented as a review roadmap.
|
||||
- [x] Final verification.
|
||||
|
||||
## ⚠️ Action for you in the morning
|
||||
|
||||
- **v1.9.1 patch:** C1 (plan edit-modal delete button) + C2 (red tests) affect production v1.9.0. Merge this branch (or cherry-pick `86e4cbf`,`55a2c50`,`379725a`,`c5f986a`) and cut a patch.
|
||||
- Review this branch (`chore/codebase-consistency-audit`, 12 commits) and merge what you like. Nothing here is pushed or deployed.
|
||||
544
docs/codebase-audit-findings-2026-06-06.md
Normal file
544
docs/codebase-audit-findings-2026-06-06.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# Codebase Audit — Full Findings Catalog (2026-06-06)
|
||||
|
||||
Generated from the 16-dimension discovery sweep. 84 findings (high 5, medium 31, low 48).
|
||||
|
||||
## Dimension summaries
|
||||
|
||||
**svc-errors** — test2
|
||||
|
||||
**routes** — Route handlers in src/routes/admin/* are largely consistent and follow the documented patterns well: nearly every route uses a requirePermission/requireAnyPermission/requireAuth guard, validates bodies via parseBody(ZodSchema, ...) with the uniform `if ("error" in parsed) return error(reply, parsed.error, 400)` shape, uses parseId for numeric route params in most files, and logs audit entries on create/update/delete with old/new values. The warehouse, customers, invoices, projects, roles, and auth/totp files are exemplary. The real issues are: (1) a genuine authorization/visibility bug in plan.ts where the admin check reads a non-existent field; (2) a recurring style violation where roughly a third of files send raw `reply.send({ success: true, ... })` instead of the success()/paginated() helpers that CLAUDE.md mandates; (3) two security-relevant session-termination mutations with no audit logging; and (4) inconsistent ID parsing (raw parseInt vs the parseId helper) in trips.ts and sessions.ts. HTTP status codes are generally correct (201 on create, 404 not-found, 409 conflict, 403 permission), with one cluster of not-found/validation responses in project-files.ts falling back to the default 400.
|
||||
|
||||
**schemas** — The 21 Zod schema files in src/schemas/* are consistently organized (one file per entity, Create/Update pairs, z.infer type exports, Czech user-facing messages — all intentional and good). Every route body is validated via the shared parseBody helper (src/schemas/common.ts) — coverage is complete; the handful of raw `request.body` reads (attendance.ts, orders.ts, projects.ts, project-files.ts) either re-dispatch to parseBody with branch-specific schemas or do narrow manual checks, so there is no unvalidated-body gap. The dominant real issue is massive duplication of three coercion idioms — a number-from-form `z.union([z.number(),z.string()]).transform(v=>Number(v))`, a nullable variant with `z.null()`, and a boolean-from-form `z.preprocess(v=>v===true||v===1||v==="1", z.boolean())` — copy-pasted ~150+ times across files instead of living as shared helpers in common.ts. Secondary issues: the number coercion is unsafe (no NaN guard in most places, so "abc" silently becomes NaN), email validation is applied inconsistently (only users/profile, not customers/warehouse/settings), error-message language is mixed (Czech vs English "Must be a valid number"), no schemas use strict object mode so unknown keys are silently stripped, and the two inline route schemas use Zod-3-deprecated `.strict()`/`.passthrough()` rather than Zod 4's `z.strictObject()`/`z.looseObject()`. Severity is mostly low/medium: these are maintainability and robustness gaps, not security holes (the app is on Zod 4.3.6, so the modern helpers are available).
|
||||
|
||||
**logging** — Overall the codebase handles errors deliberately: routes use Fastify's pino logger (request.log.*/app.log.*), the global error handler logs unhandled exceptions, audit failures are logged and treated as non-fatal, and a couple of catch blocks (auth.ts verifyAccessToken, attendance/leave duplicate-record handling) are intentionally selective with good comments. The two real systemic issues are: (1) the NAS file/financials managers (src/services/nas-file-manager.ts, src/services/nas-financials-manager.ts) contain ~25 empty/silent catch blocks that swallow genuine filesystem operation failures (delete/mkdir/write/rename) with zero logging, returning only a generic Czech string — directly violating the CLAUDE.md "never silently swallow errors" rule and making NAS issues undebuggable; and (2) all service-layer error logging uses console.* instead of the configured pino logger, so those messages bypass structured logging and the production log-level config. A secondary correctness smell: markOverdueInvoices swallows DB-update failures and returns void, so its route caller treats a failed update as success. Frontend error handling is consistent (alert.error with Czech fallback); a few best-effort .catch(()=>{}) swallows on background calls are defensible but undocumented.
|
||||
|
||||
**prisma** — Solid Prisma usage overall. Real issues in findings.
|
||||
|
||||
**security** — Auth and crypto fundamentals are solid: refresh tokens are SHA-256-hashed with rotation + reuse detection (replaced_at/replaced_by_hash + FOR UPDATE), TOTP has counter-based replay protection, password comparison is timing-safe (dummy bcrypt hash on user-not-found/locked/inactive), TOTP secrets are AES-256-GCM encrypted, account lockout + per-route rate limits exist, bcrypt cost is 12, and JWT verification pins HS256. Contrary to CLAUDE.md "known issue #5", HTML sanitization for invoice notes, quotation scope, and order notes is NOT missing: DOMPurify (jsdom) is applied at all three server-side PDF routes plus a regex-based cleanQuillHtml second pass, and both frontend render sites (InvoiceDetail, OrderDetail) sanitize before dangerouslySetInnerHTML. NAS file access has strong path-traversal defenses (rejects "..", null bytes, confines to base, rejects symlink components, MIME/extension allowlist). The real gaps are narrower: the TOTP-code verification step does not increment failed-login counters (relies only on a 5/min rate limit), HSTS lacks preload, and the server-side invoice/offer PDF endpoints serve attacker-influenced (but sanitized) HTML as same-origin text/html. No SQL injection found — all $queryRaw uses are parameterized tagged templates.
|
||||
|
||||
**dates** — Date handling splits into two well-reasoned, internally-consistent regimes plus a set of genuine off-by-one bugs at the boundary between them. (1) The plan module (src/services/plan.service.ts, src/admin/pages/PlanWork.tsx, usePlanWork.ts, PlanGrid.tsx) deliberately does ALL date-only arithmetic in UTC (setUTCDate/getUTCDate, toISOString().slice(0,10), proper ISO-8601 week calc). Because every column it touches is `@db.Date` (stored at UTC-midnight by Prisma), this is correct and stable — explicitly documented at plan.service.ts:63-67 and 342-352. Do not "fix" it. (2) The attendance/leave server code constructs `@db.Date` writes at LOCAL noon (new Date(y,m,d,12,0,0)), which is the robust convention. The real problems: a repeated frontend pattern `new Date().toISOString().split("T")[0]` for "today" defaults computes the UTC date, giving yesterday in the late-evening Prague window; the invoices/received-invoices/trips edit forms round-trip API date strings through `new Date(x).toISOString().split(...)` instead of the existing string-slice helper `normalizeDateStr`, risking a shift; trips' raw API datetime string is fed into a native <input type=date> (blank on mobile); markOverdueInvoices compares a `@db.Date` due_date against full `new Date()` so invoices flip to "overdue" on their due day; and getCzechDate uses a non-ISO local week formula that disagrees with PlanWork's ISO week. A correct local-date string helper (utils/date.ts localDateStr, and frontend normalizeDateStr) already exists but isn't used in the offending spots.
|
||||
|
||||
**ts-safety** — Both tsconfigs enable `strict: true` and there are zero `@ts-ignore`/`@ts-nocheck` directives, so the baseline is good. The real risk is concentrated, not diffuse: 77 `any` occurrences cluster in the work-plan stack (plan.service.ts, routes/admin/plan.ts, hooks/usePlanWork.ts) and a few PDF/HTML builders. The single most important finding is a latent authorization-scoping bug masked by an `any`-typed parameter in plan.ts (`isAdminLike` reads `authData.role`, but `AuthData` only has `roleName`), which makes admins always scope to their own plan rows. Systemically, ~47 of 107 exported service functions have no explicit return type, which forces `(result as any).status` casts in route handlers (projects.ts, quotations.ts) because the inferred error union isn't uniform. There is also a genuinely duplicated/contradictory `PaginationMeta` shape defined three times with diverging fields. Non-null assertions are mostly the defensible `request.authData!` post-auth pattern and are not a concern.
|
||||
|
||||
**rq-data** — The frontend data layer is built on a clean, idiomatic foundation: every read is a `queryOptions()` factory in `src/admin/lib/queries/*` (TanStack Query v5 best practice), data flows through a thin `apiAdapter.ts` (jsonQuery/paginatedJsonQuery) that unwraps the `{success,data}` envelope and throws on error, and a purpose-built `useApiMutation` hook (lib/queries/mutations.ts) wraps `useMutation` + apiFetch + broad invalidation exactly as the docs recommend. The factory pattern is consistently applied for queries and `useApiMutation` is adopted in 34 files. The real weakness is on the WRITE side: a large minority of mutations bypass `useApiMutation` and hand-roll `apiFetch` + `await response.json()` + manual `invalidateQueries` + `try/catch alert("Chyba připojení")` (14 files, ~27 sites), duplicating the exact logic the hook exists to centralize. Within that hand-rolled code the broad-domain invalidation convention from CLAUDE.md is violated in OfferDetail.tsx (uses narrow `["offers","list"]` while Offers.tsx uses broad `["offers"]`). useAttendanceAdmin.ts runs an entirely parallel data system (manual fetchData + setTimeout(300) instead of query factories). Query-key conventions themselves are sound; minor smells are a duplicated `require2FAOptions` factory and an unused deprecated alias. None of these are correctness bugs — they are consistency/maintainability gaps. Best-practice basis: TanStack Query v5 docs (/tanstack/query) confirm prefix-matching invalidation (validating the broad-key convention) and the useMutation→onSuccess→invalidateQueries pattern that useApiMutation implements.
|
||||
|
||||
**react** — The frontend is mostly modern and disciplined: data flows through TanStack Query via lib/queries/*, lazy-loading is consistent for every page in AdminApp.tsx, and there are two solid reusable modal primitives (FormModal, ConfirmModal) used in 34 files. The real issues are concentrated and concrete: (1) one large hook (useAttendanceAdmin.ts, ~830 lines) opts out of TanStack Query and hand-rolls fetching with useEffect/useState, complete with silent empty catches and no race-condition guard — the single biggest consistency/best-practice gap; (2) two editable, mutable lists use the array index as React key (OrderConfirmationModal items, OfferDetail scope sections), which the React docs flag as a correctness bug because rows are deleted/reordered while holding controlled-input state; (3) modal usage is inconsistent — alongside FormModal/ConfirmModal, ~7 components hand-roll the admin-modal-overlay markup with diverging accessibility (DashProfile/DashQuickActions omit role="dialog"/aria-modal entirely; none of the hand-rolled ones wire Esc-to-close that FormModal provides). Several "sync server data into form state via useEffect" effects exist (InvoiceDetail, OfferDetail, CompanySettings, Settings); these use a one-shot ref/flag guard and are an accepted-but-not-ideal pattern per the React "You Might Not Need an Effect" guidance, so they are reported as low severity. Per React docs I pulled: avoid adjusting/resetting state on prop change in an Effect (prefer key-reset or compute during render), and data-fetching effects need an ignore/abort cleanup to avoid stale overwrites.
|
||||
|
||||
**css** — The admin CSS is a single global stylesheet assembled from 19 per-feature files, all imported in AdminApp.tsx (so every selector shares one global namespace — the file split is purely organizational, not scoped). Theming is well-architected and consistent: a central variables.css drives a token system via [data-theme="dark"]/[data-theme="light"] (no prefers-color-scheme mixing), and the large majority of color usage correctly references CSS variables. Genuine issues are: (1) one undefined variable (--danger-color) used in base.css; (2) an inconsistent class-naming scheme — most files use either admin-* or a feature prefix (plan-*, dash-*, fm-*, attendance-*, offers-*), but warehouse uses admin-warehouse-*, invoices mixes admin-badge-*/invoice-*/received-*, and a block of leave badges in components.css drops the admin- prefix entirely; (3) heavy duplication of the badge color recipe (background: color-mix(... 15%) + matching text color) across leave/order/project/invoice/attendance badges, with two different recipes (--success-soft vs color-mix --success 15%) used for the same semantic color; and (4) several defined-but-unused tokens plus an empty responsive.css stub and a double-import of plan.css. Hardcoded hex outside variables.css is mostly legitimate (#fff text on accent backgrounds, feature-scoped local theme token blocks in plan.css/layout.css), not a real problem.
|
||||
|
||||
**naming** — Naming in this codebase is broadly consistent and mostly intentional. Backend `src/` is overwhelmingly kebab-case for files (94 kebab vs 5 camelCase), service functions follow a clean `list*` (collections) / `get*` (single) / `create|update|delete*` (mutations) scheme with no fetch/load mixing, route verbs map cleanly to fastify methods, and Czech appears only in user-facing string literals (a documented, intentional choice — not an identifier problem). The frontend leans on its own consistent conventions: `handle*` event handlers (148 vs 8 `on*`, where the `on*` cases are local DOM listeners — idiomatic), `is*/has*` for boolean props/fields, and `*Options` for TanStack query factories (55 of them). The real, genuine deviations are a small cluster of files added during the "plan" feature work that broke the surrounding kebab-case convention (`planCategory.schema.ts`, `planCategory.service.ts`, `planAuditDescription.ts` and their tests), and the `plan.ts` query module that uses `gridQuery`/`usersQuery` instead of the established `*Options` suffix. The `.service.ts` suffix is applied to 10 of 20 service files, but this tracks a defensible entity-CRUD-vs-infra split rather than randomness. These are low-to-medium consistency gaps, not correctness issues; most fixes are renames with cross-file import ripples, so few qualify as safe-auto.
|
||||
|
||||
**deadcode** — The codebase is generally well-factored (date helpers in src/utils/date.ts and frontend formatters are correctly centralized and reused). The real dead-code/duplication problems are concentrated in two areas: (1) the three PDF route files (invoices-pdf.ts, orders-pdf.ts, offers-pdf.ts) each redefine the same set of HTML/number helpers (escapeHtml, formatNum, formatCurrency, formatDate, cleanQuillHtml) plus an identical JSDOM+DOMPurify bootstrap — and the copies have already diverged (one uses a regular space as a thousands separator where the others use a non-breaking space); and (2) a handful of genuinely unused exports and one orphan component file. escapeHtml alone is independently defined six times across server and frontend. There are no leftover debug console.logs in the conventional sense — service-layer console.* calls are the established (consistent) logging pattern here since services have no Fastify logger, and the one documented console.warn in plan.service.ts is intentional. No commented-out code blocks of note were found.
|
||||
|
||||
**i18n** — Localization is overwhelmingly correct and intentional: all React UI labels, placeholders, titles, button text, alert/toast messages, and the large majority of backend error strings are Czech with correct gender agreement (nenalezen/nenalezena/nenalezeno), while code identifiers, enum values, internal sentinels (e.g. EMAIL_EXISTS), env-validation throws, console.error logs, and dev-only React invariant throws ("useAuth must be used within...") are English — all intentional and not flagged. The genuine gaps are narrow: (1) a handful of English user-facing API error strings that leak to the client ("Missing attendance_id"/"Missing id" in attendance.ts, and "Unauthorized"/"Request failed (n)"/"Invalid JSON response" thrown in the frontend query layer that ProjectFileManager renders verbatim); (2) the parseBody helper surfaces raw Zod messages, so any validator lacking a custom Czech message leaks Zod's default English (enums, .max() length caps, TOTP token .min(1)); and (3) inconsistent Czech phrasing for "not found" — the compact "nenalezen*" form (92 uses) vs the verbose "nebyl/nebyla nalezen*" form (16 uses), sometimes for the identical entity ("Projekt nenalezen" vs "Projekt nebyl nalezen"). No mixed tone, no English UI chrome.
|
||||
|
||||
**tests part2b** — remaining two low findings
|
||||
|
||||
**config-structure** — The build/config layout is coherent: a project-references tsconfig.json splits server (CJS, ES2022, excludes src/admin) from app (ES2020, bundler resolution), with matching vite/vitest configs and env validation in src/config/env.ts. The main structural problem is the absence of a shared constants/types module spanning server and src/admin: domain label maps (entity-type, action, leave-type) and the canonical EntityType value list are duplicated and have DRIFTED. Most seriously, the frontend audit-log label/filter maps use PHP-legacy entity-type keys (offers_quotation, invoices_invoice, projects_project, trips, vehicles) that no longer match what the TS server writes (quotation, invoice, project, trip, vehicle) — a functional bug caused by the duplication. Secondary issues: a configured-but-unused @/* path alias (also absent from vite resolve), a package.json db:push script contradicting the CLAUDE.md golden rule, and .env.example drift vs config/env.ts. The one piece of genuine cross-boundary sharing (czech-holidays imported by src/admin from src/utils) is inconsistent with how everything else is duplicated and is structurally fragile.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. [HIGH/needs-judgment] Frontend audit-type label/filter maps use legacy keys that don't match server-written entity_type values
|
||||
- **dimension:** config-structure
|
||||
- **where:** src/admin/pages/AuditLog.tsx:47-66; src/admin/utils/dashboardHelpers.ts:21-40; src/types/index.ts:121-149; src/services/audit.ts:30,43; src/routes/admin/audit-log.ts:34; src/admin/lib/queries/auditLog.ts:18
|
||||
- **problem:** The server's canonical EntityType union (src/types/index.ts) and every logAudit() call write values like 'invoice', 'customer', 'quotation', 'order', 'project', 'trip', 'vehicle' (e.g. invoices.ts:134 entityType:'invoice', vehicles.ts:68 'vehicle', quotations.ts:80 'quotation'). But the frontend ENTITY_TYPE_LABELS maps (duplicated identically in AuditLog.tsx and dashboardHelpers.ts) key on PHP-legacy values: offers_quotation, offers_customer, orders_order, invoices_invoice, projects_project, trips, vehicles. Two consequences: (1) the audit log UI falls back to showing raw entity_type strings for most entities because the lookup misses; (2) ENTITY_OPTIONS (derived from this map) feeds the entity-type filter dropdown, which sends e.g. entity_type=invoices_invoice to audit-log.ts:34 where it is exact-matched (where.entity_type = String(...)), so filtering by Faktura/Objednavka/Projekt/Jizda/Vozidlo returns zero rows.
|
||||
- **fix:** Make EntityType a single runtime source of truth (an `as const` array exported from a module importable by both server and src/admin) and derive both the TS union and the label maps from it, or at minimum fix the frontend keys to match the server values (invoice, customer, quotation, order, project, trip, vehicle) and de-duplicate the two copies. Add a small test asserting every EntityType value has a label.
|
||||
|
||||
### 2. [HIGH/needs-judgment] NAS file manager swallows real filesystem failures with empty catch blocks and zero logging
|
||||
- **dimension:** logging
|
||||
- **where:** src/services/nas-file-manager.ts:118; src/services/nas-file-manager.ts:139; src/services/nas-file-manager.ts:153; src/services/nas-file-manager.ts:176; src/services/nas-file-manager.ts:426; src/services/nas-file-manager.ts:531; src/services/nas-file-manager.ts:679
|
||||
- **problem:** nas-file-manager.ts has ~25 catch blocks and not a single logging call (Grep for console./.log./logger returns no matches). While many are legitimate existence/control-flow checks where the error is expected (e.g. lines 471-473, 525-527, 666-668), several swallow genuine operation failures: deleteProjectFolder (118), createProjectFolder (139), delete (426) returning 'Nepodařilo se smazat...', createFolder mkdir (531) returning 'Nepodařilo se vytvořit složku', and countItems (679). The underlying OS error (EACCES, ENOSPC, network share down, etc.) is discarded, so when a NAS operation fails in production there is nothing in the logs to diagnose it. This directly violates the CLAUDE.md rule: 'Never silently swallow errors. Even if a failure is non-fatal, log it.'
|
||||
- **fix:** In the catch blocks that represent actual operation failures (delete, mkdir, rename move, write, copy), capture the error and log it via the Fastify logger before returning the generic Czech message. Since these are service methods without request context, either accept an optional logger/request param or have the calling route log the returned error with the original cause. At minimum, distinguish 'expected ENOENT existence check' catches (which can stay silent) from 'operation should have succeeded' catches (which must log).
|
||||
|
||||
### 3. [HIGH/needs-judgment] NAS financials manager swallows write/read/delete/mkdir errors without logging
|
||||
- **dimension:** logging
|
||||
- **where:** src/services/nas-financials-manager.ts:135; src/services/nas-financials-manager.ts:151; src/services/nas-financials-manager.ts:163; src/services/nas-financials-manager.ts:184
|
||||
- **problem:** saveReceivedInvoice (135) catches a writeFileSync failure and returns the error string to the caller but never logs it server-side. readReceivedInvoice (151-153), deleteReceivedInvoice (163-165) and ensureDir (184-186) use fully empty 'catch { return null/false }' blocks that discard the filesystem error entirely. A failed received-invoice save/read/delete on the network share leaves no server-side trace, again violating the 'never silently swallow' rule.
|
||||
- **fix:** Log the caught error (with the resolved path) before returning null/false/error-string. As with nas-file-manager, thread the Fastify logger in or log the returned error at the route layer. The empty 'catch {}' forms at 151/163/184 are the worst offenders — they should at least preserve the error variable and log it.
|
||||
|
||||
### 4. [HIGH/needs-judgment] plan.ts admin check reads non-existent authData.role field — admins cannot see other users' plan rows
|
||||
- **dimension:** routes
|
||||
- **where:** src/routes/admin/plan.ts:43-45; src/routes/admin/plan.ts:120; src/routes/admin/plan.ts:142; src/services/plan.service.ts:780; src/services/plan.service.ts:799; src/types/index.ts:51
|
||||
- **problem:** isAdminLike() does `return authData?.role === 'admin'`, but the AuthData interface (src/types/index.ts:51) has `roleName`, not `role`. Every other consumer in the codebase uses `roleName` (middleware/auth.ts:47,69; users.ts:109; services/auth.ts). So `authData.role` is always undefined and isAdminLike() always returns false. It is passed as the `isAdmin` argument to listEntries()/listOverrides() (plan.ts:120,142), where plan.service.ts:780/799 do `effectiveUserId = isAdmin ? query.user_id : actorUserId`. Net effect: GET /plan/entries and GET /plan/overrides silently ignore the user_id query param for everyone, including admins, so an admin can never read another employee's raw plan/override rows. (Security direction is safe — it over-restricts rather than leaking — but the feature is broken for admins.)
|
||||
- **fix:** Change isAdminLike to use the documented role/permission model: either `authData?.roleName === 'admin'` to match middleware/auth.ts, or better, check the relevant permission (e.g. authData.permissions.includes('attendance.manage')) consistent with how trips.ts:22 and attendance.ts:208 distinguish admin vs self. Also drop the `any` type on the parameter and type it as AuthData.
|
||||
|
||||
### 5. [HIGH/needs-judgment] `any`-typed param hides admin-scoping bug: isAdminLike reads nonexistent `authData.role`
|
||||
- **dimension:** ts-safety
|
||||
- **where:** src/routes/admin/plan.ts:43; src/routes/admin/plan.ts:44; src/routes/admin/plan.ts:120; src/routes/admin/plan.ts:142; src/types/index.ts:44; src/services/plan.service.ts:780; src/services/plan.service.ts:799
|
||||
- **problem:** `isAdminLike(authData: any)` returns `authData?.role === "admin"`. The `AuthData` type (types/index.ts:44) has NO `role` field — the admin role is on `roleName` (used correctly everywhere else: auth.ts:47/69, users.ts:109). Because the parameter is typed `any`, the compiler cannot flag that `.role` is always `undefined`, so `isAdminLike` always returns `false`. It is passed as the `isAdmin` arg to `listEntries`/`listOverrides`, where `effectiveUserId = isAdmin ? query.user_id : actorUserId`. Result: an admin querying another user's plan is silently scoped to their own rows; the `user_id` query param is ignored for everyone. The `any` is the direct cause the type system did not catch the `role` vs `roleName` typo.
|
||||
- **fix:** Type the parameter as `AuthData | null | undefined` (or reuse the existing `roleName === "admin"` convention) and change the comparison to `authData?.roleName === "admin"`. This both fixes the bug and lets the compiler verify the field exists. Add/confirm a list-scoping test for the cross-user admin case.
|
||||
|
||||
### 6. [MEDIUM/needs-judgment] Domain label maps duplicated verbatim across frontend files (and diverging)
|
||||
- **dimension:** config-structure
|
||||
- **where:** src/admin/utils/dashboardHelpers.ts:1-47; src/admin/pages/AuditLog.tsx:17-66; src/admin/utils/attendanceHelpers.ts:80-97; src/admin/components/ShiftFormModal.tsx:338-340
|
||||
- **problem:** ENTITY_TYPE_LABELS and ACTION_LABELS exist as near-identical copies in dashboardHelpers.ts and AuditLog.tsx, and have already diverged: ACTION_LABELS.create is 'Vytvoreni' in AuditLog but 'Vytvoril' in dashboardHelpers, and AuditLog's copy has extra entries (view, activate, deactivate, password_change, ...). Separately, leave-type labels (vacation->'Dovolena', sick->'Nemoc', unpaid->'Neplacene volno') are repeated in dashboardHelpers.ts:2-4, attendanceHelpers.ts:83-85, and inline <option> values in ShiftFormModal.tsx. There is no shared constants module, so these will keep drifting.
|
||||
- **fix:** Extract one constants module (e.g. src/admin/constants/labels.ts) holding ENTITY_TYPE_LABELS, ACTION_LABELS, LEAVE_TYPE_LABELS and import it everywhere; ideally co-locate with the shared EntityType list from the audit-labels finding.
|
||||
|
||||
### 7. [MEDIUM/safe-auto] Undefined CSS variable --danger-color used in base.css
|
||||
- **dimension:** css
|
||||
- **where:** src/admin/base.css:268
|
||||
- **problem:** .admin-error-stack sets color: var(--danger-color), but no file defines --danger-color (variables.css defines --danger, --error, --accent-color, etc., but never --danger-color). The property has no fallback, so the error-stack text color resolves to the inherited/initial color instead of the intended danger red. This is a copy-paste/naming bug, not an intentional choice.
|
||||
- **fix:** Change to color: var(--danger) (the intended token). Optionally add a --danger-color alias in variables.css if other call sites expect it, but a grep shows base.css:268 is the only consumer.
|
||||
|
||||
### 8. [MEDIUM/needs-judgment] Duplicated badge color recipe across 5 feature areas
|
||||
- **dimension:** css
|
||||
- **where:** src/admin/components.css:142; src/admin/components.css:160; src/admin/components.css:178; src/admin/invoices.css:5; src/admin/attendance.css:333
|
||||
- **problem:** The same status-badge formula — background: color-mix(in srgb, var(--X) 15%, transparent); color: var(--X) — is hand-repeated for every semantic color across leave badges (badge-pending/approved/rejected, components.css:142-152), order badges (admin-badge-order-*, :160-174), project badges (admin-badge-project-*, :178-188), invoice badges (admin-badge-invoice-*, invoices.css:5-17), and attendance leave badges (badge-vacation/sick/holiday, attendance.css:333-345). E.g. info-15% is duplicated 4x and danger-15% 4x. Worse, the recipe is inconsistent with the generic badges right above it: admin-badge-success/info/danger (components.css:106-128) use the --*-soft tokens instead of color-mix 15%, so two different visual recipes exist for the same semantic 'success/danger badge'.
|
||||
- **fix:** Introduce semantic helper classes (e.g. .admin-badge-tone-success/info/warning/danger/muted) defined once, and have the leave/order/project/invoice/attendance status classes compose them, or apply the tone class in TSX. Pick one recipe (either --*-soft or color-mix 15%) so the same status reads identically everywhere. This removes ~20 near-identical rules.
|
||||
|
||||
### 9. [MEDIUM/needs-judgment] "Today" form defaults use UTC date (new Date().toISOString().split("T")[0]) → off-by-one in late evening
|
||||
- **dimension:** dates
|
||||
- **where:** src/admin/pages/InvoiceDetail.tsx:329; src/admin/pages/InvoiceDetail.tsx:331; src/admin/pages/Attendance.tsx:143; src/admin/pages/Attendance.tsx:144; src/admin/pages/Attendance.tsx:337; src/admin/pages/Attendance.tsx:338; src/admin/pages/AttendanceCreate.tsx:49; src/admin/pages/Trips.tsx:56; src/admin/pages/Trips.tsx:120; src/admin/components/dashboard/DashQuickActions.tsx:77
|
||||
- **problem:** These default-date initializers compute today's date with new Date().toISOString().split("T")[0], which returns the UTC date. The whole app is intentionally Europe/Prague local time (config/env.ts), and Prague is always ahead of UTC. So between ~22:00–24:00 (summer) / ~23:00–24:00 (winter) local, UTC is still the previous day and the form pre-fills yesterday's date for trip_date, shift_date, leave date_from/date_to, invoice issue/tax dates. A user creating an attendance/trip/leave record late in the evening silently gets the wrong day.
|
||||
- **fix:** Replace with a local-date string built from local getters. A correct helper already exists server-side (utils/date.ts localDateStr); add/import an equivalent frontend helper, e.g. `const d=new Date(); const today=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}``, and use it for every "today" default. Note InvoiceDetail.tsx:330 due_date = new Date(Date.now()+14*86400000).toISOString()... has the same issue and should derive from the local issue_date instead.
|
||||
|
||||
### 10. [MEDIUM/needs-judgment] Edit forms round-trip API date strings through new Date().toISOString() instead of string slicing
|
||||
- **dimension:** dates
|
||||
- **where:** src/admin/pages/InvoiceDetail.tsx:481; src/admin/pages/InvoiceDetail.tsx:484; src/admin/pages/InvoiceDetail.tsx:487; src/admin/pages/InvoiceDetail.tsx:658; src/admin/pages/ReceivedInvoices.tsx:373-377
|
||||
- **problem:** When populating a date <input>/AdminDatePicker from an API value, these do new Date(inv.issue_date).toISOString().split("T")[0]. The API value is already a local-time string (Date.toJSON override drops the Z), so new Date() parses it as local, then toISOString() converts back to UTC. For @db.Date values (read back as UTC-midnight → local +1/+2h) this currently lands on the right day in Prague, but it is fragile: it depends on the offset always being positive and the time-of-day being near midnight. The codebase already has the correct, timezone-safe primitive — normalizeDateStr (src/admin/utils/attendanceHelpers.ts:309), which regex-slices the YYYY-MM-DD prefix off the string without ever constructing a Date.
|
||||
- **fix:** Use normalizeDateStr(inv.issue_date) / a string slice for date-only fields instead of routing through new Date().toISOString(). For computedDueDate (InvoiceDetail.tsx:654-659) the date-only UTC math is self-consistent, but switching to plain string/day arithmetic via the same helper removes the latent risk.
|
||||
|
||||
### 11. [MEDIUM/needs-judgment] Raw API datetime string assigned to a native <input type=date> value (mobile blanks the field)
|
||||
- **dimension:** dates
|
||||
- **where:** src/admin/pages/Trips.tsx:140; src/admin/components/AdminDatePicker.tsx:92-96
|
||||
- **problem:** openEditModal sets form.trip_date = trip.trip_date, but trip_date is a @db.Date serialized by the toJSON override as a full datetime string like "2026-06-05T02:00:00" (routes/admin/trips.ts returns the Prisma object directly). On desktop AdminDatePicker uses date-fns parse(val,"yyyy-MM-dd",...) which tolerates the trailing time, so it works. But on touch devices (AdminDatePicker.tsx:127-140) it renders <input type="date" value="2026-06-05T02:00:00">, which is not a valid date-input value — browsers show it blank, so the user appears to have no date and may submit an empty/changed value.
|
||||
- **fix:** Normalize before seeding the form: trip_date: normalizeDateStr(trip.trip_date). More robustly, have AdminDatePicker's NativeInput coerce its value to the date prefix for mode="date" so any caller passing a datetime string is handled.
|
||||
|
||||
### 12. [MEDIUM/needs-judgment] markOverdueInvoices flips invoices to "overdue" on their due date (date-only column vs full now())
|
||||
- **dimension:** dates
|
||||
- **where:** src/services/invoices.service.ts:78-85
|
||||
- **problem:** due_date is a @db.Date column (stored at UTC-midnight, e.g. 2026-06-06T00:00:00Z for "due 2026-06-06"). The query `due_date: { lt: new Date() }` compares it against the current full timestamp. Any time after local midnight on the due day, now() (e.g. 2026-06-06T06:00Z) is already greater than the stored UTC-midnight, so an invoice due *today* is immediately marked overdue. An invoice is normally not overdue until the day after its due date.
|
||||
- **fix:** Compare against local start-of-today, not now(): build today = new Date(y,m,d,0,0,0) from local getters and use `due_date: { lt: today }` (and the reverse branch `gte: today`). This makes the boundary inclusive of the due day. Confirm the intended business rule with the user (due-day inclusive vs exclusive) before changing.
|
||||
|
||||
### 13. [MEDIUM/needs-judgment] Three PDF route files duplicate the same helper set (escapeHtml, formatNum, formatCurrency, formatDate, cleanQuillHtml, DOMPurify bootstrap)
|
||||
- **dimension:** deadcode
|
||||
- **where:** src/routes/admin/invoices-pdf.ts:14; src/routes/admin/invoices-pdf.ts:19; src/routes/admin/invoices-pdf.ts:26; src/routes/admin/invoices-pdf.ts:35; src/routes/admin/invoices-pdf.ts:44; src/routes/admin/orders-pdf.ts:12; src/routes/admin/orders-pdf.ts:34; src/routes/admin/orders-pdf.ts:41; src/routes/admin/orders-pdf.ts:50; src/routes/admin/orders-pdf.ts:59; src/routes/admin/offers-pdf.ts:11; src/routes/admin/offers-pdf.ts:14; src/routes/admin/offers-pdf.ts:22; src/routes/admin/offers-pdf.ts:31; src/routes/admin/offers-pdf.ts:51; src/routes/admin/offers-pdf.ts:61
|
||||
- **problem:** All three PDF generation routes independently define near-identical formatDate, formatNum, escapeHtml, and cleanQuillHtml functions, plus the same `const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window);` bootstrap. The duplication has already caused a real divergence: orders-pdf.ts (formatNum, line 45) uses a regular ASCII space ' ' for the thousands separator, while invoices-pdf.ts (line 30) and offers-pdf.ts (line 26) use a non-breaking space ' '. This is exactly the failure mode duplication creates — the copies drift and produce inconsistent output (orders invoices can line-wrap mid-number where the others won't). cleanQuillHtml/escapeHtml are security-sensitive sanitizers, so divergence here is more than cosmetic.
|
||||
- **fix:** Extract the shared PDF helpers into a single module (e.g. src/utils/pdf-format.ts or extend src/utils/html-to-pdf.ts): escapeHtml, formatNum(value, decimals), formatCurrency, formatDate, cleanQuillHtml, and a shared DOMPurify instance. Import them in all three *-pdf.ts files. Pick one canonical thousands separator (the non-breaking space is correct for print) so output is consistent across invoice/order/offer PDFs.
|
||||
|
||||
### 14. [MEDIUM/safe-auto] English user-facing API error strings in attendance route
|
||||
- **dimension:** i18n
|
||||
- **where:** src/routes/admin/attendance.ts:188; src/routes/admin/attendance.ts:196
|
||||
- **problem:** Two error() responses send raw English to the client: error(reply, "Missing attendance_id", 400) and error(reply, "Missing id", 400). Every other validation/not-found message in this file and across all routes is Czech (e.g. "Záznam nenalezen", "Nedostatečná oprávnění"). error() sends the string straight into the API envelope, so a user hitting these endpoints without the param sees an English message.
|
||||
- **fix:** Replace with Czech equivalents, e.g. "Chybí ID docházky" and "Chybí ID" (or reuse the existing parseId/"Neplatné ID" pattern). Mechanical, no behavior change.
|
||||
|
||||
### 15. [MEDIUM/needs-judgment] Frontend query layer throws English errors that render in the UI
|
||||
- **dimension:** i18n
|
||||
- **where:** src/admin/lib/queries/projects.ts:96; src/admin/lib/queries/projects.ts:102; src/admin/lib/apiAdapter.ts:16; src/admin/lib/apiAdapter.ts:23; src/admin/lib/apiAdapter.ts:27; src/admin/lib/apiAdapter.ts:52; src/admin/lib/apiAdapter.ts:64; src/admin/lib/apiAdapter.ts:68; src/admin/lib/queries/mutations.ts:51; src/admin/lib/queries/mutations.ts:56; src/admin/components/ProjectFileManager.tsx:230
|
||||
- **problem:** The TanStack Query helpers throw new Error with English literals: "Unauthorized", "Invalid JSON response", and `Request failed (${status})`. ProjectFileManager.tsx:230-232 renders filesError.message verbatim (only falling back to the Czech "Nepodařilo se načíst soubory" when message is empty), so on a 401 or non-JSON/unknown-status response the user sees English ("Unauthorized", "Request failed (500)"). ErrorBoundary.tsx:30 likewise renders error.message under a Czech heading. The server-supplied result.error is Czech, but these client-side fallbacks are not. Note projects.ts:94 already does this right with the Czech "Chyba připojení", making the English siblings inconsistent.
|
||||
- **fix:** Czech-ify the user-visible fallbacks: "Unauthorized" -> e.g. "Přihlášení vypršelo" / "Nejste přihlášeni", "Request failed (n)" -> "Požadavek selhal (n)", "Invalid JSON response" -> "Neplatná odpověď serveru". These are user-facing message strings, not error codes, so translating them is safe; just confirm no code branches on the literal message text (none observed).
|
||||
|
||||
### 16. [MEDIUM/needs-judgment] Service-layer logging uses console.* instead of the configured Fastify/pino logger
|
||||
- **dimension:** logging
|
||||
- **where:** src/services/audit.ts:57; src/services/mailer.ts:34; src/services/invoices.service.ts:88; src/services/exchange-rates.ts:47; src/services/leave-notification.ts:120; src/services/invoice-alerts.ts:220; src/services/invoice-alerts.ts:224; src/services/plan.service.ts:168; src/utils/totp.ts:27; src/services/auth.ts:363
|
||||
- **problem:** server.ts:40-41 configures a pino logger (level 'warn' in production, 'info' in dev) and routes correctly use request.log.*/app.log.*. But every service/util logs via console.error/warn/log. These messages bypass pino's structured JSON output, log-level filtering (in production console.log/info still print regardless of the 'warn' level), and any future log transport/aggregation. CLAUDE.md's error-handling guidance explicitly shows app.log.error(e, 'context') as the pattern. invoice-alerts.ts:224 and plan.service.ts:168 in particular emit console.log/console.warn that will print in production despite the configured warn level.
|
||||
- **fix:** Standardize service logging on the Fastify logger. Since services are plain functions without app context, the cleanest fix is to pass the request/logger into service calls that can log, or export a shared logger instance (e.g. the pino instance from the app) that services import. The CLAUDE.md known-issue list already flags 'Mixed error patterns'; this is the logging analogue. If a full refactor is out of scope, at least demote informational console.log calls (invoice-alerts.ts:224) so they don't spam production stdout.
|
||||
|
||||
### 17. [MEDIUM/needs-judgment] markOverdueInvoices swallows DB failure and returns void, so the route treats failure as success
|
||||
- **dimension:** logging
|
||||
- **where:** src/services/invoices.service.ts:76; src/services/invoices.service.ts:87; src/routes/admin/invoices.ts:37
|
||||
- **problem:** markOverdueInvoices() wraps two updateMany calls in try/catch, logs to console.error on failure, and returns nothing. It is invoked from an onRequest hook (invoices.ts:33-39) on every GET (throttled hourly). Because the error is fully swallowed and not surfaced, a persistent DB failure to flip overdue statuses is invisible to the request path and only visible via console (which, per the previous finding, isn't even in the structured log). The throttled best-effort design is reasonable, but the failure should be logged through the Fastify logger that the hook has access to (request.log).
|
||||
- **fix:** Either keep the best-effort behavior but pass request.log into markOverdueInvoices (or log at the hook: wrap the await in try/catch and request.log.error(err, 'markOverdueInvoices failed')), so failures land in the structured production log instead of bare console.error. Do not change it to throw — that would break unrelated GET requests.
|
||||
|
||||
### 18. [MEDIUM/needs-judgment] camelCase 'plan*' files break the kebab-case convention of the backend src/ tree
|
||||
- **dimension:** naming
|
||||
- **where:** src/schemas/planCategory.schema.ts; src/services/planCategory.service.ts; src/utils/planAuditDescription.ts; src/utils/planAuditDescription.test.ts; src/services/planCategory.test.ts
|
||||
- **problem:** The backend src/ tree (excluding src/admin) is kebab-case for 94 of 99 .ts files. Sibling files in the same directories establish the convention clearly: src/schemas uses bank-accounts.schema.ts, received-invoices.schema.ts, scope-templates.schema.ts; src/services uses exchange-rates.ts, nas-file-manager.ts, invoice-alerts.ts; src/utils uses czech-holidays.ts, html-to-pdf.ts. The only 5 camelCase backend files are this 'plan' feature cluster (planCategory.schema.ts, planCategory.service.ts, planAuditDescription.ts plus two test files). Note the same feature's other files DO follow the convention (plan.schema.ts, plan.service.ts), so this is internal inconsistency within one feature, not a sub-convention.
|
||||
- **fix:** Rename to kebab-case to match the directory convention: plan-category.schema.ts, plan-category.service.ts, plan-audit-description.ts (and matching .test.ts files), updating their import paths. Mechanical but touches importers, so verify references after renaming.
|
||||
|
||||
### 19. [MEDIUM/needs-judgment] createInvoice header and items not atomic
|
||||
- **dimension:** prisma
|
||||
- **where:** src/services/invoices.service.ts:345-388
|
||||
- **problem:** invoices.create then a separate invoice_items.createMany with no transaction; a failure leaves a header without items plus a consumed sequence. updateInvoice 469-485, createOrder, createOffer all wrap header and children in a transaction.
|
||||
- **fix:** Wrap both writes in one transaction; move generateInvoiceNumber inside it.
|
||||
|
||||
### 20. [MEDIUM/needs-judgment] N1 in getBelowMinimumItems per-item aggregate in a loop
|
||||
- **dimension:** prisma
|
||||
- **where:** src/services/warehouse.service.ts:232-261; src/services/warehouse.service.ts:199-205
|
||||
- **problem:** getItemTotalStock with item.id called inside the loop; each runs its own sklad_batches aggregate, N1 round trips scaling with the catalog.
|
||||
- **fix:** One sklad_batches groupBy on item_id with is_consumed false summing quantity; build an id to total map; filter in memory.
|
||||
|
||||
### 21. [MEDIUM/manual] useAttendanceAdmin hook bypasses TanStack Query and hand-rolls fetching (with silent catches + race condition)
|
||||
- **dimension:** react
|
||||
- **where:** src/admin/hooks/useAttendanceAdmin.ts:818; src/admin/hooks/useAttendanceAdmin.ts:837; src/admin/hooks/useAttendanceAdmin.ts:866; src/admin/hooks/useAttendanceAdmin.ts:915
|
||||
- **problem:** The whole AttendanceAdmin data layer loads projects, users, attendance records and leave balances with manual apiFetch inside useEffect + useState, instead of the TanStack Query pattern the rest of the app standardizes on (lib/queries/*). Consequences: (a) no caching/dedupe/refetch story consistent with other pages; (b) the load effects swallow errors with empty `catch { /* silent */ }` blocks (lines 827-829, 856-858), which also violates the project's 'never silently swallow errors' rule; (c) fetchData (line 866) re-runs on every month/filterUserId change with no abort or ignore flag, so a slow earlier response can overwrite a newer one — exactly the race the React docs warn about for effect-based fetching. The kept eslint-disable on the deps (line 909) is a symptom of fighting the linter rather than modeling this as a query.
|
||||
- **fix:** Migrate these loads to useQuery options in src/admin/lib/queries/attendance.ts (projectListOptions, attendanceUsersOptions, attendance records keyed by [month,filterUserId], balances keyed by year). That removes all four effects, the manual loading state, the silent catches, and the race condition for free. If a full migration is out of scope now, at minimum add an `ignore`/AbortController guard in fetchData and log the caught errors via the alert/log path instead of empty catches.
|
||||
|
||||
### 22. [MEDIUM/needs-judgment] Array index used as React key on editable, mutable lists
|
||||
- **dimension:** react
|
||||
- **where:** src/admin/components/OrderConfirmationModal.tsx:247; src/admin/pages/OfferDetail.tsx:1500
|
||||
- **problem:** Both lists render rows with controlled inputs and support remove/add (and OfferDetail also reorders) yet key by index. OrderConfirmationModal: items.map((item,i)=><tr key={i}>) with updateItem(i,...) and removeItem via prev.filter((_,i)=>i!==index) (line 88). OfferDetail: sections.map((section,idx)=><div key={idx}>) with delete via prev.filter((_,i)=>i!==idx) (line 1594) and swap-reorder (lines 1540/1567). Per React docs, indices as keys on a list whose order/length changes cause React to reuse the wrong DOM node and component state — here that means after deleting a row the input values/focus shift to the wrong row. This is a genuine correctness bug, not a style nit.
|
||||
- **fix:** Key by a stable id. ConfirmationItem/ScopeSection should carry a stable client id (e.g. crypto.randomUUID() assigned when the row is created, or the server item id when present) and use key={item.id}. Read-only, non-reordering tables (Warehouse recentMovements, WarehouseReports rows) can keep index keys but ideally use the row's domain id.
|
||||
|
||||
### 23. [MEDIUM/needs-judgment] Inconsistent modal implementation: FormModal/ConfirmModal vs hand-rolled overlays with diverging accessibility
|
||||
- **dimension:** react
|
||||
- **where:** src/admin/components/FormModal.tsx:88; src/admin/components/dashboard/DashProfile.tsx:266; src/admin/components/dashboard/DashProfile.tsx:405; src/admin/components/dashboard/DashProfile.tsx:660; src/admin/components/dashboard/DashQuickActions.tsx:321; src/admin/components/BulkAttendanceModal.tsx:47; src/admin/components/ShiftFormModal.tsx:252; src/admin/components/OrderConfirmationModal.tsx:108; src/admin/pages/WarehouseLocations.tsx:339; src/admin/pages/Attendance.tsx:936
|
||||
- **problem:** There are two good reusable modal primitives (FormModal handles body-scroll lock, Esc-to-close, role=dialog/aria-modal/aria-labelledby, focus-grouped footer; ConfirmModal similar), but at least 7 components reimplement the admin-modal-overlay + backdrop + motion markup by hand with inconsistent behavior. DashProfile's three modals (lines 266/405/660) and DashQuickActions (line 321) omit role="dialog", aria-modal, and aria-labelledby entirely; none of the hand-rolled overlays wire Esc-to-close (only FormModal/ConfirmModal do). BulkAttendanceModal/ShiftFormModal/OrderConfirmationModal include the ARIA roles but still lack Esc handling. The result is duplicated markup and an accessibility/UX behavior that varies modal-to-modal.
|
||||
- **fix:** For the smaller content modals (DashProfile edit-profile, 2FA setup/disable, DashQuickActions trip modal) switch to FormModal — they fit its API and would inherit Esc/lock/ARIA. For the large complex forms (ShiftFormModal, BulkAttendanceModal, OrderConfirmationModal) where FormModal's per-child stagger animation is awkward, factor the overlay+backdrop+Esc+ARIA shell into a shared ModalShell component and have these consume it, so a11y/Esc behavior is uniform. At minimum, add role="dialog"/aria-modal/aria-labelledby to the DashProfile/DashQuickActions overlays.
|
||||
|
||||
### 24. [MEDIUM/safe-auto] Raw reply.send({ success: true, ... }) used instead of success()/paginated() helpers
|
||||
- **dimension:** routes
|
||||
- **where:** src/routes/admin/attendance.ts:32; src/routes/admin/attendance.ts:108; src/routes/admin/attendance.ts:118; src/routes/admin/attendance.ts:128; src/routes/admin/attendance.ts:142; src/routes/admin/attendance.ts:165; src/routes/admin/attendance.ts:179; src/routes/admin/attendance.ts:190; src/routes/admin/attendance.ts:203; src/routes/admin/attendance.ts:235; src/routes/admin/trips.ts:73; src/routes/admin/leave-requests.ts:57; src/routes/admin/invoices.ts:62; src/routes/admin/projects.ts:41; src/routes/admin/received-invoices.ts:80; src/routes/admin/customers.ts:100
|
||||
- **problem:** CLAUDE.md explicitly states: 'Use the success() and error() helpers in routes — never write raw reply.send().' Many handlers hand-roll the envelope. The paginated-list cases (attendance.ts:235, trips.ts:73, leave-requests.ts:57, invoices.ts:62, projects.ts:41, received-invoices.ts:80, customers.ts:100) duplicate exactly what paginated(reply, data, meta) produces; the single-object cases (attendance.ts:32,108,118,...) duplicate success(reply, data). This is purely a consistency/maintainability gap (the shapes happen to match today), but it bypasses the central helper, so any future change to the response envelope or status handling won't propagate.
|
||||
- **fix:** Replace the raw `return reply.send({ success: true, data, pagination })` calls with `return paginated(reply, data, buildPaginationMeta(total, page, limit))`, and `return reply.send({ success: true, data })` with `return success(reply, data)`. attendance.ts and customers.ts already import these helpers; trips/leave-requests/invoices/projects/received-invoices import paginated where needed.
|
||||
|
||||
### 25. [MEDIUM/needs-judgment] Session-termination mutations have no audit logging
|
||||
- **dimension:** routes
|
||||
- **where:** src/routes/admin/sessions.ts:80-99; src/routes/admin/sessions.ts:102-127
|
||||
- **problem:** DELETE /sessions/:id (revoke a specific refresh token) and DELETE /sessions?action=all (revoke all other sessions) mutate security-relevant state — they invalidate refresh tokens — but call no logAudit(). CLAUDE.md and the database conventions say data that is created/updated/deleted should be audit-logged, and these are exactly the kind of security events (forced logout / session revocation) that belong in the forensic trail. Every other mutating route in the module logs an audit entry; these two are the notable omissions among security-sensitive endpoints.
|
||||
- **fix:** Add logAudit() calls after the successful update/updateMany in both handlers (e.g. action 'delete' or 'logout', entityType 'session' or 'refresh_token', entityId the session id for the single case), mirroring the audit pattern used in totp.ts and auth.ts for login/logout events.
|
||||
|
||||
### 26. [MEDIUM/needs-judgment] Two competing mutation patterns coexist; ~27 raw apiFetch mutations bypass useApiMutation
|
||||
- **dimension:** rq-data
|
||||
- **where:** src/admin/lib/queries/mutations.ts:88; src/admin/pages/WarehouseReservations.tsx:106; src/admin/pages/WarehouseReservations.tsx:136; src/admin/pages/Invoices.tsx:208; src/admin/pages/Invoices.tsx:234; src/admin/pages/Offers.tsx:222; src/admin/pages/OfferDetail.tsx:734; src/admin/pages/OfferDetail.tsx:757; src/admin/pages/OfferDetail.tsx:782; src/admin/hooks/useAttendanceAdmin.ts:1039
|
||||
- **problem:** The project ships a dedicated useApiMutation hook (mutations.ts) that wraps useMutation + apiFetch + envelope unwrapping + broad invalidation, and it is adopted in 34 files. But 14 files (~27 call sites) still hand-roll the same flow: `const response = await apiFetch(url,{method:...}); const result = await response.json(); if(result.success){ queryClient.invalidateQueries(...); alert.success(...) } else { alert.error(...) } } catch { alert.error('Chyba připojení') }`. This duplicates exactly what useApiMutation centralizes (error throwing as ApiError, success/error branching, invalidation), and the two paths diverge in behavior: useApiMutation throws typed ApiError with .status so callers can branch on HTTP code, whereas the raw path collapses all failures into a generic Czech 'Chyba připojení' string and never exposes status. This is the dominant inconsistency in the data layer. TanStack v5 docs document the useMutation→onSuccess→invalidateQueries flow that useApiMutation implements; the raw path is the non-idiomatic outlier.
|
||||
- **fix:** Standardize new and touched mutations on useApiMutation (pass `invalidate: ["warehouse"]` etc. instead of manual invalidateQueries, and use mutate/mutateAsync's onSuccess/onError for UI side-effects). Migrate the raw-apiFetch mutation sites incrementally, starting with the simplest CRUD pages (WarehouseReservations, Invoices delete/toggleStatus). Document useApiMutation as the canonical mutation entry point in CLAUDE.md's Frontend Conventions so the pattern stops regrowing.
|
||||
|
||||
### 27. [MEDIUM/safe-auto] Broad-domain invalidation convention violated: OfferDetail uses narrow ["offers","list"]
|
||||
- **dimension:** rq-data
|
||||
- **where:** src/admin/pages/OfferDetail.tsx:681; src/admin/pages/OfferDetail.tsx:693; src/admin/pages/OfferDetail.tsx:739; src/admin/pages/OfferDetail.tsx:765; src/admin/pages/OfferDetail.tsx:792; src/admin/pages/Offers.tsx:227
|
||||
- **problem:** CLAUDE.md mandates invalidating the broad domain key (`["offers"]` over `["offers","list"]`) because React Query prefix-matches. OfferDetail.tsx invalidates the narrow key `["offers","list"]` in five handlers (save edit/create, create order, invalidate, delete), while the sibling Offers.tsx (line 227) and CompanySettings.tsx (line 437) correctly invalidate broad `["offers"]`. Consequence: the narrow key does NOT prefix-match the offer-detail cache entry `["offers", id]` (offerDetailOptions, offers.ts:144) nor `["offers","next-number"]`, so after editing an offer the detail query for OTHER offers / next-number stays stale until refetched by other means. The TanStack v5 docs' prefix-match example (`invalidateQueries({queryKey:['projects']})`) is the exact rationale CLAUDE.md cites — OfferDetail is the lone violator.
|
||||
- **fix:** Change the five `["offers","list"]` invalidations in OfferDetail.tsx to broad `["offers"]` to match the convention and the rest of the offers code. (The delete handler additionally removeQueries(["offers",id]) which is fine to keep.) Mechanical and behavior-safe: broadening only marks more inactive queries stale; active ones already refetch.
|
||||
|
||||
### 28. [MEDIUM/manual] useAttendanceAdmin runs a parallel data system instead of query factories / useApiMutation
|
||||
- **dimension:** rq-data
|
||||
- **where:** src/admin/hooks/useAttendanceAdmin.ts:1049; src/admin/hooks/useAttendanceAdmin.ts:1050; src/admin/hooks/useAttendanceAdmin.ts:1121; src/admin/hooks/useAttendanceAdmin.ts:1256; src/admin/hooks/useAttendanceAdmin.ts:1286
|
||||
- **problem:** This 1200+ line hook does its own data orchestration: each mutation does `queryClient.invalidateQueries(["attendance"])` AND `await fetchData(false)` AND `await new Promise(r=>setTimeout(r,300))` to wait for the manual refetch (e.g. lines 1049-1052, 1121-1123). It runs a hand-rolled `fetchData` alongside React Query rather than relying on the attendance.ts queryOptions factories and letting invalidation drive refetch. The fixed 300ms sleep is a race-condition smell (it guesses how long the refetch takes) and the double invalidate+fetchData duplicates work. This is the single largest divergence from the otherwise-consistent data layer.
|
||||
- **fix:** Treat as a larger refactor (out of scope for safe-auto): migrate the hook's reads to the attendance.ts queryOptions factories and its writes to useApiMutation so invalidation alone drives refetch, removing fetchData and the setTimeout(300). At minimum, drop the redundant manual fetchData+sleep where invalidateQueries already covers it. Flag for a dedicated cleanup ticket.
|
||||
|
||||
### 29. [MEDIUM/needs-judgment] Three coercion idioms duplicated ~150+ times instead of shared helpers
|
||||
- **dimension:** schemas
|
||||
- **where:** src/schemas/common.ts (only has parseBody); src/schemas/invoices.schema.ts:5-33; src/schemas/orders.schema.ts:6-27; src/schemas/warehouse.schema.ts:85-219; src/schemas/attendance.schema.ts:19-145; src/schemas/settings.schema.ts:15-62; src/schemas/trips.schema.ts:4-37; src/schemas/vehicles.schema.ts:8-39; src/schemas/bank-accounts.schema.ts:14-34; src/schemas/received-invoices.schema.ts:5-60
|
||||
- **problem:** The same three sub-schemas are copy-pasted across nearly every file: (1) number-from-form `z.union([z.number(), z.string()]).transform((v) => Number(v))`, (2) its nullable form `z.union([z.number(), z.string(), z.null()]).transform((v) => (v === null ? null : Number(v)))`, and (3) boolean-from-form `z.preprocess((v) => v === true || v === 1 || v === '1', z.boolean())`. There are 150+ occurrences. common.ts only exports parseBody — no shared id/number/bool/date primitives exist, even though plan.schema.ts (intFromForm/isoDate) and planCategory.schema.ts (hexColor) already prove the team knows how to factor these out locally. This is the single biggest source of drift: any fix to coercion behavior must be applied in dozens of places.
|
||||
- **fix:** Add shared exports to src/schemas/common.ts, e.g. `numFromForm`, `nullableNumFromForm`, `boolFromForm`, `idFromForm`, `isoDate`, and import them everywhere. Zod 4 (in use here, v4.3.6) provides `z.coerce.number()` and `z.stringbool()` which replace idioms (1) and (3) outright; per Zod 4 docs `z.stringbool()` throws on unrecognized strings (safer than the silent preprocess). Consolidating also lets you fix the NaN bug (next finding) in one spot.
|
||||
|
||||
### 30. [MEDIUM/needs-judgment] Number coercion silently produces NaN (no validation guard in most files)
|
||||
- **dimension:** schemas
|
||||
- **where:** src/schemas/trips.schema.ts:4,12-13; src/schemas/vehicles.schema.ts:8-17,29-34; src/schemas/invoices.schema.ts:26-29,48-52,89-92; src/schemas/settings.schema.ts:15-62; src/schemas/attendance.schema.ts:20-37,90-129; src/schemas/received-invoices.schema.ts:5-6,35-60; src/schemas/bank-accounts.schema.ts:14-18,31-34
|
||||
- **problem:** Most `.transform((v) => Number(v))` coercions have no follow-up refine, so a non-numeric string parses to `NaN` and passes validation. e.g. CreateTripSchema vehicle_id/start_km/end_km (trips.schema.ts:4,12,13) accept garbage as NaN; settings break_threshold_hours, clock_rounding_minutes, max_login_attempts etc. all coerce to NaN silently. Notably this is applied INCONSISTENTLY: orders.schema.ts and offers.schema.ts DO add `.refine((v) => !Number.isNaN(v), ...)` after every numeric transform, and invoices.schema.ts:8-12 throws inside the transform for quantity — so the same project has three different behaviors for the identical concern. NaN then flows into Prisma/business logic.
|
||||
- **fix:** Standardize on one safe numeric primitive (ideally the shared helper from the previous finding) that rejects NaN — e.g. `z.coerce.number()` which natively errors on non-numeric input, or `.refine((v) => !Number.isNaN(v))`. Apply it uniformly so trips/vehicles/settings/attendance match the stricter orders/offers behavior.
|
||||
|
||||
### 31. [MEDIUM/needs-judgment] Email validation applied inconsistently across schemas
|
||||
- **dimension:** schemas
|
||||
- **where:** src/schemas/users.schema.ts:5,21; src/schemas/profile.schema.ts:4; src/schemas/customers.schema.ts (no email field, but); src/schemas/warehouse.schema.ts:30,42; src/schemas/settings.schema.ts:38-41
|
||||
- **problem:** Email fields are validated as proper emails only in users.schema.ts and profile.schema.ts via `z.string().email(...)`. Other schemas that accept email-typed data use plain `z.string().nullish()` with no format check: warehouse supplier `email` (warehouse.schema.ts:30,42), and company-settings `invoice_alert_email`, `leave_notify_email`, `smtp_from` (settings.schema.ts:38-40). The notify/alert emails in settings are used to actually send mail, so an invalid value is a silent operational failure.
|
||||
- **fix:** Apply email-format validation to the email-typed fields in warehouse and settings schemas (e.g. `z.string().email(...).nullish()`, allowing empty/null). Note: Zod 4 prefers the top-level `z.email()` over the now-deprecated `z.string().email()`; consider migrating the existing users/profile usages too for consistency.
|
||||
|
||||
### 32. [MEDIUM/needs-judgment] Services return error with no status, forcing routes to hardcode or cast the HTTP code
|
||||
- **dimension:** svc-errors
|
||||
- **where:** src/services/attendance.service.ts:376; src/routes/admin/attendance.ts:50; src/routes/admin/projects.ts:73; src/routes/admin/quotations.ts:286
|
||||
- **problem:** Many service functions return an error object with NO status field, while the documented convention is error plus status. Routes compensate inconsistently: attendance hardcodes the status at the call site (400 at attendance.ts:50, 404 at :70 for the same shape), and projects/quotations cast result.status with as-any. The same conceptual error yields 400 from one route and 404 from another by call-site choice, not service intent.
|
||||
- **fix:** Make every error path return error plus status. Then collapse route call sites to the uniform check already used by plan.ts, removing the as-any casts.
|
||||
|
||||
### 33. [MEDIUM/needs-judgment] String-sentinel error values decoded by routes instead of self-describing error plus status
|
||||
- **dimension:** svc-errors
|
||||
- **where:** src/services/invoices.service.ts:393; src/services/offers.service.ts:231; src/services/projects.service.ts:149; src/routes/admin/invoices.ts:165
|
||||
- **problem:** updateInvoice, deleteProject and updateOffer return machine-codes as the error string (not_found, has_order, invalidated) without a status. The Czech message and HTTP status are reconstructed in the route via if/else chains ending in a fall-through 500 Neznama chyba. This splits one entity error vocabulary across two files and is easy to desync.
|
||||
- **fix:** Return the final Czech message plus status directly, as updateOrder/createOffer already do. The route then needs only the uniform one-line check and the 500 fall-throughs disappear.
|
||||
|
||||
### 34. [MEDIUM/needs-judgment] Duplicated and contradictory `PaginationMeta` / pagination shape (3 definitions)
|
||||
- **dimension:** ts-safety
|
||||
- **where:** src/types/index.ts:85; src/admin/lib/apiAdapter.ts:33; src/admin/hooks/usePaginatedQuery.ts:8
|
||||
- **problem:** The pagination response shape is declared three times with diverging fields. The server type `PaginationMeta` (types/index.ts:85) has `page, limit, per_page, total, total_pages`. The frontend `PaginationMeta` (apiAdapter.ts:33) drops `limit` entirely (`total, page, per_page, total_pages`). A third inline `PaginatedResult.pagination` (usePaginatedQuery.ts:8-16) re-declares the same fields again. These are independent definitions that can drift; the missing `limit` on the frontend copy means a consumer expecting the server contract is silently incompatible.
|
||||
- **fix:** Pick one canonical `PaginationMeta` (the server one in types/index.ts is the contract) and have the frontend import/derive from it, or at minimum collapse apiAdapter.ts and usePaginatedQuery.ts onto a single shared interface so the `limit` discrepancy is resolved and can't drift.
|
||||
|
||||
### 35. [MEDIUM/needs-judgment] Missing return types on ~47 of 107 exported service functions force `as any` casts in routes
|
||||
- **dimension:** ts-safety
|
||||
- **where:** src/services/projects.service.ts:95; src/routes/admin/projects.ts:73; src/routes/admin/projects.ts:106; src/routes/admin/quotations.ts:286; src/services/users.service.ts:59; src/services/offers.service.ts:161; src/services/invoices.service.ts:339
|
||||
- **problem:** Roughly 47 of 107 exported `async function`s in src/services have no explicit return type and rely on inference. For functions like `updateProject` (projects.service.ts:95) the inferred type is a union of `null | { error, status } | <prismaRow>`; after `"error" in result` narrowing the row branch still has no `status`, so route handlers reach for `(result as any).status ?? 400` (projects.ts:73, projects.ts:106, quotations.ts:286) to read a field the compiler can't guarantee. The `any` cast erases all type checking on the result at exactly the error-handling boundary. The standard `{ data } | { error, status }` service contract documented in CLAUDE.md is not enforced because the return type is left implicit.
|
||||
- **fix:** Annotate service functions with an explicit discriminated return type (e.g. a shared `ServiceResult<T> = { data: T } | { error: string; status: number }`, or `Result<T>` like plan.service.ts:387 already defines). With a uniform `status` on the error branch, the `(result as any).status` casts in routes can be deleted and become type-safe. This is the systemic root cause of the route-level casts.
|
||||
|
||||
### 36. [MEDIUM/needs-judgment] Heavy `any` in usePlanWork mutations plus mutation-object monkey-patching (`(createEntry as any)._rolled`)
|
||||
- **dimension:** ts-safety
|
||||
- **where:** src/admin/hooks/usePlanWork.ts:235; src/admin/hooks/usePlanWork.ts:241; src/admin/hooks/usePlanWork.ts:261; src/admin/hooks/usePlanWork.ts:273; src/admin/hooks/usePlanWork.ts:322; src/admin/hooks/usePlanWork.ts:355; src/admin/hooks/usePlanWork.ts:388; src/admin/hooks/usePlanWork.ts:430; src/admin/hooks/usePlanWork.ts:457; src/admin/hooks/usePlanWork.ts:478
|
||||
- **problem:** The plan-work optimistic-update hook is the single densest `any` site in the frontend (20 occurrences). Mutation `mutationFn`/`onSuccess` bodies and vars are typed `any` (e.g. `(body: any)`, `(data: any, body: any)`), and rollback state is stored by mutating the mutation object: `(createEntry as any)._rolled = rolled` (and 5 more `_rolled` writes). `qc.getQueryData<GridData>(currentGridKey as any)` also casts the typed query key away. This defeats type safety on the optimistic-update path where cell-patching correctness matters most, and the `_rolled` side-channel is invisible to the type system, so a typo in the property name would silently break rollback.
|
||||
- **fix:** Type the mutation bodies with the request DTOs (the plan entry/override create/update shapes already exist in schemas/ and lib/queries/plan.ts) and `onSuccess` data with the API response type. Replace the `(mutation as any)._rolled` side-channel with React Query's `onMutate`/context return value (the supported, fully-typed rollback mechanism), which removes the casts.
|
||||
|
||||
### 37. [LOW/safe-auto] Configured @/* path alias is unused and not wired into the Vite/vitest build
|
||||
- **dimension:** config-structure
|
||||
- **where:** tsconfig.app.json:19-22; tsconfig.server.json:16-19; vite.config.ts:4-32; vitest.config.ts:5-14
|
||||
- **problem:** Both tsconfig.app.json and tsconfig.server.json declare baseUrl:'.' with paths {'@/*':['src/*']}, but a search for imports from '@/' across src returns zero matches — the alias is dead config. Worse, if someone did adopt it, vite.config.ts and vitest.config.ts define no matching resolve.alias, so TypeScript would resolve it but the Vite client build and Vitest would fail at runtime. The codebase consistently uses relative imports instead.
|
||||
- **fix:** Either remove the baseUrl/paths block from both tsconfigs to avoid implying an alias convention the bundler does not support, or commit to it by adding the matching resolve.alias in vite.config.ts/vitest.config.ts and migrating imports. Removal is the lower-risk option given current usage.
|
||||
|
||||
### 38. [LOW/needs-judgment] package.json db:push / db:pull scripts contradict the documented migration golden rule
|
||||
- **dimension:** config-structure
|
||||
- **where:** package.json:16-17; CLAUDE.md
|
||||
- **problem:** CLAUDE.md states the golden rule 'NEVER use prisma db push' (it bypasses migration history and causes drift), yet package.json exposes "db:push": "prisma db push" (and "db:pull": "prisma db pull") as first-class npm scripts, which invites exactly the prohibited workflow. The documented schema-change path is prisma migrate dev, which has no script alias.
|
||||
- **fix:** Remove the db:push (and likely db:pull) scripts, or rename/guard them so they can't be run casually; add a db:migrate script wrapping `prisma migrate dev` to match the documented workflow.
|
||||
|
||||
### 39. [LOW/needs-judgment] Cross-boundary import of a server util by the client is inconsistent and structurally fragile
|
||||
- **dimension:** config-structure
|
||||
- **where:** src/admin/utils/attendanceHelpers.ts:1; src/admin/hooks/useAttendanceAdmin.ts:4; src/utils/czech-holidays.ts:1-8; tsconfig.server.json:21-30
|
||||
- **problem:** src/admin code imports getHolidays/isHoliday from the server-side util src/utils/czech-holidays via '../../utils/czech-holidays'. This is the ONLY place server logic is genuinely shared with the client; every other shared concern (formatters, label maps, EntityType) is duplicated instead, so the sharing strategy is inconsistent. It also reaches across the build boundary: tsconfig.server.json compiles src/utils into the server bundle while Vite separately bundles the same file into the client. It works today only because czech-holidays has no Node-only dependencies (it imports just ./date). If czech-holidays ever pulls in a Node API (fs, crypto, prisma), the client build breaks silently.
|
||||
- **fix:** Decide on one strategy: either introduce a dedicated src/shared/ (or src/common/) module for genuinely isomorphic helpers like czech-holidays and date formatters, referenced by both builds with an explicit boundary, or duplicate intentionally. Avoid ad-hoc src/admin -> src/utils relative imports that silently couple the two bundles.
|
||||
|
||||
### 40. [LOW/safe-auto] .env.example has drifted from the variables actually read in config/env.ts
|
||||
- **dimension:** config-structure
|
||||
- **where:** .env.example:1-32; src/config/env.ts:55-110
|
||||
- **problem:** config/env.ts reads several env vars absent from .env.example: TOTP_ALGORITHM, TOTP_DIGITS, TOTP_PERIOD, LOGIN_TOKEN_EXPIRY_MINUTES, NAS_FINANCIALS_PATH, NAS_OFFERS_PATH, SMTP_FROM_NAME, INVOICE_ALERT_EMAIL, RATE_LIMIT_MAX/WINDOW/LOGIN/TOTP/REFRESH, and TRUST_PROXY. All have defaults so nothing breaks, but the example file no longer documents the full configuration surface, making prod tuning (rate limits, trust proxy) undiscoverable.
|
||||
- **fix:** Add the missing optional variables (with their default values as comments) to .env.example so it stays a complete reference for config/env.ts.
|
||||
|
||||
### 41. [LOW/manual] Inconsistent badge/class naming scheme (admin- prefix dropped)
|
||||
- **dimension:** css
|
||||
- **where:** src/admin/components.css:142; src/admin/components.css:146; src/admin/components.css:150; src/admin/components.css:154; src/admin/attendance.css:333; src/admin/invoices.css:24; src/admin/invoices.css:70
|
||||
- **problem:** Class naming is inconsistent within the single global namespace. Most badges use admin-badge-* (admin-badge-order-prijata, admin-badge-invoice-paid), but the leave-status badges in components.css:142-154 are bare badge-pending/badge-approved/badge-rejected/badge-cancelled, and attendance.css reuses bare badge-vacation/sick/holiday/unpaid. invoices.css mixes three schemes in one file: admin-badge-invoice-* (:5), invoice-month-* (:24), and received-upload-* (:70). Because all CSS is global, these bare badge-* names risk collision and make the convention ambiguous (admin-* for shared vs feature-prefix for page-local).
|
||||
- **fix:** Document and enforce one rule: shared/reusable components use admin-*, page-local elements use a feature prefix (already done for plan-*, dash-*, fm-*). Rename bare badge-* to admin-badge-* (or feature-prefix them) — coordinate the matching className strings in the TSX. Not safe-auto because it requires synchronized TSX edits.
|
||||
|
||||
### 42. [LOW/needs-judgment] Defined-but-unused CSS variables (dead tokens)
|
||||
- **dimension:** css
|
||||
- **where:** src/admin/variables.css:27; src/admin/variables.css:34; src/admin/variables.css:14; src/admin/variables.css:15; src/admin/variables.css:26
|
||||
- **problem:** Several tokens are declared but never referenced anywhere in *.css: --gradient-subtle (variables.css:27), --transition-slow (:34), --space-10 (:14) and --space-12 (:15). Additionally --gradient (:26) is used only once (attendance.css:220) and is just a duplicate literal of --accent-color (#d63031), so it is a redundant alias rather than a real gradient token. These are dead/redundant and add noise to the token system.
|
||||
- **fix:** Remove the unused tokens, or wire them up if intended. For --gradient, either point attendance.css:220 at var(--accent-color) and delete --gradient, or rename it to something meaningful. Verify against TSX inline styles before deleting (grep showed no CSS consumers).
|
||||
|
||||
### 43. [LOW/needs-judgment] Empty responsive.css stub imported into the bundle
|
||||
- **dimension:** css
|
||||
- **where:** src/admin/responsive.css:1; src/admin/AdminApp.tsx:22
|
||||
- **problem:** responsive.css contains only a 6-line comment ('reserved for responsive rules that span multiple components') and no rules, yet is imported at AdminApp.tsx:22. All responsive media queries actually live in their per-feature files (forms.css, layout.css, components.css, etc.), so the file is dead. It is harmless but misleading — a reader may expect cross-cutting breakpoints to live here.
|
||||
- **fix:** Either delete the file and its import, or actually consolidate the repeated breakpoints here. Note there is no shared breakpoint token: 480/640/768/1024 recur across ~15 files as raw px (e.g. forms.css:293-312, components.css, layout.css). If kept, this file is the natural place to at least document the canonical breakpoint set.
|
||||
|
||||
### 44. [LOW/safe-auto] plan.css imported twice
|
||||
- **dimension:** css
|
||||
- **where:** src/admin/AdminApp.tsx:26; src/admin/pages/PlanWork.tsx:18
|
||||
- **problem:** plan.css is imported globally in AdminApp.tsx:26 (alongside all other stylesheets) and again in PlanWork.tsx:18. Since AdminApp already loads it globally, the second import is redundant. The bundler de-dupes so there is no runtime double-application, but it is inconsistent — no other page re-imports its own CSS (e.g. WarehousePage does not re-import warehouse.css), so this is an outlier that suggests uncertainty about the loading model.
|
||||
- **fix:** Remove the import at PlanWork.tsx:18 to match the convention that all admin CSS is loaded once in AdminApp.tsx. Low risk since the global import already covers it.
|
||||
|
||||
### 45. [LOW/needs-judgment] getCzechDate week number uses a non-ISO local formula that disagrees with the plan module's ISO week
|
||||
- **dimension:** dates
|
||||
- **where:** src/admin/utils/dashboardHelpers.ts:75-78; src/admin/pages/PlanWork.tsx:61-69
|
||||
- **problem:** The dashboard header (getCzechDate) computes "Týden N" with a homegrown formula: Math.ceil(((now - Jan1)/86400000 + Jan1.getDay() + 1)/7) using local getters. This is not ISO-8601 (ISO weeks start Monday; week 1 is the week containing the first Thursday) and is also locale-dependent via getDay(). PlanWork.tsx:61-69 implements a correct ISO-8601 week (Thursday-anchored, UTC). For dates near year boundaries the two will display different week numbers for the same day, an internal inconsistency users can notice.
|
||||
- **fix:** Extract PlanWork's isoWeekNumber into a shared util and reuse it in getCzechDate so the week number is computed one consistent (ISO) way across the UI.
|
||||
|
||||
### 46. [LOW/needs-judgment] Two divergent conventions for writing @db.Date columns (UTC-midnight vs local-noon)
|
||||
- **dimension:** dates
|
||||
- **where:** src/routes/admin/trips.ts:234; src/routes/admin/trips.ts:294; src/services/invoices.service.ts:362-364; src/services/attendance.service.ts:1176; src/services/attendance.service.ts:1227-1234
|
||||
- **problem:** Date-only (@db.Date) columns are written two different ways. Trips and invoices do new Date(String(body.x)) where body.x is "YYYY-MM-DD" → UTC-midnight. Attendance constructs new Date(year,month,day,12,0,0) → local noon. Both currently store the correct date in Prague only because the local offset is always positive and 12:00 local is comfortably inside the day in UTC. The local-noon form is the robust one (immune to offset sign and DST edges); the UTC-midnight form is the fragile one. Mixing them is an inconsistency that invites a future off-by-one if anyone copies the wrong pattern or the deployment TZ ever changes.
|
||||
- **fix:** Standardize on one convention for @db.Date writes — prefer the local-noon construction (or a single shared helper like dateOnly("YYYY-MM-DD")) and use it in trips/invoices too. Behavior is unchanged for Prague today, so this is a consistency/robustness cleanup, not an urgent fix.
|
||||
|
||||
### 47. [LOW/needs-judgment] escapeHtml is defined six times across the codebase
|
||||
- **dimension:** deadcode
|
||||
- **where:** src/routes/admin/invoices-pdf.ts:35; src/routes/admin/orders-pdf.ts:50; src/routes/admin/offers-pdf.ts:51; src/services/invoice-alerts.ts:18; src/services/leave-notification.ts:21; src/admin/hooks/useAttendanceAdmin.ts:341
|
||||
- **problem:** There are six separate definitions of an escapeHtml(str) function. Five escape &,<,>," identically; the sixth (useAttendanceAdmin.ts:341) additionally escapes the single quote ('). They are used for HTML email bodies, PDF templates, and print HTML — all contexts where consistent escaping matters. The subtle difference (one escapes the apostrophe, the others don't) is the kind of inconsistency that hides XSS/output bugs.
|
||||
- **fix:** Move escapeHtml into a single shared util (server-side src/utils, plus one for the frontend, or share via a common module). Standardize on the stricter variant that also escapes the single quote. Replace the six local copies with imports.
|
||||
|
||||
### 48. [LOW/safe-auto] formatDate duplicated between two frontend util files
|
||||
- **dimension:** deadcode
|
||||
- **where:** src/admin/utils/formatters.ts:20; src/admin/utils/attendanceHelpers.ts:24
|
||||
- **problem:** formatters.ts exports `formatDate(dateStr)` => `d.toLocaleDateString('cs-CZ')` with a '—' fallback, and attendanceHelpers.ts exports an identical `formatDate` (same body, same '—' fallback). Both are imported across the app, so two names resolve to the same logic in different modules — a maintenance hazard if one is changed.
|
||||
- **fix:** Keep the canonical formatDate in formatters.ts and have attendanceHelpers re-export or import it instead of redefining. Update attendanceHelpers consumers to the shared one.
|
||||
|
||||
### 49. [LOW/safe-auto] Dead export: previewPattern() in numbering.service.ts
|
||||
- **dimension:** deadcode
|
||||
- **where:** src/services/numbering.service.ts:343
|
||||
- **problem:** `previewPattern(pattern, prefix, code)` is exported but has zero references anywhere in src (verified by grep across .ts/.tsx). It is the only public function in the numbering service with no callers; all sibling preview/generate functions are used.
|
||||
- **fix:** Remove previewPattern, or wire it into the settings UI/route that previews a numbering pattern if that was its intent. As-is it is dead code.
|
||||
|
||||
### 50. [LOW/safe-auto] Dead export: usersQuery + planKeys.users() in plan query module
|
||||
- **dimension:** deadcode
|
||||
- **where:** src/admin/lib/queries/plan.ts:111; src/admin/lib/queries/plan.ts:95
|
||||
- **problem:** `usersQuery()` (plan.ts:111) is exported but never imported or used by any component/page. Its query key `planKeys.users()` (plan.ts:95) is referenced only by usersQuery itself. The `/api/admin/plan/users` endpoint string appears only inside this dead query (the PlanWork grid gets its users from the grid payload instead). So the frontend query, its key entry, and the `users: () => ['plan','users']` key are an orphaned cluster. (The backend route src/routes/admin/plan.ts:63 still exists and was not assessed as dead from the server side.)
|
||||
- **fix:** Remove usersQuery and the planKeys.users key entry. Separately confirm whether the backend GET /plan/users route still has any consumer; if not, it can be removed too.
|
||||
|
||||
### 51. [LOW/needs-judgment] Dead export: getMonthlyWorkFund() in czech-holidays.ts
|
||||
- **dimension:** deadcode
|
||||
- **where:** src/utils/czech-holidays.ts:95
|
||||
- **problem:** `getMonthlyWorkFund(...)` is exported but has zero references across src (verified by grep). Sibling exports getHolidays/isHoliday/getBusinessDaysInMonth are all used; this one is not.
|
||||
- **fix:** Remove getMonthlyWorkFund, or use it where monthly work-fund hours are currently computed inline (e.g. attendance.service.ts computes fund hours as `getBusinessDaysInMonth(...) * 8` in several places — that inline math is what this helper appears intended to replace).
|
||||
|
||||
### 52. [LOW/needs-judgment] Unused file: ReservationPicker.tsx
|
||||
- **dimension:** deadcode
|
||||
- **where:** src/admin/components/warehouse/ReservationPicker.tsx:11
|
||||
- **problem:** The whole file (54 lines, default export ReservationPicker) has no importers anywhere in the project — grep for 'ReservationPicker' returns only self-references inside the file. The sibling warehouse pickers (ItemPicker, BatchPicker, SupplierSelect, LocationSelect) are all imported and used; this one is an orphan.
|
||||
- **fix:** Delete src/admin/components/warehouse/ReservationPicker.tsx, or wire it into the warehouse issue/reservation flow if it was intended for an unfinished feature.
|
||||
|
||||
### 53. [LOW/safe-auto] Inconsistent Czech phrasing for "not found" (nenalezen vs nebyl nalezen)
|
||||
- **dimension:** i18n
|
||||
- **where:** src/routes/admin/project-files.ts:61; src/routes/admin/project-files.ts:73; src/routes/admin/project-files.ts:92; src/routes/admin/project-files.ts:112; src/routes/admin/project-files.ts:165; src/routes/admin/project-files.ts:228; src/routes/admin/project-files.ts:270; src/services/warehouse.service.ts:967; src/services/warehouse.service.ts:1021; src/routes/admin/projects.ts:56
|
||||
- **problem:** Two phrasings express the same concept. The dominant, compact form "nenalezen/nenalezena/nenalezeno" appears ~92 times across routes/services; the verbose form "nebyl/nebyla nalezen*" appears ~16 times (all of project-files.ts plus warehouse.service.ts reservations/inventory). The clash is sometimes on the identical entity: projects.ts:56 "Projekt nenalezen" vs project-files.ts:61 "Projekt nebyl nalezen"; warehouse routes "Kategorie nenalezena"/"Dodavatel nenalezen" vs warehouse.service.ts "Rezervace nebyla nalezena"/"Inventarizace nebyla nalezena". Same product, two wordings for the same 404.
|
||||
- **fix:** Standardize on the dominant compact form ("Projekt nenalezen", "Soubor nenalezen", "Složka nenalezena", "Rezervace nenalezena", "Inventarizace nenalezena"). Pure string normalization, no logic change. Low priority polish.
|
||||
|
||||
### 54. [LOW/needs-judgment] parseBody leaks default English Zod messages for validators without a custom message
|
||||
- **dimension:** i18n
|
||||
- **where:** src/schemas/common.ts:12; src/schemas/received-invoices.schema.ts:27; src/schemas/attendance.schema.ts:25; src/schemas/leave-requests.schema.ts:4; src/schemas/offers.schema.ts:92; src/schemas/orders.schema.ts:93; src/schemas/attendance.schema.ts:8; src/schemas/plan.schema.ts:42; src/schemas/auth.schema.ts:26
|
||||
- **problem:** parseBody (common.ts:12) joins each Zod issue's .message and returns it directly as the user-facing API error. Fields with explicit Czech messages (e.g. .min(1, "Název je povinný")) are fine, but validators relying on Zod defaults emit English to the user: z.enum([...]) without a message -> "Invalid enum value..."; .max(500) on note/address -> "String must contain at most 500 character(s)"; TOTP secret/code .min(1) (auth.schema.ts:26-33) -> "String must contain at least 1 character(s)". Most are hard for a normal user to trigger (enum values come from dropdowns), but the length caps on free-text note/address fields are reachable.
|
||||
- **fix:** Add Czech messages to the user-reachable validators (the free-text .max() caps and any enum a user could submit), or centralize a Czech errorMap on the Zod instance so defaults are Czech globally. Lower urgency for purely internal enums/tokens. Treat as needs-judgment because deciding which validators are user-reachable requires per-field review.
|
||||
|
||||
### 55. [LOW/safe-auto] Best-effort frontend background calls swallow errors silently with .catch(() => {})
|
||||
- **dimension:** logging
|
||||
- **where:** src/admin/pages/Attendance.tsx:235; src/admin/pages/Attendance.tsx:238; src/admin/pages/InvoiceDetail.tsx:778
|
||||
- **problem:** Three fire-and-forget calls (reverse-geocode address update, and post-save PDF pre-generation) use '.catch(() => {})' with no logging. These are genuinely non-critical side effects, so failing silently is a defensible product choice, but the empty handler gives no breadcrumb when the address never updates or the PDF isn't pre-generated. Unlike the rest of the frontend, which consistently surfaces errors via alert.error with a Czech fallback, these are invisible.
|
||||
- **fix:** Add a console.warn (or a debug-level log) in the catch so the swallow is intentional-and-traceable rather than fully silent, e.g. '.catch((e) => console.warn("address update failed", e))'. No user-facing alert is warranted for these background ops.
|
||||
|
||||
### 56. [LOW/needs-judgment] plan.ts query factories use '*Query' suffix instead of the established '*Options' convention
|
||||
- **dimension:** naming
|
||||
- **where:** src/admin/lib/queries/plan.ts:98; src/admin/lib/queries/plan.ts:111
|
||||
- **problem:** Across src/admin/lib/queries, 55 query factories use the '*Options' suffix (userListOptions, orderListOptions, attendanceBalancesOptions, planCategoriesOptions, etc.). plan.ts itself uses planCategoriesOptions correctly, but then defines gridQuery (line 98) and usersQuery (line 111) with a '*Query' suffix instead. These are the only two '*Query'-suffixed factories in the whole queries directory, so the names read as a different kind of thing than they are (they are queryOptions() factories identical in shape to the *Options ones).
|
||||
- **fix:** Rename gridQuery -> planGridOptions and usersQuery -> planUsersOptions for parity with the surrounding 55 *Options factories. Both are used inside the same module/feature, so the blast radius is small.
|
||||
|
||||
### 57. [LOW/needs-judgment] '.service.ts' suffix applied to only half of service files, with two borderline cases
|
||||
- **dimension:** naming
|
||||
- **where:** src/services/exchange-rates.ts; src/services/system-settings.ts; src/services/numbering.service.ts
|
||||
- **problem:** Of 20 files in src/services, exactly 10 carry the '.service.ts' suffix and 10 do not. The split largely tracks a sensible distinction (entity CRUD services like invoices.service.ts / users.service.ts are suffixed; infra/integration helpers like mailer.ts, audit.ts, nas-*-manager.ts, leave-notification.ts are not), so this is not pure randomness. However the boundary is fuzzy: exchange-rates.ts (exports getRate/toCzk) and system-settings.ts (exports getSystemSettings) are read/query services that resemble the suffixed group but lack the suffix, while numbering.service.ts is suffixed despite being a low-level sequence helper. A reader cannot rely on the suffix to predict a file's role.
|
||||
- **fix:** Document the intended rule in CLAUDE.md (e.g. '*.service.ts = entity business-logic services; bare name = infra/integration helpers') and rename the borderline files to match it, OR drop the suffix entirely since the directory name 'services/' already conveys the role. Either way pick one rule; do not leave it implicit.
|
||||
|
||||
### 58. [LOW/needs-judgment] queryKey helper naming: only 'plan' defines a 'planKeys' key-factory object; other domains inline keys
|
||||
- **dimension:** naming
|
||||
- **where:** src/admin/lib/queries/plan.ts:87
|
||||
- **problem:** plan.ts introduces a dedicated planKeys object (all/grid/entries/overrides/users) to centralize query keys, which is a good pattern. But it is the only domain in src/admin/lib/queries that does so; every other module (users.ts, orders.ts, invoices.ts, trips.ts, etc.) inlines its queryKey arrays directly inside each *Options factory. The result is two coexisting key-management styles with no documented reason, so a contributor cannot tell which pattern to follow when adding a query.
|
||||
- **fix:** Decide on one approach: either adopt the planKeys-style key-factory object per domain (more robust against typos and easier invalidation), or treat planKeys as a deliberate exception and note it. Low priority — purely a consistency/discoverability issue, no behavior impact.
|
||||
|
||||
### 59. [LOW/needs-judgment] getInvoiceStats walks the dataset four times with sequential per-invoice currency conversion
|
||||
- **dimension:** prisma
|
||||
- **where:** src/services/invoices.service.ts:247-256; src/services/invoices.service.ts:284-297
|
||||
- **problem:** sumCzk awaits toCzk per invoice in a loop, called three times plus a fourth VAT loop; serializes many awaits. Cached so it is await overhead not network.
|
||||
- **fix:** Pre-fetch the rate map once and convert synchronously, or resolve conversions together. Low priority.
|
||||
|
||||
### 60. [LOW/needs-judgment] getBalances runs a sequential leave_balances query per user
|
||||
- **dimension:** prisma
|
||||
- **where:** src/services/attendance.service.ts:490-504; src/services/attendance.service.ts:944-946
|
||||
- **problem:** Loops users running leave_balances findFirst each; getPrintData already uses one findMany for the year then a map, so this is inconsistent.
|
||||
- **fix:** One findMany filtered by user_id in the list and year; index by user_id.
|
||||
|
||||
### 61. [LOW/needs-judgment] enrich and body handlers typed as any, dropping Prisma types
|
||||
- **dimension:** prisma
|
||||
- **where:** src/services/orders.service.ts:41; src/services/offers.service.ts:49; src/services/invoices.service.ts:339; src/services/invoices.service.ts:391; src/services/projects.service.ts:95
|
||||
- **problem:** enrichOrder and enrichQuotation and several handlers take any, losing the types Prisma generates for includes and mapped rows; code otherwise strict TS, deriving a getPayload type at attendance.service.ts 23-28.
|
||||
- **fix:** Type enrich inputs with the Prisma getPayload helper; tighten bodies to existing input interfaces.
|
||||
|
||||
### 62. [LOW/needs-judgment] Server data synced into form state via useEffect instead of derived/key-reset (accepted-but-not-ideal)
|
||||
- **dimension:** react
|
||||
- **where:** src/admin/pages/InvoiceDetail.tsx:371; src/admin/pages/InvoiceDetail.tsx:454; src/admin/pages/OfferDetail.tsx:383; src/admin/pages/OfferDetail.tsx:428; src/admin/pages/CompanySettings.tsx:256; src/admin/pages/Settings.tsx:213
|
||||
- **problem:** Multiple pages copy query data into local form state inside an effect, guarded by a one-shot ref/flag (formInitializedRef / dataReady / sysFormInitialized) or a derived-default merge (InvoiceDetail:371, OfferDetail:383 adjust currency/vat from companySettings on prop change). The React 'You Might Not Need an Effect' guidance explicitly lists 'adjusting/resetting state on prop change in an Effect' as an anti-pattern (causes an extra render with stale data) and recommends either computing during render or resetting state with a `key`. The flag-guarded population effects are the commonly-accepted compromise for editable forms seeded from server data, so this is low severity, but it is the same family of effect the docs steer away from.
|
||||
- **fix:** Where feasible, prefer resetting form state by giving the editor a key (e.g. key={id} on the detail component) so a fresh mount re-seeds state without an effect, and compute the companySettings-derived defaults (currency/vat) during render rather than patching state in an effect. Treat as cleanup, not a blocker — the current guards prevent the worst (re-clobbering user edits).
|
||||
|
||||
### 63. [LOW/manual] 2FA/profile state prop-drilled through DashProfile (18 props)
|
||||
- **dimension:** react
|
||||
- **where:** src/admin/components/dashboard/DashProfile.tsx:10; src/admin/components/dashboard/DashProfile.tsx:40
|
||||
- **problem:** DashProfile receives 18 props, ~14 of which are a single concern: the 2FA enrollment/disable flow state and its setters (totpEnabled, totpLoading, totpSubmitting, totpSecret, totpQrUri, totpCode/setTotpCode, backupCodes/setBackupCodes, show2FASetup/setShow2FASetup, show2FADisable/setShow2FADisable, disableCode/setDisableCode, plus onStart/onConfirm/onDisable callbacks). This is all owned by the Dashboard parent and threaded down, making the component signature brittle and the 2FA logic split across two files.
|
||||
- **fix:** Encapsulate the 2FA flow in a dedicated hook (e.g. use2FA()) colocated with DashProfile, or move the 2FA modals fully into DashProfile and have it own that state. The parent then passes only totpEnabled (or nothing). This is a maintainability refinement, not a bug.
|
||||
|
||||
### 64. [LOW/safe-auto] Minor: derived-state effect in ItemPicker resets activeIndex via useEffect
|
||||
- **dimension:** react
|
||||
- **where:** src/admin/components/warehouse/ItemPicker.tsx:48
|
||||
- **problem:** The effect at line 48 watches [items, activeIndex] to reset the highlighted index when the result list shrinks. This is derived state maintained through an effect (the docs' 'you might not need an effect' territory) and introduces an extra render cycle; it also lists activeIndex in deps while setting it, which is benign here only because of the guard. Low impact — the component otherwise correctly uses refs for keyboard nav.
|
||||
- **fix:** Clamp activeIndex during render (e.g. const safeActive = activeIndex >= items.length ? -1 : activeIndex) or reset it in the same handler that changes the search/items rather than reacting in an effect. Optional cleanup.
|
||||
|
||||
### 65. [LOW/safe-auto] Inconsistent numeric ID parsing — raw parseInt instead of the parseId helper
|
||||
- **dimension:** routes
|
||||
- **where:** src/routes/admin/trips.ts:192-193; src/routes/admin/trips.ts:264-265; src/routes/admin/trips.ts:338-339; src/routes/admin/sessions.ts:84-85
|
||||
- **problem:** response.ts exports parseId(raw, reply) specifically to centralize param-id parsing (it rejects NaN and id<=0 with a uniform 400 'Neplatné ID'). Most files use it, but trips.ts and sessions.ts hand-roll `parseInt(...,10)` + `isNaN` checks. Besides the duplication, the hand-rolled versions accept id <= 0 (e.g. 0 or negative) because they only check isNaN, whereas parseId also rejects id <= 0 — a subtle behavioral divergence. Error messages also differ ('Neplatné ID vozidla', 'Neplatné ID relace' vs the helper's 'Neplatné ID').
|
||||
- **fix:** Replace the parseInt/isNaN blocks with `const id = parseId(request.params.id, reply); if (id === null) return;` (trips.ts uses request.params.id / vehicleId; keep a tailored message only if the distinct wording is desired). This unifies the <=0 rejection and the 400 response shape.
|
||||
|
||||
### 66. [LOW/needs-judgment] Several not-found / precondition errors in project-files.ts fall back to default 400 instead of an explicit status
|
||||
- **dimension:** routes
|
||||
- **where:** src/routes/admin/project-files.ts:68; src/routes/admin/project-files.ts:69; src/routes/admin/project-files.ts:88; src/routes/admin/project-files.ts:114; src/routes/admin/project-files.ts:124; src/routes/admin/project-files.ts:138; src/routes/admin/project-files.ts:179; src/routes/admin/project-files.ts:202; src/routes/admin/project-files.ts:229; src/routes/admin/project-files.ts:241; src/routes/admin/project-files.ts:279
|
||||
- **problem:** Within the same file, downloadFile-not-found correctly returns error(reply, 'Soubor nebyl nalezen', 404) (line 73) and listFiles folder-not-found returns 404 (line 92), but the parallel 'Projekt nemá číslo projektu' (88,114,229), required-path messages (68,279), and fm-returned-error passthroughs (138,202,241) all omit the status and silently use the helper's default 400. Most are genuinely validation/precondition (400 is fine), but 'Projekt nemá číslo projektu' is a state precondition inconsistently a 400 here vs config-missing cases returning 500, and the omitted statuses make intent unclear. Consistency/clarity issue, not a correctness bug.
|
||||
- **fix:** Pass an explicit status to each error() call so the intended HTTP semantics are visible and uniform (400 for client validation, 404/409/422 for state preconditions as appropriate). At minimum make the 'Projekt nemá číslo projektu' and required-path messages explicit rather than relying on the default.
|
||||
|
||||
### 67. [LOW/safe-auto] Redundant non-null assertions on parseBody result (body.data!) in plan.ts
|
||||
- **dimension:** routes
|
||||
- **where:** src/routes/admin/plan.ts:170; src/routes/admin/plan.ts:194; src/routes/admin/plan.ts:238-242; src/routes/admin/plan.ts:267; src/routes/admin/plan.ts:319; src/routes/admin/plan.ts:361
|
||||
- **problem:** plan.ts writes `const body = parseBody(...); if ('error' in body) return ...; ... body.data!`. parseBody returns `{ data: T } | { error: string }` (schemas/common.ts:3-6), so after the `'error' in body` guard TypeScript already narrows body to `{ data: T }` and `.data` is non-null. Every other route file uses the cleaner `const parsed = parseBody(...); if ('error' in parsed) return ...; const body = parsed.data;` form without the `!`. The `!` is harmless but inconsistent and signals (falsely) that data might be nullable.
|
||||
- **fix:** Adopt the prevailing pattern: bind `const parsed = parseBody(...)`, guard, then `const body = parsed.data` without the non-null assertion, matching customers.ts / warehouse.ts / attendance.ts.
|
||||
|
||||
### 68. [LOW/needs-judgment] usePlanWork re-implements the gridQuery factory inline with untyped apiFetch parsing
|
||||
- **dimension:** rq-data
|
||||
- **where:** src/admin/hooks/usePlanWork.ts:87; src/admin/lib/queries/plan.ts:98
|
||||
- **problem:** plan.ts exports a `gridQuery(dateFrom,dateTo,view)` queryOptions factory (line 98) that uses jsonQuery<GridData> for type-safe envelope handling. usePlanWork.ts:87 ignores it and inlines its own useQuery with `apiFetch(...).then(r=>r.json().then((b:any)=>b.data as GridData))` — duplicating the URL construction, skipping jsonQuery's `!response.ok || !result.success` error check (so a backend `{success:false}` silently yields undefined data instead of throwing), and using `b:any`. The planKeys factory is reused for the key, but the queryFn diverges. This is the only place a query bypasses the apiAdapter.
|
||||
- **fix:** Replace the inline useQuery body with the existing gridQuery factory: `useQuery({ ...gridQuery(isoDate(range.from), isoDate(range.to), view), placeholderData: keepPreviousData })`. Restores jsonQuery's error handling and removes the `any`. Low risk but should be eyeballed since the hook also reads this query's cache key elsewhere (key is unchanged, so cache stays compatible).
|
||||
|
||||
### 69. [LOW/safe-auto] Duplicate require2FAOptions factory (dead copy in dashboard.ts) and unused deprecated systemSettingsOptions alias
|
||||
- **dimension:** rq-data
|
||||
- **where:** src/admin/lib/queries/dashboard.ts:12; src/admin/lib/queries/settings.ts:82; src/admin/lib/queries/settings.ts:80
|
||||
- **problem:** `require2FAOptions` is defined identically in two query files — dashboard.ts:12 and settings.ts:82 — both with the same key `["settings","2fa"]` and same URL. Both consumers (Dashboard.tsx:10, Settings.tsx:14) import the settings.ts copy, so the dashboard.ts copy is dead code. Because keys match it is not a cache-collision bug, but two sources of truth for one query is a maintenance hazard (edit one, miss the other). Separately, settings.ts:80 exports a `/** @deprecated */ systemSettingsOptions = systemInfoOptions` alias that has no importers (only systemInfoOptions is used).
|
||||
- **fix:** Delete the duplicate require2FAOptions from dashboard.ts:12 (keep the settings.ts canonical one). Delete the unused deprecated systemSettingsOptions alias at settings.ts:80. Both are confirmed unreferenced by grep, so removal is mechanical and behavior-free.
|
||||
|
||||
### 70. [LOW/needs-judgment] Inconsistent filter-key serialization across list query factories
|
||||
- **dimension:** rq-data
|
||||
- **where:** src/admin/lib/queries/invoices.ts:129; src/admin/lib/queries/invoices.ts:157; src/admin/lib/queries/trips.ts:40; src/admin/lib/queries/warehouse.ts:194; src/admin/lib/queries/projects.ts:45
|
||||
- **problem:** List factories are inconsistent in how the filters object becomes part of the query key. Most spread the raw filters object directly (invoices.ts:129 `["invoices","list",filters]`; warehouse, projects, offers, audit-log do the same), but trips.ts:40 and invoices received (invoices.ts:157) instead spell out an explicit object literal of the same fields. Functionally both work (React Query hashes the key deterministically and ignores object key order), but the inconsistency is gratuitous: the explicit form silently drops any new filter field that isn't manually listed, whereas the spread form auto-includes it. Not a bug today, just a divergence and a latent footgun for the spelled-out ones.
|
||||
- **fix:** Pick one convention (spreading the filters object is the simpler, more future-proof choice) and apply it uniformly. Low priority — cosmetic consistency, no behavior change. Verify each endpoint's filters object contains only key-relevant fields before spreading.
|
||||
|
||||
### 71. [LOW/needs-judgment] Wrong TOTP code does not increment failed-login attempts (relies on rate limit only)
|
||||
- **dimension:** security
|
||||
- **where:** src/routes/admin/auth.ts:160-163; src/services/system-settings.ts
|
||||
- **problem:** In the /login/totp handler, an invalid TOTP code returns 'Neplatný TOTP kód' (line 161-163) without touching failed_login_attempts or locked_until. The password stage (auth.ts service) and the backup-code endpoint (totp.ts:315-335) both implement lockout, but the primary TOTP step does not. The only protection against TOTP brute force after a valid login_token is the per-minute rate limit (config.rateLimit.loginTotp, default 5/min). The login_token lives 5 minutes and a fresh one can be re-minted by re-submitting the password, so a determined attacker who has the password gets a sustained ~5 guesses/minute against a 6-digit (1M) space without ever tripping account lockout.
|
||||
- **fix:** On invalid TOTP code in /login/totp, increment failed_login_attempts inside a transaction and set locked_until when it reaches max_login_attempts, mirroring the backup-verify logic. This makes the second factor enforce the same lockout policy as the first.
|
||||
|
||||
### 72. [LOW/needs-judgment] Server-side PDF endpoints return attacker-influenceable HTML as same-origin text/html
|
||||
- **dimension:** security
|
||||
- **where:** src/routes/admin/invoices-pdf.ts:1076; src/routes/admin/offers-pdf.ts:764; src/routes/admin/invoices-pdf.ts:521; src/routes/admin/offers-pdf.ts:357
|
||||
- **problem:** In non-save mode, the invoices-pdf and offers-pdf routes render the full document HTML and send it with reply.type('text/html').send(html) directly to the browser (not application/pdf). The notes/scope content is user-controlled rich text. It IS sanitized (DOMPurify.sanitize wrapped by cleanQuillHtml), so this is defense-in-depth rather than an open hole, but serving same-origin HTML built from user data is fragile: any future regression in the sanitizer, or any unescaped interpolation added to these large templates, becomes stored XSS on the app origin. The production CSP (security.ts:24-36) does mitigate inline script, but these PDF responses are served by the same Fastify app and inherit that origin.
|
||||
- **fix:** Prefer returning application/pdf (render via htmlToPdf) to the client instead of raw HTML, as the orders-pdf route already does (orders-pdf.ts:889-892). If an HTML preview must be served, consider a Content-Disposition or a sandboxed delivery path. At minimum, keep the DOMPurify + cleanQuillHtml layering and avoid adding any non-escapeHtml interpolation of DB/user fields into these templates.
|
||||
|
||||
### 73. [LOW/needs-judgment] Scope/notes rich-text is sanitized only at render time, never on write
|
||||
- **dimension:** security
|
||||
- **where:** src/routes/admin/invoices-pdf.ts:521; src/routes/admin/orders-pdf.ts:409; src/admin/pages/InvoiceDetail.tsx:1206; src/admin/pages/OrderDetail.tsx:687
|
||||
- **problem:** Invoice notes and quotation/order scope HTML are stored raw in the DB and sanitized at every read/render site (3 PDF routes + 2 frontend render sites). This is consistent and currently complete, but it means correctness depends on every present and future consumer remembering to sanitize. Any new feature that renders these fields (e.g. an email body, a new export, an admin preview) without DOMPurify reintroduces stored XSS. Sanitizing on write would make the stored value safe-by-default for all consumers.
|
||||
- **fix:** Consider sanitizing rich-text scope/notes once on the write path (in the quotations/orders/invoices create+update services or Zod transforms) so stored data is already clean, treating render-time sanitization as defense-in-depth. Not urgent given current coverage is complete; flagging so the invariant is documented and enforced centrally.
|
||||
|
||||
### 74. [LOW/needs-judgment] HSTS header omits preload and is gated entirely on APP_ENV=production
|
||||
- **dimension:** security
|
||||
- **where:** src/middleware/security.ts:18-22; src/middleware/security.ts:23-36
|
||||
- **problem:** Strict-Transport-Security is set to 'max-age=31536000; includeSubDomains' without 'preload', and both HSTS and the full CSP are only emitted when config.isProduction (APP_ENV==='production'). The max-age/includeSubDomains is good, but without preload the first-visit TLS-strip window remains. More notably, all CSP protection is absent in any non-production deployment (e.g. a staging box on real DNS), so the same app code runs without script-src/frame-ancestors restrictions there.
|
||||
- **fix:** Add 'preload' to the HSTS value if the domain is (or will be) submitted to the preload list. Consider emitting at least a baseline CSP and the security headers in non-local non-production environments too, so staging/preprod is not left unprotected.
|
||||
|
||||
### 75. [LOW/needs-judgment] TOTP backup-code comparison does not stop at first match (minor timing/clarity)
|
||||
- **dimension:** security
|
||||
- **where:** src/routes/admin/totp.ts:307-312
|
||||
- **problem:** The backup-code verify loop runs bcrypt.compare against every stored hash and records matchIndex but never breaks, so it always performs all 8 comparisons. This is intentional-looking constant-time behavior, but the loop also lets a later match overwrite an earlier one and does an unnecessary 8x bcrypt work per attempt. With bcryptCost=12, 8 comparisons per submission under a 5/min rate limit is a small but real CPU amplification, and the non-break pattern is easy to misread. The CLAUDE.md error-handling rule is not violated here; this is a robustness nit.
|
||||
- **fix:** Either document that the full-scan is deliberate constant-time, or break on first match. Given backup codes are unique random values, breaking on match is safe and reduces bcrypt load; combine with the existing lockout to keep brute-force resistance.
|
||||
|
||||
### 76. [LOW/safe-auto] Error-message language mixed Czech/English within schemas
|
||||
- **dimension:** schemas
|
||||
- **where:** src/schemas/orders.schema.ts:9,16,26,37,45,71,81,106; src/schemas/offers.schema.ts:9,16,26,37,56,87; src/schemas/invoices.schema.ts:10,19 (thrown Error strings)
|
||||
- **problem:** Project convention is Czech for user-facing messages. Most schemas follow this, but orders.schema.ts and offers.schema.ts mix English `{ message: 'Must be a valid number' }` (orders:9,16,26,37,71,81,106; offers:9,16,26,37,56,87) alongside Czech `'Musí být platné číslo'` (orders:55,60; offers:47) in the SAME file for the same validation. parseBody surfaces issue.message directly to the client (common.ts:12), so end users can see the English strings. invoices.schema.ts:10,19 also throw English `'Invalid quantity'`/`'Invalid unit_price'`.
|
||||
- **fix:** Translate the English validation messages to Czech to match the rest of the codebase (e.g. 'Musí být platné číslo'). If a shared numeric helper is introduced (finding 1), it can carry one canonical Czech message and eliminate the divergence.
|
||||
|
||||
### 77. [LOW/safe-auto] Inline route schemas use Zod-3-deprecated .strict()/.passthrough()
|
||||
- **dimension:** schemas
|
||||
- **where:** src/routes/admin/audit-log.ts:15-20; .strict(); src/routes/admin/orders-pdf.ts:15-30; .passthrough()
|
||||
- **problem:** The only two object-mode modifiers in the codebase use the Zod 3 forms `.strict()` (audit-log.ts:20) and `.passthrough()` (orders-pdf.ts:30). Per the Zod 4 changelog (the project is on zod ^4.3.6) these are deprecated in favor of the top-level `z.strictObject({...})` and `z.looseObject({...})`. They still work but are flagged for removal and are inconsistent with a Zod-4 codebase.
|
||||
- **fix:** Migrate to `z.strictObject({ days: ..., confirm: ... })` and `z.looseObject({ items: ... })` respectively. Mechanical, no behavior change.
|
||||
|
||||
### 78. [LOW/needs-judgment] No schemas use strict object mode — unknown keys silently stripped everywhere
|
||||
- **dimension:** schemas
|
||||
- **where:** src/schemas/common.ts:8 (schema.parse, default strip); all src/schemas/*.schema.ts object definitions
|
||||
- **problem:** Every body schema is a default `z.object(...)`, which strips unknown keys silently. Only audit-log.ts uses `.strict()`. For mutation endpoints this means a client typo (e.g. `is_activ` instead of `is_active`) is silently dropped rather than rejected, masking client bugs. This is a deliberate-tradeoff call (lenient APIs are sometimes intentional), so flagged as low, but the near-total absence of strictness combined with the broad Create/Update surface is worth a conscious decision rather than an accident.
|
||||
- **fix:** Decide on a policy: either keep lenient (document it) or adopt `z.strictObject` for Create/Update bodies to catch client mistakes early. If adopting strict, do it via the shared pattern so it is uniform; watch for fields the frontend sends but the schema omits (audit before flipping).
|
||||
|
||||
### 79. [LOW/needs-judgment] Duplicated ScopeSection/Item sub-schemas across offers/orders/scope-templates
|
||||
- **dimension:** schemas
|
||||
- **where:** src/schemas/offers.schema.ts:3-39 (QuotationItemSchema, ScopeSectionSchema); src/schemas/orders.schema.ts:3-39 (OrderItemSchema, OrderSectionSchema); src/schemas/scope-templates.schema.ts:3-11 (ScopeSectionSchema); src/schemas/invoices.schema.ts:3-34 (InvoiceItemSchema)
|
||||
- **problem:** QuotationItemSchema (offers:3-28) and OrderItemSchema (orders:3-28) are byte-for-byte identical. OrderSectionSchema (orders:30-39), ScopeSectionSchema (offers:30-39) and ScopeSectionSchema (scope-templates:3-11) are the same shape (title/title_cz/content/position) duplicated three times. These line/quote/section structures are the same domain concept reimplemented per file, so a change to e.g. position validation must be made in 3-4 places.
|
||||
- **fix:** Extract a shared `LineItemSchema` and `ScopeSectionSchema` (e.g. in common.ts or a schemas/shared.ts) and import them. Note invoices.schema.ts:6-12 deliberately diverges (throws on quantity<=0), so keep that one specialized or make the shared schema configurable.
|
||||
|
||||
### 80. [LOW/needs-judgment] buildApp and token helpers duplicated instead of reusing helpers.ts
|
||||
- **dimension:** tests part2b
|
||||
- **where:** src/__tests__/helpers.ts:7; src/__tests__/warehouse.test.ts:26; src/__tests__/plan.test.ts:649
|
||||
- **problem:** helpers.ts exports buildApp for auth and totp routes only. warehouse.test.ts and plan.test.ts each redefine their own buildApp, generateToken, and auth header helpers with slightly different setup, since warehouse adds securityHeaders and helpers does not. The token-minting and app-bootstrap logic now lives in three places and will drift, and a token minted wrong in one file is not caught by the others.
|
||||
- **fix:** Extract a shared buildApp that takes routes and options plus one token and auth-header helper into helpers.ts and import everywhere, then re-run the full suite since it changes test bootstrap.
|
||||
|
||||
### 81. [LOW/safe-auto] Test named soft delete actually asserts a hard delete that its own comment flags as buggy
|
||||
- **dimension:** tests part2b
|
||||
- **where:** src/__tests__/warehouse.test.ts:630; src/__tests__/warehouse.test.ts:637
|
||||
- **problem:** The test titled deactivates an item soft delete asserts a 404 after delete. Its own comment at lines 636 to 638 notes that the route hard-deletes the row and calls this inconsistent with the supplier soft-delete pattern, so the misleading name hides the discrepancy from future readers.
|
||||
- **fix:** Rename the test to hard-deletes an item, and if soft-delete is the intended contract convert it to a todo or expected-fail test documenting the desired behaviour. Confirm the decision with the team.
|
||||
|
||||
### 82. [LOW/needs-judgment] `Record<string, any>` / untyped record builders in attendance PDF/HTML construction
|
||||
- **dimension:** ts-safety
|
||||
- **where:** src/admin/hooks/useAttendanceAdmin.ts:350; src/admin/hooks/useAttendanceAdmin.ts:382; src/admin/hooks/useAttendanceAdmin.ts:397; src/admin/hooks/useAttendanceAdmin.ts:598; src/services/offers.service.ts:49; src/services/orders.service.ts:41
|
||||
- **problem:** Several HTML/PDF/total-calculation builders take `Record<string, any>` and iterate with `(log: any)`/`(i: any)` (useAttendanceAdmin buildUserSectionHtml/recordsByDate; offers `enrichQuotation`, orders `enrichOrder`). These produce HTML that is interpolated into PDF/print output, so an `any`-typed field that is unexpectedly an object or undefined can render `[object Object]` or `NaN` totals with no compile-time signal. Lower severity than the auth bug because output is internal documents, but it is the broadest untyped surface after the plan stack.
|
||||
- **fix:** Define narrow input interfaces for the attendance record and quotation/order item shapes (the Prisma row types or the existing schemas can be reused) and replace the `Record<string, any>` parameters. At minimum type the `.map`/`.reduce` callbacks (`i`, `log`) with the item type to catch field-name and numeric-coercion mistakes.
|
||||
|
||||
### 83. [LOW/safe-auto] `category: input.category as any` casts bypass the Prisma category column type
|
||||
- **dimension:** ts-safety
|
||||
- **where:** src/services/plan.service.ts:451; src/services/plan.service.ts:518; src/services/plan.service.ts:639; src/services/plan.service.ts:710
|
||||
- **problem:** Four `create`/`update` calls cast `input.category` (a plain `string`) to `any` to satisfy the Prisma `category` column type. Validity is enforced at runtime via `assertActiveCategory` (a DB lookup), so it's not unsafe in practice, but the `as any` discards the compiler's check and would also silence a future schema type change for this column.
|
||||
- **fix:** If the Prisma column is an enum, cast to that specific enum type (e.g. `Prisma.$Enums.<Name>`) instead of `any`; if it is a `String` column, the cast is unnecessary and can be removed. Either way avoid `as any` so the field stays type-checked.
|
||||
|
||||
### 84. [LOW/manual] `request.authData!` non-null assertions: acceptable post-auth pattern (no action)
|
||||
- **dimension:** ts-safety
|
||||
- **where:** src/routes/admin/attendance.ts:30; src/middleware/auth.ts:44; src/routes/admin/plan.ts:119; src/routes/admin/warehouse.ts:1066; src/routes/admin/quotations.ts:139
|
||||
- **problem:** Non-null assertions are widespread but almost entirely `request.authData!` inside handlers guarded by `requirePermission`/`requireAuth`, plus `result.error!`/`result.status!` after an `"error" in result` narrowing. These are correct given the middleware contract; the Fastify `authData` is declared optional on the request type but is always populated after the auth preHandler. Flagging only to note it was reviewed and is intentional, not a defect.
|
||||
- **fix:** No change required. If you want to eliminate the `authData!` assertions, declare an `AuthenticatedRequest` type (or a module augmentation making `authData` required) for routes mounted behind the auth hook, so the `!` becomes unnecessary. Optional, cosmetic.
|
||||
|
||||
@@ -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.
|
||||
450
docs/superpowers/specs/2026-06-05-plan-praci-design.md
Normal file
450
docs/superpowers/specs/2026-06-05-plan-praci-design.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# Plán prací (Work Schedule) Module Design
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved
|
||||
**Module:** Work schedule / Rozpis prací for boha-app-ts
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A new module under the "Docházka" sidebar section that lets a foreman plan
|
||||
each employee's work on a per-day basis, weeks or months in advance, and lets
|
||||
every employee see what they (and the team) are supposed to be doing on any
|
||||
given day.
|
||||
|
||||
The reference for the UX is `simon/plan_boha.pdf` — a hand-rolled Excel grid
|
||||
with employees as columns and dates as rows, each cell holding a free-text
|
||||
task description. The module replaces that spreadsheet with a live, audited,
|
||||
permission-aware grid in the existing admin app.
|
||||
|
||||
A plan entry is **what should happen**. It is intentionally separate from
|
||||
attendance (which records **what actually happened**). The two are not
|
||||
auto-linked.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Core
|
||||
|
||||
- Plan entries are stored as **date ranges** (e.g. "PLC upgrade VW USA"
|
||||
from 8.6. to 26.6.), with an optional **per-day override** for the rare
|
||||
case where one day in a range is different (e.g. "Volno po noční").
|
||||
- Each entry has:
|
||||
- `user_id` — the employee this plan is for.
|
||||
- `date_from` / `date_to` — inclusive range.
|
||||
- `project_id` — **optional** FK to the existing `projects` table.
|
||||
- `category` — enum (see below), drives color-coding and reports.
|
||||
- `note` — required free-text task description (max 500 chars).
|
||||
- The project dropdown is populated from the existing `projects` table.
|
||||
No new "task" or "sub-project" entity — the note is the human-readable
|
||||
task description.
|
||||
- A cell can be empty (unassigned / not planned). An empty cell is also
|
||||
clickable for admins — opens the create-entry modal.
|
||||
- A `work_plan_overrides` row covers a single `(user, date)` and takes
|
||||
precedence over the range that would otherwise cover that day.
|
||||
|
||||
### Effective-cell resolution (runtime)
|
||||
|
||||
To compute the cell for user `U` on date `D`:
|
||||
|
||||
1. Look up `work_plan_overrides` for `(U, D)`. If present, use it.
|
||||
2. Otherwise, look up `work_plan_entries` where `user_id = U` and
|
||||
`date_from <= D <= date_to`. If multiple match, return the
|
||||
**last-created** one and write a warning to the audit log.
|
||||
3. Otherwise, the cell is empty.
|
||||
|
||||
### Editing rules
|
||||
|
||||
- **Lock past, free future.** Cells with `shift_date < today` are
|
||||
read-only. Admins can override with `?force=1` on the API; the
|
||||
override is recorded in the audit log.
|
||||
- Today and future are always editable for users with `attendance.manage`.
|
||||
- The modal offers three actions when editing a cell inside an existing
|
||||
range:
|
||||
- "Upravit celý rozsah" — edit the range.
|
||||
- "Upravit pouze tento den" — creates an override.
|
||||
- "Zrušit přiřazení tohoto dne" — creates an override with
|
||||
`category = 'other'` and empty note, leaving the cell empty for
|
||||
that day only.
|
||||
|
||||
### Categories
|
||||
|
||||
A new enum `plan_category` for color-coding and reports:
|
||||
|
||||
| Value | Czech label | Cell color hint |
|
||||
| ------------- | -------------- | --------------- |
|
||||
| `work` | Práce | blue |
|
||||
| `preparation` | Příprava | teal |
|
||||
| `travel` | Cesta / Montáž | amber |
|
||||
| `leave` | Dovolená | green |
|
||||
| `sick` | Nemoc | red |
|
||||
| `training` | Školení | purple |
|
||||
| `other` | Jiné | gray |
|
||||
|
||||
Plan overrides for leave/sick are **informational only**. They do not
|
||||
create or modify `leave_requests` or `attendance` rows. Employees still
|
||||
file leave through the existing `Žádosti` flow.
|
||||
|
||||
### Visibility & permissions
|
||||
|
||||
- **Single sidebar entry** under Docházka: `Plán prací` → `/attendance/plan`.
|
||||
- The sidebar entry is gated by `attendance.manage || attendance.record`
|
||||
(mirrors the existing `Žádosti` entry).
|
||||
- The page is **one** (`PlanWork.tsx`):
|
||||
- If `useAuth().hasPermission('attendance.manage')` → editor mode
|
||||
(grid + edit modal + create/edit/delete affordances).
|
||||
- Otherwise → view-only mode (same grid, read-only detail modal).
|
||||
- All endpoints require `attendance.manage` for writes, and either
|
||||
`attendance.manage` or `attendance.record` for reads. Employees
|
||||
with `attendance.record` are scoped server-side to their own
|
||||
`user_id` for any direct row read; the grid endpoint handles the
|
||||
scoping transparently.
|
||||
- Admin role bypasses all permission checks (existing behavior).
|
||||
|
||||
### Grid
|
||||
|
||||
- Default view is **week** (T23, T24, T25 … matches the PDF's week
|
||||
numbering). A toggle at the top switches to **month** view.
|
||||
- The grid is `employees × dates`. Columns are ordered by
|
||||
role/team first, then alphabetically by name.
|
||||
- Columns are users with the `attendance.record` permission (which
|
||||
includes admin). A "Aktivní" toggle in the toolbar lets the planner
|
||||
hide inactive users.
|
||||
- Each cell shows: a colored chip for the category, the project name
|
||||
(if any) in bold, and the note underneath. Empty cells are blank.
|
||||
- Weekend cells have a subtle yellow background tint to match the
|
||||
PDF's "So 6.6. ★" rows.
|
||||
- Read-only employees see the same grid, but cells are non-clickable
|
||||
for editing. A click opens a read-only detail modal showing project,
|
||||
category, note, "Vytvořil: <user> · <date>" (the entry's
|
||||
`created_by`), and (if the cell is part of a range) "Patří do
|
||||
rozsahu: <date_from> – <date_to>".
|
||||
|
||||
### Audit
|
||||
|
||||
Every create/update/delete of `work_plan_entries` and
|
||||
`work_plan_overrides` writes a row to `audit_logs` with
|
||||
`entity_type = 'work_plan_entry' | 'work_plan_override'` and full
|
||||
`old_values` / `new_values` JSON, following the same pattern as
|
||||
`attendance` and `projects`. The `?force=1` past-edit override is
|
||||
recorded in the description field.
|
||||
|
||||
### Non-goals (out of scope for v1)
|
||||
|
||||
- No automatic link to attendance (plan stays independent).
|
||||
- No time-slot granularity (full days only).
|
||||
- No shift / payroll calculation.
|
||||
- No drag-fill on the grid.
|
||||
- No "task list" entity — projects and free text only.
|
||||
- No notification/email when a plan is changed.
|
||||
- No browser tests (the codebase has none; manual verification in dev).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module placement
|
||||
|
||||
- New module under the **Docházka** sidebar section.
|
||||
- Reuses the existing `attendance.shift_date` concept (date column on
|
||||
the `attendance` table) — the planner lives next to the calendar it
|
||||
informs.
|
||||
- No "administrativa" module exists; the sidebar "Administrativa"
|
||||
section is the commercial back-office (offers, orders, invoices,
|
||||
projects, customers) and is not the right home for HR-style planning.
|
||||
|
||||
### File layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── routes/admin/plan.ts # Fastify routes
|
||||
├── services/plan.service.ts # Business logic
|
||||
├── schemas/plan.schema.ts # Zod validation
|
||||
└── __tests__/plan.test.ts # Vitest + Supertest tests
|
||||
|
||||
src/admin/
|
||||
├── pages/PlanWork.tsx # The single page (editor or view-only)
|
||||
├── hooks/usePlanWork.ts # State, modal, mutations
|
||||
├── lib/queries/plan.ts # React Query queryOptions
|
||||
└── components/
|
||||
├── PlanGrid.tsx # Week/month grid (shared by both modes)
|
||||
├── PlanCellModal.tsx # Edit modal (create / edit-range / override)
|
||||
├── PlanCellDetailModal.tsx # Read-only detail modal
|
||||
├── PlanRangeChips.tsx # Cell content chip with category color
|
||||
└── plan.css # Module-specific styles only
|
||||
```
|
||||
|
||||
The new CSS file holds only category color helpers and grid-specific
|
||||
overrides. All form/modal/table styling reuses existing classes
|
||||
(`.admin-table`, `.admin-table-sticky`, `FormField`, `FormModal`,
|
||||
`ConfirmModal`, `AdminDatePicker`, etc.) — no new design tokens.
|
||||
|
||||
### React Query keys
|
||||
|
||||
Per the project's "invalidate the full domain" convention:
|
||||
|
||||
- `['plan', 'grid', { dateFrom, dateTo, view }]`
|
||||
- `['plan', 'entries', { userId, dateFrom, dateTo }]`
|
||||
- `['plan', 'overrides', { userId, dateFrom, dateTo }]`
|
||||
- `['plan', 'users']`
|
||||
|
||||
A mutation on any plan row invalidates `['plan']` (covers all four
|
||||
sub-queries by prefix match).
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New enum
|
||||
|
||||
```prisma
|
||||
enum plan_category {
|
||||
work
|
||||
preparation
|
||||
travel
|
||||
leave
|
||||
sick
|
||||
training
|
||||
other
|
||||
}
|
||||
```
|
||||
|
||||
### New table: `work_plan_entries`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------------------ | ------------------------------------------------------- |
|
||||
| `id` | int PK autoincrement | |
|
||||
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||||
| `date_from` | Date | NOT NULL — first day of the range |
|
||||
| `date_to` | Date | NOT NULL — last day of the range, must be ≥ `date_from` |
|
||||
| `project_id` | int? FK → `projects(id)` | nullable — unlinked text entries are allowed |
|
||||
| `category` | enum `plan_category` | NOT NULL, default `'work'` |
|
||||
| `note` | varchar(500) | NOT NULL — free-text task description |
|
||||
| `created_by` | int FK → `users(id)` | who entered it |
|
||||
| `created_at` | DateTime | Prisma default `now()` |
|
||||
| `updated_at` | DateTime | Prisma default `now()` |
|
||||
| `is_deleted` | bool? default false | soft-delete |
|
||||
|
||||
Indexes:
|
||||
|
||||
- `(user_id, date_from)`
|
||||
- `(user_id, date_to)`
|
||||
- `(project_id)`
|
||||
- `(date_from, date_to)` — for cross-user queries like
|
||||
"what's planned for project X this month?"
|
||||
|
||||
### New table: `work_plan_overrides`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------------------ | ------------------------------------------------- |
|
||||
| `id` | int PK autoincrement | |
|
||||
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||||
| `shift_date` | Date | NOT NULL — the one day being overridden |
|
||||
| `project_id` | int? FK → `projects(id)` | nullable |
|
||||
| `category` | enum `plan_category` | NOT NULL — usually `'leave'`, `'sick'`, `'other'` |
|
||||
| `note` | varchar(500) | NOT NULL |
|
||||
| `created_by` | int FK → `users(id)` | |
|
||||
| `created_at` | DateTime | |
|
||||
| `updated_at` | DateTime | |
|
||||
| `is_deleted` | bool? default false | soft-delete |
|
||||
|
||||
Indexes:
|
||||
|
||||
- **Unique** `(user_id, shift_date)` — exactly one non-deleted
|
||||
override per user-day. MySQL unique indexes don't ignore
|
||||
soft-deleted rows, so the service layer enforces "at most one
|
||||
active override per `(user_id, shift_date)`" in application code
|
||||
(the database unique constraint exists as a final safety net and
|
||||
may be temporarily violated by soft-deleted rows, which is
|
||||
acceptable). When restoring a soft-deleted override, the service
|
||||
re-validates that no active override exists for the same day.
|
||||
- `(shift_date)` — for cross-user "what's overridden today" queries.
|
||||
|
||||
### Read semantics (recap)
|
||||
|
||||
For user `U` and date `D`, the "effective" cell is:
|
||||
|
||||
1. Override `(U, D)` if it exists and is not soft-deleted.
|
||||
2. Otherwise the latest `work_plan_entry` whose range covers `D`
|
||||
for user `U` and is not soft-deleted. If multiple match, pick
|
||||
the latest by `created_at` and write a warning to `audit_logs`.
|
||||
3. Otherwise, empty.
|
||||
|
||||
---
|
||||
|
||||
## API Surface
|
||||
|
||||
REST endpoints under `/api/admin/plan`. All routes use the existing
|
||||
Zod-validated, `requirePermission`, `success`/`error` helpers, and
|
||||
`logAudit` pattern. Czech error messages, following project convention.
|
||||
|
||||
| Method | Path | Permission | Body / Query | Returns |
|
||||
| ------ | --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| GET | `/plan/grid` | `attendance.manage` OR `attendance.record` | `?date_from=&date_to=&view=week\|month` (range ≤ 92 days) | `{ days, users, cells }` — effective cells ready to render |
|
||||
| GET | `/plan/entries` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_entries` rows in range |
|
||||
| GET | `/plan/overrides` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_overrides` rows in range |
|
||||
| GET | `/plan/users` | `attendance.manage` OR `attendance.record` | — | users with `attendance.record`, ordered by role/team then name |
|
||||
| POST | `/plan/entries` | `attendance.manage` | `{ user_id, date_from, date_to, project_id?, category, note }` | created entry |
|
||||
| PATCH | `/plan/entries/:id` | `attendance.manage` | partial of create body | updated entry |
|
||||
| DELETE | `/plan/entries/:id` | `attendance.manage` | `?force=1` to bypass past-date lock | `{ ok: true }` (soft-delete) |
|
||||
| POST | `/plan/overrides` | `attendance.manage` | `{ user_id, shift_date, project_id?, category, note }` | created override |
|
||||
| PATCH | `/plan/overrides/:id` | `attendance.manage` | partial of create body | updated override |
|
||||
| DELETE | `/plan/overrides/:id` | `attendance.manage` | `?force=1` | `{ ok: true }` (soft-delete) |
|
||||
| GET | `/plan/audit` | `settings.audit` | `?entity_type=&entity_id=` | audit log entries for a row |
|
||||
|
||||
### Past-date lock
|
||||
|
||||
Server-enforced. POST/PATCH/DELETE with any `shift_date` / `date_from`
|
||||
in the past returns 403 with a Czech error message:
|
||||
|
||||
```
|
||||
Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.
|
||||
```
|
||||
|
||||
With `?force=1` and `attendance.manage`, the operation succeeds and
|
||||
the audit log records `description = 'force-edit past date'`.
|
||||
|
||||
### Employee scoping
|
||||
|
||||
`GET /plan/entries` and `GET /plan/overrides` accept `?user_id=`. For
|
||||
a user with only `attendance.record`, the service silently overrides
|
||||
the query to scope to `user_id = auth.user.id`. The grid endpoint
|
||||
returns the same data either way (employees see the full grid in
|
||||
view-only mode).
|
||||
|
||||
---
|
||||
|
||||
## Frontend Detail
|
||||
|
||||
### `PlanWork.tsx`
|
||||
|
||||
- Mounts and calls `useAuth()`. If `hasPermission('attendance.manage')`
|
||||
→ editor state. Otherwise → view-only state.
|
||||
- Renders the toolbar (view toggle, date navigator, active-toggle,
|
||||
print) and `PlanGrid`.
|
||||
- Owns the modal state machine for `PlanCellModal` (editor) or
|
||||
`PlanCellDetailModal` (view).
|
||||
|
||||
### `PlanGrid.tsx`
|
||||
|
||||
- Pure presentational. Takes `users`, `days`, `cells` as props.
|
||||
- Renders a `<table>` with sticky first column (date) and sticky
|
||||
header (employee names).
|
||||
- Each cell is a `<button>` (admin) or `<div>` (view-only) with the
|
||||
colored chip + note.
|
||||
- Calls `onCellClick(userId, date)` for the parent to open the modal.
|
||||
- Reuses `.admin-table` and `.admin-table-sticky` from existing
|
||||
stylesheets.
|
||||
|
||||
### `PlanCellModal.tsx` (editor)
|
||||
|
||||
Three modes, decided by what the user clicked:
|
||||
|
||||
- **Create** — empty cell. Fields: `user_id` (locked to clicked user,
|
||||
can be switched), `date_from` (default = clicked date), `date_to`
|
||||
(default = clicked date; "Jeden den" / "Rozsah dnů" toggle),
|
||||
`project_id` (dropdown, populated from `projects`, nullable),
|
||||
`category` (select), `note` (textarea, required).
|
||||
- **Edit range** — cell is part of an existing range. Loads the
|
||||
full range into the form. "Upravit celý rozsah" button is the
|
||||
primary action.
|
||||
- **Edit day in range** — cell is part of a range. Three buttons:
|
||||
"Upravit celý rozsah" / "Upravit pouze tento den" /
|
||||
"Zrušit přiřazení tohoto dne".
|
||||
- **Edit single-day override** — cell is itself a `work_plan_overrides`
|
||||
row. Standard edit form.
|
||||
- Bottom: delete button (with `ConfirmModal`).
|
||||
|
||||
### `PlanCellDetailModal.tsx` (view-only)
|
||||
|
||||
Read-only. Shows project, category, note, "Vytvořil: <full name of
|
||||
`created_by`> · <`created_at` in local Czech time>", and (if the
|
||||
cell is part of a range) "Patří do rozsahu: <date_from> – <date_to>".
|
||||
For override cells, the "Vytvořil" line refers to the override's own
|
||||
`created_by` (not the parent range's). No edit, no delete.
|
||||
|
||||
### `usePlanWork.ts`
|
||||
|
||||
Owns:
|
||||
|
||||
- `view: 'week' | 'month'`, `anchorDate`, `filterActive`
|
||||
- `effectiveCells` from `['plan', 'grid']` query
|
||||
- `entries` and `overrides` for the cell being edited
|
||||
- `modalState: { mode, userId, date, entryId?, overrideId? }`
|
||||
- `createEntry`, `updateEntry`, `deleteEntry`,
|
||||
`createOverride`, `updateOverride`, `deleteOverride` —
|
||||
React Query mutations that invalidate `['plan']`.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
Single new migration:
|
||||
|
||||
`prisma/migrations/<timestamp>_add_work_plan/`
|
||||
|
||||
- `migration.sql`:
|
||||
1. `CREATE TABLE work_plan_entries (...)` with columns and indexes.
|
||||
2. `CREATE TABLE work_plan_overrides (...)` with columns and indexes.
|
||||
3. `CREATE TYPE plan_category` (or in-table enum, per Prisma's
|
||||
MySQL output).
|
||||
|
||||
No data migration. No `seed.ts` change. No new permission keys.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
`src/__tests__/plan.test.ts` — Vitest + Supertest against the real
|
||||
test database (`.env.test`):
|
||||
|
||||
1. **Effective-cell resolution** — range + override returns the
|
||||
override for that day, the range for the other days, `null`
|
||||
for uncovered days.
|
||||
2. **Past-date lock** — POST with `date_from < today` returns 403
|
||||
for users without `attendance.manage`; allowed with `?force=1`
|
||||
for users with `attendance.manage`; audit log records the force.
|
||||
3. **Employee self-view scoping** — employee with `attendance.record`
|
||||
calling `GET /plan/entries?user_id=<other>` is scoped to self.
|
||||
4. **Soft-delete + is_deleted** — deleted rows are excluded from
|
||||
`/plan/grid` but kept in `/plan/audit`.
|
||||
5. **Project FK nullability** — entries with `project_id = null`
|
||||
and `category = 'leave'` are valid and render correctly.
|
||||
6. **Range overlap** — two ranges for the same `(user, day)` does
|
||||
not error; the latest one wins; a warning is logged.
|
||||
7. **Audit log on every write** — POST/PATCH/DELETE each insert
|
||||
exactly one `audit_logs` row with `entity_type`,
|
||||
`old_values`, `new_values`.
|
||||
|
||||
---
|
||||
|
||||
## Release Plan
|
||||
|
||||
1. Bump `package.json` version (minor, since new module).
|
||||
2. `npm run build` — confirm clean compile.
|
||||
3. Commit + push to Gitea.
|
||||
4. Tarball + deploy to production via the existing release script
|
||||
(per `CLAUDE.md` "Release Process" section).
|
||||
5. `npx prisma migrate deploy` on prod.
|
||||
6. `pm2 restart app-ts --update-env`.
|
||||
7. Smoke test: log in, open Docházka → Plán prací, add a one-day
|
||||
entry for yourself, reload, confirm it shows.
|
||||
|
||||
The deployment follows the same pattern as the warehouse module
|
||||
release (see commit `5233db2` and the `2026-05-29-warehouse-module`
|
||||
spec for reference).
|
||||
|
||||
---
|
||||
|
||||
## Open items / future work
|
||||
|
||||
- Month view polish if the toggle proves popular.
|
||||
- Drag-fill on the grid (out of scope for v1).
|
||||
- Optional tie-in to attendance: pre-fill `notes` on
|
||||
`AttendanceCreate` from today's plan (read-only hint). Not
|
||||
pursued in v1.
|
||||
- "Patří do rozsahu" link in the detail modal: clickable
|
||||
jump to the range's full detail.
|
||||
- Per-team filtering (if a "team" concept is added to `users` later).
|
||||
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.
|
||||
1853
package-lock.json
generated
1853
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.4.2",
|
||||
"version": "2.0.3",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
@@ -17,7 +17,11 @@
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -28,12 +32,18 @@
|
||||
"@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",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -42,6 +52,7 @@
|
||||
"file-type": "^16.5.4",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hi-base32": "^0.5.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -67,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",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add billing_text column to invoices table
|
||||
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add lock fields to quotations table for pessimistic locking
|
||||
ALTER TABLE `quotations` ADD COLUMN `locked_by` INT NULL AFTER `scope_description`;
|
||||
ALTER TABLE `quotations` ADD COLUMN `locked_at` DATETIME NULL AFTER `locked_by`;
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE `invoice_alert_log` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`invoice_type` VARCHAR(20) NOT NULL,
|
||||
`invoice_id` INT NOT NULL,
|
||||
`alert_type` VARCHAR(20) NOT NULL,
|
||||
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `invoice_type_invoice_id_alert_type` (`invoice_type`, `invoice_id`, `alert_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add file_path column for NAS storage of received invoice files
|
||||
ALTER TABLE `received_invoices` ADD COLUMN `file_path` VARCHAR(500) NULL AFTER `file_data`;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Remove file_data (BLOB) and file_path columns — files are now stored on NAS
|
||||
-- Path is derived from year, month, and file_name
|
||||
ALTER TABLE `received_invoices` DROP COLUMN `file_data`;
|
||||
ALTER TABLE `received_invoices` DROP COLUMN `file_path`;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE `company_settings`
|
||||
ADD COLUMN `offer_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `order_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `invoice_number_pattern` VARCHAR(100) NULL;
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE `company_settings`
|
||||
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
|
||||
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- System settings columns on company_settings
|
||||
ALTER TABLE `company_settings`
|
||||
ADD COLUMN `break_threshold_hours` DECIMAL(4,2) DEFAULT 6,
|
||||
ADD COLUMN `break_duration_short` INT DEFAULT 15,
|
||||
ADD COLUMN `break_duration_long` INT DEFAULT 30,
|
||||
ADD COLUMN `clock_rounding_minutes` INT DEFAULT 15,
|
||||
ADD COLUMN `invoice_alert_email` VARCHAR(255) NULL,
|
||||
ADD COLUMN `leave_notify_email` VARCHAR(255) NULL,
|
||||
ADD COLUMN `max_login_attempts` INT DEFAULT 5,
|
||||
ADD COLUMN `lockout_minutes` INT DEFAULT 15,
|
||||
ADD COLUMN `max_requests_per_minute` INT DEFAULT 300,
|
||||
ADD COLUMN `available_vat_rates` LONGTEXT NULL,
|
||||
ADD COLUMN `available_currencies` LONGTEXT NULL;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Create new unified permission
|
||||
INSERT INTO permissions (name, display_name, description, module)
|
||||
VALUES ('settings.manage', 'Správa nastavení', 'Správa všech nastavení systému', 'settings')
|
||||
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name);
|
||||
|
||||
-- Grant to all roles that had any of the old 3
|
||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT DISTINCT rp.role_id, (SELECT id FROM permissions WHERE name = 'settings.manage')
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE p.name IN ('offers.settings', 'settings.roles', 'settings.security');
|
||||
|
||||
-- Clean up old role_permissions
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security'));
|
||||
|
||||
-- Remove old permissions
|
||||
DELETE FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security');
|
||||
@@ -98,6 +98,7 @@ CREATE TABLE `company_settings` (
|
||||
`vat_id` VARCHAR(50) NULL,
|
||||
`custom_fields` LONGTEXT NULL,
|
||||
`logo_data` LONGBLOB NULL,
|
||||
`logo_data_dark` MEDIUMBLOB NULL,
|
||||
`quotation_prefix` VARCHAR(20) NULL,
|
||||
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
||||
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||
@@ -108,7 +109,24 @@ CREATE TABLE `company_settings` (
|
||||
`order_type_code` VARCHAR(10) NULL,
|
||||
`invoice_type_code` VARCHAR(10) NULL,
|
||||
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
|
||||
`break_threshold_hours` DECIMAL(4, 2) NULL DEFAULT 6.00,
|
||||
`break_duration_short` INTEGER NULL DEFAULT 15,
|
||||
`break_duration_long` INTEGER NULL DEFAULT 30,
|
||||
`clock_rounding_minutes` INTEGER NULL DEFAULT 15,
|
||||
`invoice_alert_email` VARCHAR(255) NULL,
|
||||
`leave_notify_email` VARCHAR(255) NULL,
|
||||
`max_login_attempts` INTEGER NULL DEFAULT 5,
|
||||
`lockout_minutes` INTEGER NULL DEFAULT 15,
|
||||
`max_requests_per_minute` INTEGER NULL DEFAULT 300,
|
||||
`available_vat_rates` LONGTEXT NULL,
|
||||
`available_currencies` LONGTEXT NULL,
|
||||
`smtp_from` VARCHAR(255) NULL,
|
||||
`smtp_from_name` VARCHAR(255) NULL,
|
||||
`offer_number_pattern` VARCHAR(100) NULL,
|
||||
`order_number_pattern` VARCHAR(100) NULL,
|
||||
`invoice_number_pattern` VARCHAR(100) NULL,
|
||||
|
||||
UNIQUE INDEX `company_settings_uuid_key`(`uuid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
@@ -167,14 +185,18 @@ CREATE TABLE `invoices` (
|
||||
`tax_date` DATE NULL,
|
||||
`paid_date` DATE NULL,
|
||||
`issued_by` VARCHAR(255) NULL,
|
||||
`billing_text` VARCHAR(500) NULL,
|
||||
`language` VARCHAR(5) NULL DEFAULT 'cs',
|
||||
`notes` TEXT NULL,
|
||||
`internal_notes` TEXT NULL,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `idx_invoices_number_unique`(`invoice_number`),
|
||||
INDEX `customer_id`(`customer_id`),
|
||||
INDEX `idx_invoices_due_date`(`due_date`),
|
||||
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
|
||||
INDEX `idx_invoices_status_due`(`status`, `due_date`),
|
||||
INDEX `order_id`(`order_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -239,6 +261,7 @@ CREATE TABLE `number_sequences` (
|
||||
`year` INTEGER NULL,
|
||||
`last_number` INTEGER NULL DEFAULT 0,
|
||||
|
||||
UNIQUE INDEX `idx_number_sequences_type_year`(`type`, `year`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
@@ -294,6 +317,7 @@ CREATE TABLE `orders` (
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `idx_orders_number_unique`(`order_number`),
|
||||
INDEX `customer_id`(`customer_id`),
|
||||
INDEX `quotation_id`(`quotation_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
@@ -341,6 +365,7 @@ CREATE TABLE `projects` (
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `projects_project_number_key`(`project_number`),
|
||||
INDEX `customer_id`(`customer_id`),
|
||||
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
|
||||
INDEX `order_id`(`order_id`),
|
||||
@@ -386,10 +411,13 @@ CREATE TABLE `quotations` (
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
`scope_title` VARCHAR(500) NULL,
|
||||
`scope_description` TEXT NULL,
|
||||
`locked_by` INTEGER NULL,
|
||||
`locked_at` DATETIME(0) NULL,
|
||||
`uuid` VARCHAR(36) NULL,
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
`sync_version` INTEGER NULL DEFAULT 0,
|
||||
|
||||
UNIQUE INDEX `quotations_quotation_number_key`(`quotation_number`),
|
||||
INDEX `customer_id`(`customer_id`),
|
||||
INDEX `idx_quotations_number`(`quotation_number`),
|
||||
PRIMARY KEY (`id`)
|
||||
@@ -411,7 +439,6 @@ CREATE TABLE `received_invoices` (
|
||||
`due_date` DATE NULL,
|
||||
`paid_date` DATE NULL,
|
||||
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
|
||||
`file_data` MEDIUMBLOB NULL,
|
||||
`file_name` VARCHAR(255) NULL,
|
||||
`file_mime` VARCHAR(100) NULL,
|
||||
`file_size` INTEGER UNSIGNED NULL,
|
||||
@@ -572,6 +599,7 @@ CREATE TABLE `users` (
|
||||
`totp_secret` VARCHAR(255) NULL,
|
||||
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
|
||||
`totp_backup_codes` TEXT NULL,
|
||||
`totp_last_used_counter` INTEGER NULL,
|
||||
|
||||
UNIQUE INDEX `username`(`username`),
|
||||
UNIQUE INDEX `email`(`email`),
|
||||
@@ -597,12 +625,28 @@ CREATE TABLE `vehicles` (
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `invoice_alert_log` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`invoice_type` VARCHAR(20) NOT NULL,
|
||||
`invoice_id` INTEGER NOT NULL,
|
||||
`alert_type` VARCHAR(20) NOT NULL,
|
||||
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`created_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
UNIQUE INDEX `invoice_type_invoice_id_alert_type`(`invoice_type`, `invoice_id`, `alert_type`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_attendance_id_fkey` FOREIGN KEY (`attendance_id`) REFERENCES `attendance`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_ibfk_1` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
@@ -674,4 +718,3 @@ ALTER TABLE `trips` ADD CONSTRAINT `trips_vehicle_fk` FOREIGN KEY (`vehicle_id`)
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Insert the settings.system permission (also available via seed)
|
||||
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
|
||||
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
|
||||
ON DUPLICATE KEY UPDATE `name` = `name`;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
|
||||
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AlterTable: quotation_items
|
||||
ALTER TABLE `quotation_items` DROP COLUMN `uuid`;
|
||||
ALTER TABLE `quotation_items` DROP COLUMN `is_deleted`;
|
||||
ALTER TABLE `quotation_items` DROP COLUMN `sync_version`;
|
||||
|
||||
-- AlterTable: quotations
|
||||
ALTER TABLE `quotations` DROP COLUMN `uuid`;
|
||||
ALTER TABLE `quotations` DROP COLUMN `sync_version`;
|
||||
|
||||
-- AlterTable: scope_sections
|
||||
ALTER TABLE `scope_sections` DROP COLUMN `content_editor_height`;
|
||||
ALTER TABLE `scope_sections` DROP COLUMN `uuid`;
|
||||
ALTER TABLE `scope_sections` DROP COLUMN `is_deleted`;
|
||||
ALTER TABLE `scope_sections` DROP COLUMN `sync_version`;
|
||||
@@ -0,0 +1,277 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_categories` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_suppliers` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`ico` VARCHAR(20) NULL,
|
||||
`dic` VARCHAR(20) NULL,
|
||||
`contact_person` VARCHAR(255) NULL,
|
||||
`email` VARCHAR(255) NULL,
|
||||
`phone` VARCHAR(50) NULL,
|
||||
`address` TEXT NULL,
|
||||
`notes` TEXT NULL,
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_locations` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(20) NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `sklad_locations_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_items` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`item_number` VARCHAR(50) NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`category_id` INTEGER NULL,
|
||||
`unit` VARCHAR(20) NOT NULL,
|
||||
`min_quantity` DECIMAL(12, 3) NULL,
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||
`notes` TEXT NULL,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `sklad_items_item_number_key`(`item_number`),
|
||||
INDEX `sklad_items_category_id`(`category_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_batches` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`item_id` INTEGER NOT NULL,
|
||||
`receipt_line_id` INTEGER NOT NULL,
|
||||
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||
`original_qty` DECIMAL(12, 3) NOT NULL,
|
||||
`unit_price` DECIMAL(12, 2) NOT NULL,
|
||||
`received_at` DATETIME(0) NULL,
|
||||
`is_consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
UNIQUE INDEX `sklad_batches_receipt_line_id_key`(`receipt_line_id`),
|
||||
INDEX `sklad_batches_fifo`(`item_id`, `is_consumed`, `received_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_item_locations` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`item_id` INTEGER NOT NULL,
|
||||
`location_id` INTEGER NOT NULL,
|
||||
`quantity` DECIMAL(12, 3) NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE INDEX `sklad_item_locations_unique`(`item_id`, `location_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_receipts` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`receipt_number` VARCHAR(50) NULL,
|
||||
`supplier_id` INTEGER NULL,
|
||||
`delivery_note_number` VARCHAR(100) NULL,
|
||||
`delivery_note_date` DATETIME(0) NULL,
|
||||
`received_by` INTEGER NULL,
|
||||
`notes` TEXT NULL,
|
||||
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_receipt_lines` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`receipt_id` INTEGER NOT NULL,
|
||||
`item_id` INTEGER NOT NULL,
|
||||
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||
`unit_price` DECIMAL(12, 2) NOT NULL,
|
||||
`location_id` INTEGER NULL,
|
||||
`notes` VARCHAR(255) NULL,
|
||||
|
||||
INDEX `sklad_receipt_lines_receipt_id`(`receipt_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_receipt_attachments` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`receipt_id` INTEGER NOT NULL,
|
||||
`file_name` VARCHAR(255) NOT NULL,
|
||||
`file_mime` VARCHAR(100) NOT NULL,
|
||||
`file_size` INTEGER NOT NULL,
|
||||
`file_path` VARCHAR(500) NOT NULL,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_issues` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`issue_number` VARCHAR(50) NULL,
|
||||
`project_id` INTEGER NOT NULL,
|
||||
`issued_by` INTEGER NULL,
|
||||
`notes` TEXT NULL,
|
||||
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_issue_lines` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`issue_id` INTEGER NOT NULL,
|
||||
`item_id` INTEGER NOT NULL,
|
||||
`batch_id` INTEGER NOT NULL,
|
||||
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||
`location_id` INTEGER NULL,
|
||||
`reservation_id` INTEGER NULL,
|
||||
`notes` VARCHAR(255) NULL,
|
||||
|
||||
INDEX `sklad_issue_lines_issue_id`(`issue_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_reservations` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`item_id` INTEGER NOT NULL,
|
||||
`project_id` INTEGER NOT NULL,
|
||||
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||
`remaining_qty` DECIMAL(12, 3) NOT NULL,
|
||||
`reserved_by` INTEGER NULL,
|
||||
`notes` TEXT NULL,
|
||||
`status` ENUM('ACTIVE', 'FULFILLED', 'CANCELLED') NOT NULL DEFAULT 'ACTIVE',
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `sklad_reservations_item_status`(`item_id`, `status`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_inventory_sessions` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`session_number` VARCHAR(50) NULL,
|
||||
`notes` TEXT NULL,
|
||||
`status` ENUM('DRAFT', 'CONFIRMED') NOT NULL DEFAULT 'DRAFT',
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`modified_at` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sklad_inventory_lines` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`session_id` INTEGER NOT NULL,
|
||||
`item_id` INTEGER NOT NULL,
|
||||
`location_id` INTEGER NULL,
|
||||
`system_qty` DECIMAL(12, 3) NOT NULL,
|
||||
`actual_qty` DECIMAL(12, 3) NOT NULL,
|
||||
`difference` DECIMAL(12, 3) NOT NULL,
|
||||
`notes` VARCHAR(255) NULL,
|
||||
|
||||
INDEX `sklad_inventory_lines_session_id`(`session_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_items` ADD CONSTRAINT `sklad_items_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `sklad_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_receipt_line_id_fkey` FOREIGN KEY (`receipt_line_id`) REFERENCES `sklad_receipt_lines`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_supplier_id_fkey` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_received_by_fkey` FOREIGN KEY (`received_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_batch_id_fkey` FOREIGN KEY (`batch_id`) REFERENCES `sklad_batches`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_reservation_id_fkey` FOREIGN KEY (`reservation_id`) REFERENCES `sklad_reservations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_reserved_by_fkey` FOREIGN KEY (`reserved_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `company_settings` ADD COLUMN `warehouse_inventory_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `warehouse_inventory_prefix` VARCHAR(20) NULL,
|
||||
ADD COLUMN `warehouse_issue_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `warehouse_issue_prefix` VARCHAR(20) NULL,
|
||||
ADD COLUMN `warehouse_receipt_number_pattern` VARCHAR(100) NULL,
|
||||
ADD COLUMN `warehouse_receipt_prefix` VARCHAR(20) NULL;
|
||||
|
||||
-- Set backward-compatible defaults for existing installations
|
||||
UPDATE `company_settings`
|
||||
SET
|
||||
`warehouse_receipt_prefix` = 'PRI',
|
||||
`warehouse_receipt_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
|
||||
`warehouse_issue_prefix` = 'VYD',
|
||||
`warehouse_issue_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
|
||||
`warehouse_inventory_prefix` = 'INV',
|
||||
`warehouse_inventory_number_pattern` = '{PREFIX}-{YYYY}-{NNN}';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Add warehouse module permissions and assign them to the admin role.
|
||||
-- These rows used to be created by `prisma/seed.ts`, which is dev-only and
|
||||
-- must not be run against production (per CLAUDE.md). This migration is
|
||||
-- idempotent: re-running it is a no-op.
|
||||
--
|
||||
-- The 4 permissions match `prisma/seed.ts` lines 284-308 exactly.
|
||||
|
||||
-- 1. Insert the 4 warehouse permissions (INSERT IGNORE skips on duplicate
|
||||
-- name, so re-running the migration is safe).
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('warehouse.view', 'Zobrazit sklad', 'warehouse', 'Prohlížet stav skladu, položky, reporty a historii pohybů', NOW()),
|
||||
('warehouse.operate', 'Příjem a výdej', 'warehouse', 'Vytvářet a potvrzovat příjmy, výdeje a rezervace', NOW()),
|
||||
('warehouse.manage', 'Správa skladu', 'warehouse', 'Spravovat katalog materiálů, dodavatele, lokace a kategorie', NOW()),
|
||||
('warehouse.inventory', 'Inventura', 'warehouse', 'Vytvářet a potvrzovat inventurní sčítkání', NOW());
|
||||
|
||||
-- 2. Assign all 4 permissions to the admin role. INSERT IGNORE on the
|
||||
-- composite primary key (role_id, permission_id) makes this idempotent
|
||||
-- and preserves any other role assignments that already exist.
|
||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `roles` r
|
||||
CROSS JOIN `permissions` p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.name IN (
|
||||
'warehouse.view',
|
||||
'warehouse.operate',
|
||||
'warehouse.manage',
|
||||
'warehouse.inventory'
|
||||
);
|
||||
61
prisma/migrations/20260605120000_add_work_plan/migration.sql
Normal file
61
prisma/migrations/20260605120000_add_work_plan/migration.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Add work_plan_entries and work_plan_overrides tables plus the plan_category
|
||||
-- enum. Both tables are owned by users (Cascade on delete) and reference
|
||||
-- projects (SetNull). `created_by` uses Restrict so we never silently lose
|
||||
-- the audit trail of who created the entry.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `work_plan_entries` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`date_from` DATE NOT NULL,
|
||||
`date_to` DATE NOT NULL,
|
||||
`project_id` INTEGER NULL,
|
||||
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL DEFAULT 'work',
|
||||
`note` VARCHAR(500) NOT NULL,
|
||||
`created_by` INTEGER NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`is_deleted` BOOLEAN NULL DEFAULT false,
|
||||
|
||||
INDEX `idx_wpe_user_from`(`user_id`, `date_from`),
|
||||
INDEX `idx_wpe_user_to`(`user_id`, `date_to`),
|
||||
INDEX `idx_wpe_project`(`project_id`),
|
||||
INDEX `idx_wpe_dates`(`date_from`, `date_to`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `work_plan_overrides` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`shift_date` DATE NOT NULL,
|
||||
`project_id` INTEGER NULL,
|
||||
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL,
|
||||
`note` VARCHAR(500) NOT NULL,
|
||||
`created_by` INTEGER NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`is_deleted` BOOLEAN NULL DEFAULT false,
|
||||
|
||||
INDEX `idx_wpo_date`(`shift_date`),
|
||||
UNIQUE INDEX `uniq_wpo_user_date`(`user_id`, `shift_date`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Drop the unique index on (user_id, shift_date) for work_plan_overrides.
|
||||
-- MySQL unique indexes don't ignore soft-deleted rows, which would prevent
|
||||
-- createOverride from soft-deleting an existing override and creating a new
|
||||
-- one for the same (user_id, shift_date). Application code enforces the
|
||||
-- "at most one active override per (user_id, shift_date)" invariant.
|
||||
-- The application-level check is wrapped in a transaction; see
|
||||
-- src/services/plan.service.ts:createOverride.
|
||||
--
|
||||
-- The original index was created in
|
||||
-- 20260605120000_add_work_plan/migration.sql as `uniq_wpo_user_date`.
|
||||
|
||||
-- IMPORTANT ordering: `uniq_wpo_user_date` is the only index covering the
|
||||
-- `user_id` foreign key, and MySQL refuses to drop an index still needed by a
|
||||
-- FK. So we CREATE the replacement non-unique index FIRST (giving the FK
|
||||
-- another covering index), THEN drop the unique one. The reverse order fails
|
||||
-- with "Cannot drop index ... needed in a foreign key constraint" on a fresh
|
||||
-- apply.
|
||||
|
||||
-- Add a non-unique index for the (user_id, shift_date) lookup used by
|
||||
-- createOverride's "is there an active override for this user-day?" query
|
||||
-- and by resolveCell / resolveGrid. This is a plain index, not a unique
|
||||
-- one — multiple rows (active and soft-deleted) can share the same
|
||||
-- (user_id, shift_date) pair.
|
||||
CREATE INDEX `idx_wpo_user_date` ON `work_plan_overrides`(`user_id`, `shift_date`);
|
||||
|
||||
DROP INDEX `uniq_wpo_user_date` ON `work_plan_overrides`;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Add plan_categories table and convert category columns from ENUM to VARCHAR(50)
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `plan_categories` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(50) NOT NULL,
|
||||
`label` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(7) NOT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
UNIQUE INDEX `plan_categories_key_key`(`key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_entries
|
||||
ALTER TABLE `work_plan_entries` MODIFY `category` VARCHAR(50) NOT NULL DEFAULT 'work';
|
||||
|
||||
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_overrides
|
||||
ALTER TABLE `work_plan_overrides` MODIFY `category` VARCHAR(50) NOT NULL;
|
||||
|
||||
-- Seed the categories that were previously the plan_category enum
|
||||
INSERT INTO `plan_categories` (`key`, `label`, `color`, `sort_order`, `is_active`) VALUES
|
||||
('work', 'Práce', '#2563eb', 1, true),
|
||||
('preparation', 'Příprava', '#0d9488', 2, true),
|
||||
('travel', 'Cesta / Montáž', '#ca8a04', 3, true),
|
||||
('leave', 'Dovolená', '#16a34a', 4, true),
|
||||
('sick', 'Nemoc', '#dc2626', 5, true),
|
||||
('training', 'Školení', '#7c3aed', 6, true),
|
||||
('other', 'Jiné', '#6b7280', 7, true);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
|
||||
-- project creation was deleted). Idempotent: re-running is a no-op.
|
||||
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).
|
||||
|
||||
-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());
|
||||
|
||||
-- 2. Grant it to the admin role (idempotent via composite PK).
|
||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `roles` r
|
||||
CROSS JOIN `permissions` p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.name IN ('projects.create');
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
|
||||
@@ -46,6 +46,7 @@ model attendance_project_logs {
|
||||
hours Int? @db.UnsignedInt
|
||||
minutes Int? @db.UnsignedInt
|
||||
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
|
||||
@@index([project_id], map: "idx_project_id")
|
||||
@@ -100,18 +101,18 @@ model company_settings {
|
||||
vat_id String? @db.VarChar(50)
|
||||
custom_fields String? @db.LongText
|
||||
logo_data Bytes?
|
||||
logo_data_dark Bytes?
|
||||
logo_data_dark Bytes? @db.MediumBlob
|
||||
quotation_prefix String? @db.VarChar(20)
|
||||
default_currency String? @default("CZK") @db.VarChar(10)
|
||||
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
uuid String? @db.VarChar(36)
|
||||
uuid String? @unique @db.VarChar(36)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
sync_version Int? @default(0)
|
||||
order_type_code String? @db.VarChar(10)
|
||||
invoice_type_code String? @db.VarChar(10)
|
||||
require_2fa Boolean @default(false)
|
||||
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
|
||||
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
|
||||
break_duration_short Int? @default(15)
|
||||
break_duration_long Int? @default(30)
|
||||
clock_rounding_minutes Int? @default(15)
|
||||
@@ -127,6 +128,12 @@ model company_settings {
|
||||
offer_number_pattern String? @db.VarChar(100)
|
||||
order_number_pattern String? @db.VarChar(100)
|
||||
invoice_number_pattern String? @db.VarChar(100)
|
||||
warehouse_receipt_prefix String? @db.VarChar(20)
|
||||
warehouse_receipt_number_pattern String? @db.VarChar(100)
|
||||
warehouse_issue_prefix String? @db.VarChar(20)
|
||||
warehouse_issue_number_pattern String? @db.VarChar(100)
|
||||
warehouse_inventory_prefix String? @db.VarChar(20)
|
||||
warehouse_inventory_number_pattern String? @db.VarChar(100)
|
||||
}
|
||||
|
||||
model customers {
|
||||
@@ -152,8 +159,9 @@ model customers {
|
||||
model invoice_items {
|
||||
id Int @id @default(autoincrement())
|
||||
invoice_id Int
|
||||
description String? @db.VarChar(500)
|
||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||
description String? @db.VarChar(500)
|
||||
item_description String? @db.Text
|
||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||
unit String? @db.VarChar(20)
|
||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
@@ -165,7 +173,7 @@ model invoice_items {
|
||||
|
||||
model invoices {
|
||||
id Int @id @default(autoincrement())
|
||||
invoice_number String? @db.VarChar(50)
|
||||
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
|
||||
order_id Int?
|
||||
customer_id Int?
|
||||
status String? @default("issued") @db.VarChar(30)
|
||||
@@ -196,6 +204,7 @@ model invoices {
|
||||
@@index([customer_id], map: "customer_id")
|
||||
@@index([due_date], map: "idx_invoices_due_date")
|
||||
@@index([status, issue_date], map: "idx_invoices_status_issue")
|
||||
@@index([status, due_date], map: "idx_invoices_status_due")
|
||||
@@index([order_id], map: "order_id")
|
||||
}
|
||||
|
||||
@@ -253,6 +262,8 @@ model number_sequences {
|
||||
type String? @db.VarChar(50)
|
||||
year Int?
|
||||
last_number Int? @default(0)
|
||||
|
||||
@@unique([type, year], map: "idx_number_sequences_type_year")
|
||||
}
|
||||
|
||||
model order_items {
|
||||
@@ -286,7 +297,7 @@ model order_sections {
|
||||
|
||||
model orders {
|
||||
id Int @id @default(autoincrement())
|
||||
order_number String? @db.VarChar(50)
|
||||
order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
|
||||
customer_order_number String? @db.VarChar(100)
|
||||
attachment_data Bytes?
|
||||
attachment_name String? @db.VarChar(255)
|
||||
@@ -338,7 +349,7 @@ model project_notes {
|
||||
|
||||
model projects {
|
||||
id Int @id @default(autoincrement())
|
||||
project_number String? @db.VarChar(50)
|
||||
project_number String? @unique @db.VarChar(50)
|
||||
name String? @db.VarChar(255)
|
||||
customer_id Int?
|
||||
responsible_user_id Int?
|
||||
@@ -350,7 +361,12 @@ model projects {
|
||||
notes String? @db.Text
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
attendance_project_logs attendance_project_logs[]
|
||||
project_notes project_notes[]
|
||||
sklad_issues sklad_issues[]
|
||||
sklad_reservations sklad_reservations[]
|
||||
work_plan_entries work_plan_entries[]
|
||||
work_plan_overrides work_plan_overrides[]
|
||||
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
|
||||
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
|
||||
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
|
||||
@@ -372,10 +388,7 @@ model quotation_items {
|
||||
unit String? @db.VarChar(20)
|
||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||
is_included_in_total Boolean? @default(true)
|
||||
uuid String? @db.VarChar(36)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
sync_version Int? @default(0)
|
||||
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||
|
||||
@@index([quotation_id], map: "quotation_id")
|
||||
@@ -383,8 +396,8 @@ model quotation_items {
|
||||
|
||||
model quotations {
|
||||
id Int @id @default(autoincrement())
|
||||
quotation_number String? @db.VarChar(50)
|
||||
project_code String? @db.VarChar(50)
|
||||
quotation_number String? @unique @db.VarChar(50)
|
||||
project_code String? @db.VarChar(100)
|
||||
customer_id Int?
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
valid_until DateTime? @db.Date
|
||||
@@ -392,17 +405,13 @@ model quotations {
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||
apply_vat Boolean? @default(true)
|
||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||
exchange_rate_date DateTime? @db.Date
|
||||
order_id Int?
|
||||
status String @default("active") @db.VarChar(20)
|
||||
scope_title String? @db.VarChar(500)
|
||||
scope_description String? @db.Text
|
||||
locked_by Int?
|
||||
locked_at DateTime? @db.DateTime(0)
|
||||
uuid String? @db.VarChar(36)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
sync_version Int? @default(0)
|
||||
orders orders[]
|
||||
projects projects[]
|
||||
quotation_items quotation_items[]
|
||||
@@ -487,11 +496,7 @@ model scope_sections {
|
||||
title String? @db.VarChar(500)
|
||||
title_cz String? @db.VarChar(500)
|
||||
content String? @db.Text
|
||||
content_editor_height Int?
|
||||
uuid String? @db.VarChar(36)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
sync_version Int? @default(0)
|
||||
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
|
||||
|
||||
@@index([quotation_id], map: "quotation_id")
|
||||
@@ -578,12 +583,20 @@ model users {
|
||||
totp_secret String? @db.VarChar(255)
|
||||
totp_enabled Boolean @default(false)
|
||||
totp_backup_codes String? @db.Text
|
||||
totp_last_used_counter Int?
|
||||
attendance attendance[]
|
||||
leave_balances leave_balances[]
|
||||
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
|
||||
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
|
||||
projects projects[]
|
||||
trips trips[]
|
||||
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
|
||||
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
|
||||
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
|
||||
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
|
||||
work_plan_entries_owned work_plan_entries[]
|
||||
work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator")
|
||||
work_plan_overrides_owned work_plan_overrides[]
|
||||
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
|
||||
|
||||
@@index([is_active], map: "idx_users_is_active")
|
||||
@@ -624,8 +637,9 @@ model invoice_alert_log {
|
||||
invoice_id Int
|
||||
alert_type String @db.VarChar(20) // "3days" or "due"
|
||||
sent_at DateTime @default(now()) @db.DateTime(0)
|
||||
created_at DateTime @default(now()) @db.DateTime(0)
|
||||
|
||||
@@unique([invoice_type, invoice_id, alert_type])
|
||||
@@unique([invoice_type, invoice_id, alert_type], map: "invoice_type_invoice_id_alert_type")
|
||||
}
|
||||
|
||||
enum received_invoices_status {
|
||||
@@ -640,3 +654,324 @@ enum attendance_leave_type {
|
||||
holiday
|
||||
unpaid
|
||||
}
|
||||
|
||||
enum sklad_receipt_status {
|
||||
DRAFT
|
||||
CONFIRMED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum sklad_issue_status {
|
||||
DRAFT
|
||||
CONFIRMED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum sklad_reservation_status {
|
||||
ACTIVE
|
||||
FULFILLED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum sklad_inventory_status {
|
||||
DRAFT
|
||||
CONFIRMED
|
||||
}
|
||||
|
||||
model sklad_categories {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @db.VarChar(100)
|
||||
description String? @db.Text
|
||||
sort_order Int @default(0)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
items sklad_items[]
|
||||
|
||||
@@map("sklad_categories")
|
||||
}
|
||||
|
||||
model sklad_suppliers {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @db.VarChar(255)
|
||||
ico String? @db.VarChar(20)
|
||||
dic String? @db.VarChar(20)
|
||||
contact_person String? @db.VarChar(255)
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
address String? @db.Text
|
||||
notes String? @db.Text
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
receipts sklad_receipts[]
|
||||
|
||||
@@map("sklad_suppliers")
|
||||
}
|
||||
|
||||
model sklad_locations {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(100)
|
||||
description String? @db.Text
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
items sklad_item_locations[]
|
||||
receipt_lines sklad_receipt_lines[]
|
||||
issue_lines sklad_issue_lines[]
|
||||
inventory_lines sklad_inventory_lines[]
|
||||
|
||||
@@map("sklad_locations")
|
||||
}
|
||||
|
||||
model sklad_items {
|
||||
id Int @id @default(autoincrement())
|
||||
item_number String? @unique @db.VarChar(50)
|
||||
name String @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
category_id Int?
|
||||
unit String @db.VarChar(20)
|
||||
min_quantity Decimal? @db.Decimal(12, 3)
|
||||
is_active Boolean @default(true)
|
||||
notes String? @db.Text
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
category sklad_categories? @relation(fields: [category_id], references: [id])
|
||||
batches sklad_batches[]
|
||||
item_locations sklad_item_locations[]
|
||||
receipt_lines sklad_receipt_lines[]
|
||||
issue_lines sklad_issue_lines[]
|
||||
reservations sklad_reservations[]
|
||||
inventory_lines sklad_inventory_lines[]
|
||||
|
||||
@@index([category_id], map: "sklad_items_category_id")
|
||||
@@map("sklad_items")
|
||||
}
|
||||
|
||||
model sklad_batches {
|
||||
id Int @id @default(autoincrement())
|
||||
item_id Int
|
||||
receipt_line_id Int @unique
|
||||
quantity Decimal @db.Decimal(12, 3)
|
||||
original_qty Decimal @db.Decimal(12, 3)
|
||||
unit_price Decimal @db.Decimal(12, 2)
|
||||
received_at DateTime? @db.DateTime(0)
|
||||
is_consumed Boolean @default(false)
|
||||
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
|
||||
issue_lines sklad_issue_lines[]
|
||||
|
||||
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
|
||||
@@map("sklad_batches")
|
||||
}
|
||||
|
||||
model sklad_item_locations {
|
||||
id Int @id @default(autoincrement())
|
||||
item_id Int
|
||||
location_id Int
|
||||
quantity Decimal @default(0) @db.Decimal(12, 3)
|
||||
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
location sklad_locations @relation(fields: [location_id], references: [id])
|
||||
|
||||
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
|
||||
@@map("sklad_item_locations")
|
||||
}
|
||||
|
||||
model sklad_receipts {
|
||||
id Int @id @default(autoincrement())
|
||||
receipt_number String? @db.VarChar(50)
|
||||
supplier_id Int?
|
||||
delivery_note_number String? @db.VarChar(100)
|
||||
delivery_note_date DateTime? @db.DateTime(0)
|
||||
received_by Int?
|
||||
notes String? @db.Text
|
||||
status sklad_receipt_status @default(DRAFT)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
|
||||
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
|
||||
items sklad_receipt_lines[]
|
||||
attachments sklad_receipt_attachments[]
|
||||
|
||||
@@map("sklad_receipts")
|
||||
}
|
||||
|
||||
model sklad_receipt_lines {
|
||||
id Int @id @default(autoincrement())
|
||||
receipt_id Int
|
||||
item_id Int
|
||||
quantity Decimal @db.Decimal(12, 3)
|
||||
unit_price Decimal @db.Decimal(12, 2)
|
||||
location_id Int?
|
||||
notes String? @db.VarChar(255)
|
||||
|
||||
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||
batch sklad_batches?
|
||||
|
||||
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
|
||||
@@map("sklad_receipt_lines")
|
||||
}
|
||||
|
||||
model sklad_receipt_attachments {
|
||||
id Int @id @default(autoincrement())
|
||||
receipt_id Int
|
||||
file_name String @db.VarChar(255)
|
||||
file_mime String @db.VarChar(100)
|
||||
file_size Int
|
||||
file_path String @db.VarChar(500)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
|
||||
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
|
||||
|
||||
@@map("sklad_receipt_attachments")
|
||||
}
|
||||
|
||||
model sklad_issues {
|
||||
id Int @id @default(autoincrement())
|
||||
issue_number String? @db.VarChar(50)
|
||||
project_id Int
|
||||
issued_by Int?
|
||||
notes String? @db.Text
|
||||
status sklad_issue_status @default(DRAFT)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
project projects @relation(fields: [project_id], references: [id])
|
||||
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
|
||||
items sklad_issue_lines[]
|
||||
|
||||
@@map("sklad_issues")
|
||||
}
|
||||
|
||||
model sklad_issue_lines {
|
||||
id Int @id @default(autoincrement())
|
||||
issue_id Int
|
||||
item_id Int
|
||||
batch_id Int
|
||||
quantity Decimal @db.Decimal(12, 3)
|
||||
location_id Int?
|
||||
reservation_id Int?
|
||||
notes String? @db.VarChar(255)
|
||||
|
||||
issue sklad_issues @relation(fields: [issue_id], references: [id])
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
batch sklad_batches @relation(fields: [batch_id], references: [id])
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
|
||||
|
||||
@@index([issue_id], map: "sklad_issue_lines_issue_id")
|
||||
@@map("sklad_issue_lines")
|
||||
}
|
||||
|
||||
model sklad_reservations {
|
||||
id Int @id @default(autoincrement())
|
||||
item_id Int
|
||||
project_id Int
|
||||
quantity Decimal @db.Decimal(12, 3)
|
||||
remaining_qty Decimal @db.Decimal(12, 3)
|
||||
reserved_by Int?
|
||||
notes String? @db.Text
|
||||
status sklad_reservation_status @default(ACTIVE)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
project projects @relation(fields: [project_id], references: [id])
|
||||
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
|
||||
issue_lines sklad_issue_lines[]
|
||||
|
||||
@@index([item_id, status], map: "sklad_reservations_item_status")
|
||||
@@map("sklad_reservations")
|
||||
}
|
||||
|
||||
model sklad_inventory_sessions {
|
||||
id Int @id @default(autoincrement())
|
||||
session_number String? @db.VarChar(50)
|
||||
notes String? @db.Text
|
||||
status sklad_inventory_status @default(DRAFT)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
items sklad_inventory_lines[]
|
||||
|
||||
@@map("sklad_inventory_sessions")
|
||||
}
|
||||
|
||||
model sklad_inventory_lines {
|
||||
id Int @id @default(autoincrement())
|
||||
session_id Int
|
||||
item_id Int
|
||||
location_id Int?
|
||||
system_qty Decimal @db.Decimal(12, 3)
|
||||
actual_qty Decimal @db.Decimal(12, 3)
|
||||
difference Decimal @db.Decimal(12, 3)
|
||||
notes String? @db.VarChar(255)
|
||||
|
||||
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
|
||||
item sklad_items @relation(fields: [item_id], references: [id])
|
||||
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||
|
||||
@@index([session_id], map: "sklad_inventory_lines_session_id")
|
||||
@@map("sklad_inventory_lines")
|
||||
}
|
||||
|
||||
model plan_categories {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique @db.VarChar(50)
|
||||
label String @db.VarChar(100)
|
||||
color String @db.VarChar(7)
|
||||
sort_order Int @default(0)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
updated_at DateTime? @default(now()) @updatedAt @db.DateTime(0)
|
||||
}
|
||||
|
||||
model work_plan_entries {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
date_from DateTime @db.Date
|
||||
date_to DateTime @db.Date
|
||||
project_id Int?
|
||||
category String @default("work") @db.VarChar(50)
|
||||
note String @db.VarChar(500)
|
||||
created_by Int
|
||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
|
||||
@@index([user_id, date_from], map: "idx_wpe_user_from")
|
||||
@@index([user_id, date_to], map: "idx_wpe_user_to")
|
||||
@@index([project_id], map: "idx_wpe_project")
|
||||
@@index([date_from, date_to], map: "idx_wpe_dates")
|
||||
}
|
||||
|
||||
model work_plan_overrides {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
shift_date DateTime @db.Date
|
||||
project_id Int?
|
||||
category String @db.VarChar(50)
|
||||
note String @db.VarChar(500)
|
||||
created_by Int
|
||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
is_deleted Boolean? @default(false)
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
|
||||
@@index([user_id, shift_date], map: "idx_wpo_user_date")
|
||||
@@index([shift_date], map: "idx_wpo_date")
|
||||
}
|
||||
|
||||
475
prisma/seed.ts
Normal file
475
prisma/seed.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const PERMISSIONS: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
module: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
// Docházka
|
||||
{
|
||||
name: "attendance.record",
|
||||
display_name: "Záznam docházky",
|
||||
module: "attendance",
|
||||
description: "Zapisovat příchody/odchody",
|
||||
},
|
||||
{
|
||||
name: "attendance.history",
|
||||
display_name: "Historie docházky",
|
||||
module: "attendance",
|
||||
description: "Prohlížet vlastní historii docházky",
|
||||
},
|
||||
{
|
||||
name: "attendance.approve",
|
||||
display_name: "Schvalování docházky",
|
||||
module: "attendance",
|
||||
description: "Schvalovat žádosti o dovolenou/nemoc",
|
||||
},
|
||||
{
|
||||
name: "attendance.manage",
|
||||
display_name: "Správa docházky",
|
||||
module: "attendance",
|
||||
description: "Spravovat všechny záznamy, vytvářet/editovat/mazat",
|
||||
},
|
||||
{
|
||||
name: "attendance.balances",
|
||||
display_name: "Správa bilancí",
|
||||
module: "attendance",
|
||||
description: "Spravovat zůstatky dovolené a sick days",
|
||||
},
|
||||
|
||||
// Kniha jízd
|
||||
{
|
||||
name: "trips.record",
|
||||
display_name: "Záznam jízd",
|
||||
module: "trips",
|
||||
description: "Zapisovat jízdy",
|
||||
},
|
||||
{
|
||||
name: "trips.history",
|
||||
display_name: "Historie jízd",
|
||||
module: "trips",
|
||||
description: "Prohlížet vlastní historii jízd",
|
||||
},
|
||||
{
|
||||
name: "trips.manage",
|
||||
display_name: "Správa jízd",
|
||||
module: "trips",
|
||||
description: "Spravovat všechny jízdy",
|
||||
},
|
||||
|
||||
// Vozidla
|
||||
{
|
||||
name: "vehicles.manage",
|
||||
display_name: "Správa vozidel",
|
||||
module: "vehicles",
|
||||
description: "Spravovat vozový park",
|
||||
},
|
||||
|
||||
// Nabídky
|
||||
{
|
||||
name: "offers.view",
|
||||
display_name: "Zobrazit nabídky",
|
||||
module: "offers",
|
||||
description: "Prohlížet seznam nabídek",
|
||||
},
|
||||
{
|
||||
name: "offers.create",
|
||||
display_name: "Vytvořit nabídku",
|
||||
module: "offers",
|
||||
description: "Vytvářet nové nabídky",
|
||||
},
|
||||
{
|
||||
name: "offers.edit",
|
||||
display_name: "Upravit nabídku",
|
||||
module: "offers",
|
||||
description: "Upravovat existující nabídky",
|
||||
},
|
||||
{
|
||||
name: "offers.delete",
|
||||
display_name: "Smazat nabídku",
|
||||
module: "offers",
|
||||
description: "Mazat nabídky",
|
||||
},
|
||||
{
|
||||
name: "offers.export",
|
||||
display_name: "Exportovat nabídku",
|
||||
module: "offers",
|
||||
description: "Exportovat nabídky do PDF",
|
||||
},
|
||||
|
||||
// Objednávky
|
||||
{
|
||||
name: "orders.view",
|
||||
display_name: "Zobrazit objednávky",
|
||||
module: "orders",
|
||||
description: "Prohlížet seznam objednávek",
|
||||
},
|
||||
{
|
||||
name: "orders.create",
|
||||
display_name: "Vytvořit objednávku",
|
||||
module: "orders",
|
||||
description: "Vytvářet nové objednávky",
|
||||
},
|
||||
{
|
||||
name: "orders.edit",
|
||||
display_name: "Upravit objednávku",
|
||||
module: "orders",
|
||||
description: "Upravovat existující objednávky",
|
||||
},
|
||||
{
|
||||
name: "orders.delete",
|
||||
display_name: "Smazat objednávku",
|
||||
module: "orders",
|
||||
description: "Mazat objednávky",
|
||||
},
|
||||
{
|
||||
name: "orders.export",
|
||||
display_name: "Exportovat objednávku",
|
||||
module: "orders",
|
||||
description: "Exportovat objednávky do PDF",
|
||||
},
|
||||
|
||||
// Faktury
|
||||
{
|
||||
name: "invoices.view",
|
||||
display_name: "Zobrazit faktury",
|
||||
module: "invoices",
|
||||
description: "Prohlížet seznam faktur",
|
||||
},
|
||||
{
|
||||
name: "invoices.create",
|
||||
display_name: "Vytvořit fakturu",
|
||||
module: "invoices",
|
||||
description: "Vytvářet nové faktury",
|
||||
},
|
||||
{
|
||||
name: "invoices.edit",
|
||||
display_name: "Upravit fakturu",
|
||||
module: "invoices",
|
||||
description: "Upravovat existující faktury",
|
||||
},
|
||||
{
|
||||
name: "invoices.delete",
|
||||
display_name: "Smazat fakturu",
|
||||
module: "invoices",
|
||||
description: "Mazat faktury",
|
||||
},
|
||||
{
|
||||
name: "invoices.export",
|
||||
display_name: "Exportovat fakturu",
|
||||
module: "invoices",
|
||||
description: "Exportovat faktury do PDF",
|
||||
},
|
||||
|
||||
// Projekty
|
||||
{
|
||||
name: "projects.view",
|
||||
display_name: "Zobrazit projekty",
|
||||
module: "projects",
|
||||
description: "Prohlížet seznam projektů",
|
||||
},
|
||||
{
|
||||
name: "projects.create",
|
||||
display_name: "Vytvořit projekt",
|
||||
module: "projects",
|
||||
description: "Ručně vytvářet projekty",
|
||||
},
|
||||
{
|
||||
name: "projects.edit",
|
||||
display_name: "Upravit projekt",
|
||||
module: "projects",
|
||||
description: "Upravovat existující projekty",
|
||||
},
|
||||
{
|
||||
name: "projects.delete",
|
||||
display_name: "Smazat projekt",
|
||||
module: "projects",
|
||||
description: "Mazat projekty",
|
||||
},
|
||||
{
|
||||
name: "projects.files",
|
||||
display_name: "Soubory projektu",
|
||||
module: "projects",
|
||||
description: "Spravovat soubory a složky projektu",
|
||||
},
|
||||
|
||||
// Zákazníci
|
||||
{
|
||||
name: "customers.view",
|
||||
display_name: "Zobrazit zákazníky",
|
||||
module: "customers",
|
||||
description: "Prohlížet seznam zákazníků",
|
||||
},
|
||||
{
|
||||
name: "customers.create",
|
||||
display_name: "Vytvořit zákazníka",
|
||||
module: "customers",
|
||||
description: "Vytvářet nové zákazníky",
|
||||
},
|
||||
{
|
||||
name: "customers.edit",
|
||||
display_name: "Upravit zákazníka",
|
||||
module: "customers",
|
||||
description: "Upravovat existující zákazníky",
|
||||
},
|
||||
{
|
||||
name: "customers.delete",
|
||||
display_name: "Smazat zákazníka",
|
||||
module: "customers",
|
||||
description: "Mazat zákazníky",
|
||||
},
|
||||
|
||||
// Uživatelé
|
||||
{
|
||||
name: "users.view",
|
||||
display_name: "Zobrazit uživatele",
|
||||
module: "users",
|
||||
description: "Prohlížet seznam uživatelů",
|
||||
},
|
||||
{
|
||||
name: "users.create",
|
||||
display_name: "Vytvořit uživatele",
|
||||
module: "users",
|
||||
description: "Vytvářet nové uživatele",
|
||||
},
|
||||
{
|
||||
name: "users.edit",
|
||||
display_name: "Upravit uživatele",
|
||||
module: "users",
|
||||
description: "Upravovat existující uživatele",
|
||||
},
|
||||
{
|
||||
name: "users.delete",
|
||||
display_name: "Smazat uživatele",
|
||||
module: "users",
|
||||
description: "Mazat uživatele",
|
||||
},
|
||||
|
||||
// Nastavení
|
||||
{
|
||||
name: "settings.company",
|
||||
display_name: "Nastavení společnosti",
|
||||
module: "settings",
|
||||
description: "Upravovat údaje o společnosti, logo",
|
||||
},
|
||||
{
|
||||
name: "settings.system",
|
||||
display_name: "Systémová nastavení",
|
||||
module: "settings",
|
||||
description: "Spravovat systémová nastavení, 2FA, e-maily, limity",
|
||||
},
|
||||
{
|
||||
name: "settings.banking",
|
||||
display_name: "Bankovní účty",
|
||||
module: "settings",
|
||||
description: "Spravovat bankovní účty společnosti",
|
||||
},
|
||||
{
|
||||
name: "settings.roles",
|
||||
display_name: "Role a oprávnění",
|
||||
module: "settings",
|
||||
description: "Spravovat role a přiřazovat oprávnění",
|
||||
},
|
||||
{
|
||||
name: "settings.templates",
|
||||
display_name: "Šablony",
|
||||
module: "settings",
|
||||
description: "Spravovat šablony nabídek a položek",
|
||||
},
|
||||
{
|
||||
name: "settings.audit",
|
||||
display_name: "Audit log",
|
||||
module: "settings",
|
||||
description: "Prohlížet auditní logy",
|
||||
},
|
||||
|
||||
// Sklad
|
||||
{
|
||||
name: "warehouse.view",
|
||||
display_name: "Zobrazit sklad",
|
||||
module: "warehouse",
|
||||
description: "Prohlížet stav skladu, položky, reporty a historii pohybů",
|
||||
},
|
||||
{
|
||||
name: "warehouse.operate",
|
||||
display_name: "Příjem a výdej",
|
||||
module: "warehouse",
|
||||
description: "Vytvářet a potvrzovat příjmy, výdeje a rezervace",
|
||||
},
|
||||
{
|
||||
name: "warehouse.manage",
|
||||
display_name: "Správa skladu",
|
||||
module: "warehouse",
|
||||
description: "Spravovat katalog materiálů, dodavatele, lokace a kategorie",
|
||||
},
|
||||
{
|
||||
name: "warehouse.inventory",
|
||||
display_name: "Inventura",
|
||||
module: "warehouse",
|
||||
description: "Vytvářet a potvrzovat inventurní sčítkání",
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding permissions...");
|
||||
|
||||
// 1. Clear existing permissions and re-insert current set
|
||||
await prisma.role_permissions.deleteMany({});
|
||||
await prisma.permissions.deleteMany({});
|
||||
console.log(" Cleared old permissions");
|
||||
|
||||
for (const perm of PERMISSIONS) {
|
||||
await prisma.permissions.create({ data: perm });
|
||||
}
|
||||
console.log(` Inserted ${PERMISSIONS.length} permissions`);
|
||||
|
||||
// 2. Ensure admin role exists
|
||||
let adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||
if (!adminRole) {
|
||||
adminRole = await prisma.roles.create({
|
||||
data: {
|
||||
name: "admin",
|
||||
display_name: "Administrátor",
|
||||
description: "Hlavní administrátor s plným přístupem",
|
||||
},
|
||||
});
|
||||
console.log(" Created admin role");
|
||||
} else {
|
||||
console.log(" Admin role already exists (id=" + adminRole.id + ")");
|
||||
}
|
||||
|
||||
// 3. Assign all permissions to admin role
|
||||
const allPerms = await prisma.permissions.findMany();
|
||||
const existingAssignments = await prisma.role_permissions.findMany({
|
||||
where: { role_id: adminRole.id },
|
||||
});
|
||||
|
||||
// Delete stale assignments (permissions no longer in the list)
|
||||
const validIds = new Set(allPerms.map((p) => p.id));
|
||||
const staleIds = existingAssignments.filter(
|
||||
(a) => !validIds.has(a.permission_id),
|
||||
);
|
||||
if (staleIds.length > 0) {
|
||||
for (const s of staleIds) {
|
||||
await prisma.role_permissions.delete({
|
||||
where: {
|
||||
role_id_permission_id: {
|
||||
role_id: adminRole.id,
|
||||
permission_id: s.permission_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` Removed ${staleIds.length} stale role_permissions`);
|
||||
}
|
||||
|
||||
// Add missing assignments
|
||||
const existingIds = new Set(existingAssignments.map((a) => a.permission_id));
|
||||
let added = 0;
|
||||
for (const perm of allPerms) {
|
||||
if (!existingIds.has(perm.id)) {
|
||||
await prisma.role_permissions.create({
|
||||
data: { role_id: adminRole.id, permission_id: perm.id },
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (added > 0) console.log(` Added ${added} role_permission assignments`);
|
||||
console.log(` Admin role now has all ${allPerms.length} permissions`);
|
||||
|
||||
// 4. Ensure admin user exists
|
||||
let adminUser = await prisma.users.findFirst({
|
||||
where: { username: "admin" },
|
||||
});
|
||||
if (!adminUser) {
|
||||
const hash = bcrypt.hashSync("admin", 10);
|
||||
adminUser = await prisma.users.create({
|
||||
data: {
|
||||
username: "admin",
|
||||
email: "admin@boha.local",
|
||||
password_hash: hash,
|
||||
first_name: "Admin",
|
||||
last_name: "BOHA",
|
||||
role_id: adminRole.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
console.log(" Created admin user (admin / admin)");
|
||||
} else if (adminUser.role_id !== adminRole.id) {
|
||||
await prisma.users.update({
|
||||
where: { id: adminUser.id },
|
||||
data: { role_id: adminRole.id },
|
||||
});
|
||||
console.log(" Updated admin user role_id to admin role");
|
||||
} else {
|
||||
console.log(" Admin user already exists with correct role");
|
||||
}
|
||||
|
||||
// 5. Create default company_settings if not exists
|
||||
const existingSettings = await prisma.company_settings.findFirst();
|
||||
if (!existingSettings) {
|
||||
await prisma.company_settings.create({
|
||||
data: {
|
||||
company_name: "BOHA s.r.o.",
|
||||
default_currency: "CZK",
|
||||
default_vat_rate: 21.0,
|
||||
available_vat_rates: JSON.stringify([0, 10, 15, 21]),
|
||||
available_currencies: JSON.stringify(["CZK", "EUR"]),
|
||||
offer_number_pattern: "NAB-{YYYY}-{NNN}",
|
||||
order_number_pattern: "OBJ-{YYYY}-{NNN}",
|
||||
invoice_number_pattern: "FV-{YYYY}-{NNN}",
|
||||
break_threshold_hours: 6.0,
|
||||
break_duration_short: 15,
|
||||
break_duration_long: 30,
|
||||
clock_rounding_minutes: 15,
|
||||
require_2fa: false,
|
||||
max_login_attempts: 5,
|
||||
lockout_minutes: 15,
|
||||
max_requests_per_minute: 300,
|
||||
},
|
||||
});
|
||||
console.log(" Created default company_settings");
|
||||
} else {
|
||||
console.log(" company_settings already exists");
|
||||
}
|
||||
|
||||
// 6. Initialize number_sequences for current year
|
||||
const year = new Date().getFullYear();
|
||||
const types = [
|
||||
"quotation",
|
||||
"order",
|
||||
"invoice",
|
||||
"warehouse_receipt",
|
||||
"warehouse_issue",
|
||||
"warehouse_inventory",
|
||||
];
|
||||
for (const type of types) {
|
||||
const existing = await prisma.number_sequences.findFirst({
|
||||
where: { type, year },
|
||||
});
|
||||
if (!existing) {
|
||||
await prisma.number_sequences.create({
|
||||
data: { type, year, last_number: 0 },
|
||||
});
|
||||
console.log(` Created number_sequence: ${type}/${year}`);
|
||||
}
|
||||
}
|
||||
console.log(" number_sequences ready");
|
||||
|
||||
console.log(
|
||||
"\nSeed complete — " +
|
||||
PERMISSIONS.length +
|
||||
" permissions, 1 admin role, company_settings + number_sequences ready.",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("Seed failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/__tests__/auth.service.test.ts
Normal file
51
src/__tests__/auth.service.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { verifyAccessToken, hashToken } from "../services/auth";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { config } from "../config/env";
|
||||
|
||||
describe("auth service", () => {
|
||||
describe("verifyAccessToken", () => {
|
||||
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const result = await verifyAccessToken("invalid-token");
|
||||
expect(result).toBeNull();
|
||||
// Invalid/expired access tokens are an expected condition (the client
|
||||
// refreshes on the resulting 401), so verifyAccessToken deliberately
|
||||
// does NOT log them as errors — only genuinely unexpected failures.
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null WITHOUT logging for an expired JWT", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const expiredToken = jwt.sign(
|
||||
{ sub: 1, username: "test", role: "user" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: -1 },
|
||||
);
|
||||
const result = await verifyAccessToken(expiredToken);
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashToken", () => {
|
||||
it("produces deterministic SHA-256 hex output", () => {
|
||||
const t1 = hashToken("hello");
|
||||
const t2 = hashToken("hello");
|
||||
expect(t1).toBe(t2);
|
||||
expect(t1).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("produces different hashes for different inputs", () => {
|
||||
const t1 = hashToken("a");
|
||||
const t2 = hashToken("b");
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
});
|
||||
19
src/__tests__/customers.schema.test.ts
Normal file
19
src/__tests__/customers.schema.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { UpdateCustomerSchema } from "../schemas/customers.schema";
|
||||
|
||||
describe("UpdateCustomerSchema", () => {
|
||||
it("rejects empty name", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ name: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid name", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ name: "Acme Corp" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts partial updates without name", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
34
src/__tests__/env.test.ts
Normal file
34
src/__tests__/env.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { config } from "../config/env";
|
||||
|
||||
describe("env validation", () => {
|
||||
it("has numeric port within valid range", () => {
|
||||
expect(typeof config.port).toBe("number");
|
||||
expect(config.port).toBeGreaterThan(0);
|
||||
expect(config.port).toBeLessThanOrEqual(65535);
|
||||
});
|
||||
|
||||
it("has JWT_SECRET defined", () => {
|
||||
expect(config.jwt.secret).toBeTruthy();
|
||||
expect(config.jwt.secret.length).toBeGreaterThanOrEqual(32);
|
||||
});
|
||||
|
||||
it("has TOTP_ENCRYPTION_KEY defined", () => {
|
||||
expect(config.totp.encryptionKey).toBeTruthy();
|
||||
expect(config.totp.encryptionKey.length).toBeGreaterThanOrEqual(32);
|
||||
});
|
||||
|
||||
it("has positive JWT expiry values", () => {
|
||||
expect(config.jwt.accessTokenExpiry).toBeGreaterThan(0);
|
||||
expect(config.jwt.refreshTokenSessionExpiry).toBeGreaterThan(0);
|
||||
expect(config.jwt.refreshTokenRememberExpiry).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has positive maxUploadSize", () => {
|
||||
expect(config.nas.maxUploadSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has DATABASE_URL defined", () => {
|
||||
expect(config.db.url).toBeTruthy();
|
||||
});
|
||||
});
|
||||
64
src/__tests__/exchange-rates.test.ts
Normal file
64
src/__tests__/exchange-rates.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { toCzk, getRate } from "../services/exchange-rates";
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("exchange-rates", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("toCzk", () => {
|
||||
it("returns amount unchanged for CZK", async () => {
|
||||
const result = await toCzk(123.45, "CZK");
|
||||
expect(result).toBe(123.45);
|
||||
});
|
||||
|
||||
it("throws for unknown currency when API fails and no cache", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
await expect(toCzk(100, "XYZ")).rejects.toThrow(
|
||||
/Nepodařilo se získat aktuální kurzy/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for unknown currency even when API succeeds", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
||||
}),
|
||||
});
|
||||
await expect(toCzk(100, "XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
||||
});
|
||||
|
||||
it("converts EUR using fetched rate", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
||||
}),
|
||||
});
|
||||
const result = await toCzk(100, "EUR");
|
||||
expect(result).toBe(2500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRate", () => {
|
||||
it("returns 1 for CZK", async () => {
|
||||
const result = await getRate("CZK");
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("throws for unknown currency", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
||||
}),
|
||||
});
|
||||
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/__tests__/invoices.service.test.ts
Normal file
55
src/__tests__/invoices.service.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||
|
||||
describe("invoiceTotalWithVat", () => {
|
||||
it("calculates subtotal without VAT when apply_vat is false", () => {
|
||||
const inv = {
|
||||
apply_vat: false,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: [
|
||||
{
|
||||
quantity: { toNumber: () => 2 },
|
||||
unit_price: { toNumber: () => 100 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(invoiceTotalWithVat(inv)).toBe(200);
|
||||
});
|
||||
|
||||
it("rounds each line VAT to 2 decimals before accumulation", () => {
|
||||
// 3 items @ 33.33 with 21% VAT
|
||||
// Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
|
||||
// Total VAT = 7.00 * 3 = 21.00
|
||||
// Subtotal = 33.33 * 3 = 99.99
|
||||
// Total = 99.99 + 21.00 = 120.99
|
||||
const inv = {
|
||||
apply_vat: true,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: Array.from({ length: 3 }, () => ({
|
||||
quantity: { toNumber: () => 1 },
|
||||
unit_price: { toNumber: () => 33.33 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
})),
|
||||
};
|
||||
expect(invoiceTotalWithVat(inv)).toBe(120.99);
|
||||
});
|
||||
|
||||
it("handles null quantity and unit_price gracefully", () => {
|
||||
const inv = {
|
||||
apply_vat: true,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: [
|
||||
{
|
||||
quantity: { toNumber: () => 0 },
|
||||
unit_price: { toNumber: () => 0 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(invoiceTotalWithVat(inv)).toBe(0);
|
||||
});
|
||||
});
|
||||
142
src/__tests__/manual-create.test.ts
Normal file
142
src/__tests__/manual-create.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createProject } from "../services/projects.service";
|
||||
import { createOrder } from "../services/orders.service";
|
||||
|
||||
const createdProjectIds: number[] = [];
|
||||
const createdOrderIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdProjectIds)
|
||||
await prisma.projects.deleteMany({ where: { id } });
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
createdProjectIds.length = 0;
|
||||
createdOrderIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
describe("createOrder (manual, optional linked project)", () => {
|
||||
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
scope_title: "Ruční zakázka",
|
||||
create_project: true,
|
||||
});
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
expect(res.project_id).toBeTruthy();
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
expect(project?.project_number).toBe(res.order_number);
|
||||
expect(project?.order_id).toBe(res.id);
|
||||
});
|
||||
|
||||
it("creates only the order when create_project=false", async () => {
|
||||
const res = await createOrder({ ...baseOrder, create_project: false });
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
expect(res.project_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared pool coherence (tracking)", () => {
|
||||
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||
if ("id" in o1) {
|
||||
createdOrderIds.push(o1.id);
|
||||
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||
}
|
||||
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||
if ("project_number" in p) createdProjectIds.push(p.id);
|
||||
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||
if ("id" in o2) createdOrderIds.push(o2.id);
|
||||
|
||||
const n1 = "id" in o1 ? o1.order_number : null;
|
||||
const np = "project_number" in p ? p.project_number : null;
|
||||
const n2 = "id" in o2 ? o2.order_number : null;
|
||||
expect(new Set([n1, np, n2]).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProject (manual, standalone)", () => {
|
||||
it("assigns a shared number and is not linked to an order", async () => {
|
||||
const result = await createProject({
|
||||
name: "Test projekt",
|
||||
status: "aktivni",
|
||||
});
|
||||
expect("project_number" in result).toBe(true);
|
||||
if (!("project_number" in result)) return;
|
||||
createdProjectIds.push(result.id);
|
||||
expect(typeof result.project_number).toBe("string");
|
||||
expect(result.project_number!.length).toBeGreaterThan(0);
|
||||
expect(result.order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("gives consecutive standalone projects distinct numbers", async () => {
|
||||
const a = await createProject({ name: "Projekt A", status: "aktivni" });
|
||||
const b = await createProject({ name: "Projekt B", status: "aktivni" });
|
||||
if ("project_number" in a) createdProjectIds.push(a.id);
|
||||
if ("project_number" in b) createdProjectIds.push(b.id);
|
||||
expect("project_number" in a && "project_number" in b).toBe(true);
|
||||
if ("project_number" in a && "project_number" in b) {
|
||||
expect(a.project_number).not.toBe(b.project_number);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrder with price line item + attachment", () => {
|
||||
it("stores a single line item with the entered price", async () => {
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
create_project: false,
|
||||
items: [
|
||||
{
|
||||
description: "Práce",
|
||||
quantity: 1,
|
||||
unit_price: 12345,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
const items = await prisma.order_items.findMany({
|
||||
where: { order_id: res.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(Number(items[0].unit_price)).toBe(12345);
|
||||
});
|
||||
|
||||
it("stores an uploaded PO attachment", async () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake");
|
||||
const res = await createOrder(
|
||||
{ ...baseOrder, create_project: false },
|
||||
buf,
|
||||
"po.pdf",
|
||||
);
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id: res.id },
|
||||
select: { attachment_name: true, attachment_data: true },
|
||||
});
|
||||
expect(order?.attachment_name).toBe("po.pdf");
|
||||
expect(order?.attachment_data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
55
src/__tests__/nas-file-manager.test.ts
Normal file
55
src/__tests__/nas-file-manager.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NasFileManager } from "../services/nas-file-manager";
|
||||
|
||||
describe("NasFileManager path traversal", () => {
|
||||
const nas = new NasFileManager();
|
||||
|
||||
describe("deleteItem", () => {
|
||||
it("rejects empty path", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects root path /", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "/");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects current directory .", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", ".");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects current directory ./", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "./");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects path traversal ..", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "../etc/passwd");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem", () => {
|
||||
it("rejects empty fromPath", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", "", "dest");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects root fromPath /", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", "/", "dest");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects current directory .", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", ".", "dest");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects path traversal in fromPath", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", "../secret", "dest");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
});
|
||||
});
|
||||
});
|
||||
70
src/__tests__/nas-project-folder.test.ts
Normal file
70
src/__tests__/nas-project-folder.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { NasFileManager } from "../services/nas-file-manager";
|
||||
|
||||
/**
|
||||
* Folder auto-creation primitive used by both the manual project-create path
|
||||
* (projects.service createProject) and the order→project paths (orders.service
|
||||
* createOrder / createOrderFromQuotation). Uses an isolated temp dir via the
|
||||
* constructor seam so it is deterministic and passes even where the real NAS
|
||||
* (Z:) is not mounted.
|
||||
*/
|
||||
describe("NasFileManager.createProjectFolder", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasFileManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-proj-"));
|
||||
nas = new NasFileManager(tmpBase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates a <number>_<name> folder for a new project", () => {
|
||||
const ok = nas.createProjectFolder("26710001", "Rekonstrukce haly");
|
||||
expect(ok).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpBase, "26710001_Rekonstrukce_haly")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent — a second call does not create a duplicate", () => {
|
||||
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
|
||||
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
|
||||
const matches = fs
|
||||
.readdirSync(tmpBase)
|
||||
.filter((e) => e.startsWith("26710002_"));
|
||||
expect(matches).toEqual(["26710002_Hala"]);
|
||||
});
|
||||
|
||||
it("preserves Czech diacritics (no transliteration)", () => {
|
||||
expect(nas.createProjectFolder("26710003", "Měření haly")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710003_Měření_haly"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("strips illegal characters and collapses spaces", () => {
|
||||
expect(nas.createProjectFolder("26710004", "A / B: C")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710004_A_B_C"))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles an empty name without crashing (trailing underscore)", () => {
|
||||
expect(nas.createProjectFolder("26710005", "")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710005_"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false and creates nothing when the NAS base is not mounted", () => {
|
||||
const missing = new NasFileManager(
|
||||
path.join(tmpBase, "does-not-exist-subdir"),
|
||||
);
|
||||
expect(missing.createProjectFolder("26710006", "X")).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpBase, "does-not-exist-subdir"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,134 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
generateOfferNumber,
|
||||
previewSharedNumber,
|
||||
isSharedNumberTaken,
|
||||
previewOfferNumber,
|
||||
isOfferNumberTaken,
|
||||
} from "../services/numbering.service";
|
||||
import prisma from "../config/database";
|
||||
|
||||
describe("generateSharedNumber", () => {
|
||||
it("returns correct format (YYtypeCode + 4 digits)", async () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
it("returns a non-empty string", async () => {
|
||||
const num = await generateSharedNumber();
|
||||
const yy = String(new Date().getFullYear()).slice(-2);
|
||||
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
|
||||
expect(typeof num).toBe("string");
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
const num1 = await generateSharedNumber();
|
||||
const num2 = await generateSharedNumber();
|
||||
expect(num1).not.toBe(num2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOfferNumber", () => {
|
||||
it("returns correct format (YEAR/PREFIX/NNN)", async () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
it("returns a non-empty string", async () => {
|
||||
const num = await generateOfferNumber();
|
||||
const year = new Date().getFullYear();
|
||||
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
|
||||
expect(typeof num).toBe("string");
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
const num1 = await generateOfferNumber();
|
||||
const num2 = await 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);
|
||||
});
|
||||
});
|
||||
|
||||
886
src/__tests__/plan.test.ts
Normal file
886
src/__tests__/plan.test.ts
Normal file
@@ -0,0 +1,886 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import planRoutes from "../routes/admin/plan";
|
||||
import {
|
||||
resolveCell,
|
||||
resolveGrid,
|
||||
listPlanUsers,
|
||||
assertNotPastDate,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
createOverride,
|
||||
updateOverride,
|
||||
deleteOverride,
|
||||
listEntries,
|
||||
listOverrides,
|
||||
} from "../services/plan.service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Note-prefix for test-created rows so we can clean them up safely. */
|
||||
const N = "wh_plan_";
|
||||
|
||||
let adminUserId: number;
|
||||
let noPermUserId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Pick the first admin user from the test database as our fixture.
|
||||
// The users -> roles relation is named `roles` in the Prisma schema
|
||||
// (users.role_id references roles.id).
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
|
||||
// For list-scoping tests: any non-admin user that is NOT the admin we use
|
||||
// for create tests. Just pick the second user.
|
||||
const allUsers = await prisma.users.findMany({ take: 2 });
|
||||
noPermUserId =
|
||||
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up any leftover test data from previous runs. We only touch rows
|
||||
// whose `note` contains our test prefix, so we never disturb real data.
|
||||
await prisma.work_plan_entries.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
await prisma.work_plan_overrides.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Final cleanup so we never leave soft-deleted test rows behind.
|
||||
await prisma.work_plan_entries.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
await prisma.work_plan_overrides.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveCell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.resolveCell", () => {
|
||||
it("returns null when nothing covers the date", async () => {
|
||||
// No entry, no override for (adminUserId, "2099-01-01")
|
||||
const result = await resolveCell(adminUserId, "2099-01-01");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the entry that covers the date", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}PLC upgrade`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("entry");
|
||||
expect(result?.note).toBe(`${N}PLC upgrade`);
|
||||
expect(result?.category).toBe("work");
|
||||
expect(result?.rangeFrom).toBe("2099-06-01");
|
||||
expect(result?.rangeTo).toBe("2099-06-10");
|
||||
});
|
||||
|
||||
it("returns the override for that day, not the entry", async () => {
|
||||
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
|
||||
// takes precedence.
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}PLC upgrade`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_overrides.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date("2099-06-05"),
|
||||
category: "leave",
|
||||
note: `${N}Volno po noční`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("override");
|
||||
expect(result?.note).toBe(`${N}Volno po noční`);
|
||||
expect(result?.category).toBe("leave");
|
||||
});
|
||||
|
||||
it("ignores soft-deleted entries and overrides", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}deleted entry`,
|
||||
is_deleted: true,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveGrid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.resolveGrid", () => {
|
||||
it("returns a 2D map keyed by userId then date", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-03"),
|
||||
category: "work",
|
||||
note: `${N}A`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-04"]).toBeNull();
|
||||
expect(cells[adminUserId]["2099-06-05"]).toBeNull();
|
||||
});
|
||||
|
||||
it("applies override on a covered day", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-03"),
|
||||
category: "work",
|
||||
note: `${N}A`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_overrides.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date("2099-06-02"),
|
||||
category: "leave",
|
||||
note: `${N}B`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listPlanUsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.listPlanUsers", () => {
|
||||
it("returns at least the admin user", async () => {
|
||||
const users = await listPlanUsers();
|
||||
const found = users.find((u) => u.id === adminUserId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.has_attendance_record).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assertNotPastDate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.assertNotPastDate", () => {
|
||||
it("returns error for a past date", () => {
|
||||
const result = assertNotPastDate("2000-01-01", false);
|
||||
expect(result).toEqual({
|
||||
error:
|
||||
"Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a future date", () => {
|
||||
const result = assertNotPastDate("2099-01-01", false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a past date with force=true", () => {
|
||||
const result = assertNotPastDate("2000-01-01", true);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.createEntry", () => {
|
||||
it("creates an entry and returns { data, oldData: null }", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-01",
|
||||
date_to: "2099-07-05",
|
||||
category: "work",
|
||||
note: `${N}new entry`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data.note).toBe(`${N}new entry`);
|
||||
expect(result.oldData).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a past date without force", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-02",
|
||||
category: "work",
|
||||
note: `${N}past`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows a past date with force", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-02",
|
||||
category: "work",
|
||||
note: `${N}past forced`,
|
||||
},
|
||||
adminUserId,
|
||||
true,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when date_to < date_from", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-10",
|
||||
date_to: "2099-07-05",
|
||||
category: "work",
|
||||
note: `${N}invalid range`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an empty note (text is optional)", async () => {
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-15",
|
||||
date_to: "2099-07-15",
|
||||
category: "work",
|
||||
note: "",
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a range whose date_to is in the past, even if date_from is today", async () => {
|
||||
// The range check `date_to >= date_from` fires before the past-date
|
||||
// check, so a backwards range returns 400 (correct UX). The to-endpoint
|
||||
// past-date check matters when updating an existing entry — see the
|
||||
// matching test in updateEntry below. Here we just confirm that a
|
||||
// range whose start is in the past is rejected as 403.
|
||||
const result = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-05",
|
||||
category: "work",
|
||||
note: `${N}past-both`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.updateEntry", () => {
|
||||
it("updates the note and returns the old data", async () => {
|
||||
const created = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-08-01",
|
||||
date_to: "2099-08-03",
|
||||
category: "work",
|
||||
note: `${N}original`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup failed");
|
||||
|
||||
const updated = await updateEntry(
|
||||
created.data.id,
|
||||
{ note: `${N}updated` },
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an update that pushes date_to into the past", async () => {
|
||||
// Future entry, but the user tries to extend its end backwards into
|
||||
// the past. The to-endpoint past-date check should fire.
|
||||
const created = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-08-10",
|
||||
date_to: "2099-08-15",
|
||||
category: "work",
|
||||
note: `${N}to-shorten`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup failed");
|
||||
const updated = await updateEntry(
|
||||
created.data.id,
|
||||
{ date_to: "2000-01-05" },
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in updated).toBe(true);
|
||||
if ("error" in updated) expect(updated.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.deleteEntry", () => {
|
||||
it("soft-deletes the row and returns the old data", async () => {
|
||||
const created = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-09-01",
|
||||
date_to: "2099-09-01",
|
||||
category: "work",
|
||||
note: `${N}to delete`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup failed");
|
||||
|
||||
const result = await deleteEntry(created.data.id, adminUserId, false);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
expect((result.oldData as any).note).toBe(`${N}to delete`);
|
||||
}
|
||||
|
||||
const stillThere = await prisma.work_plan_entries.findUnique({
|
||||
where: { id: created.data.id },
|
||||
});
|
||||
expect(stillThere?.is_deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOverride
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.createOverride", () => {
|
||||
it("creates an override and returns { data, oldData: null, replacedData: null }", async () => {
|
||||
const result = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-01",
|
||||
category: "leave",
|
||||
note: `${N}day off`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data.note).toBe(`${N}day off`);
|
||||
expect(result.oldData).toBeNull();
|
||||
expect(result.replacedData).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-deletes the existing override and reports it in replacedData", async () => {
|
||||
await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "leave",
|
||||
note: `${N}first`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const second = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "sick",
|
||||
note: `${N}second`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in second).toBe(true);
|
||||
if ("data" in second) {
|
||||
expect((second.replacedData as any)?.note).toBe(`${N}first`);
|
||||
}
|
||||
|
||||
const all = await prisma.work_plan_overrides.findMany({
|
||||
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
|
||||
});
|
||||
// The first should be soft-deleted, the second active
|
||||
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
|
||||
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a past date without force", async () => {
|
||||
const result = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2000-01-01",
|
||||
category: "leave",
|
||||
note: `${N}past`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateOverride
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.updateOverride", () => {
|
||||
it("updates the note and returns the old data", async () => {
|
||||
const created = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-11-01",
|
||||
category: "leave",
|
||||
note: `${N}original`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup");
|
||||
const updated = await updateOverride(
|
||||
created.data.id,
|
||||
{ note: `${N}updated` },
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteOverride
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.deleteOverride", () => {
|
||||
it("soft-deletes the override and returns the old data", async () => {
|
||||
const created = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-12-01",
|
||||
category: "leave",
|
||||
note: `${N}to delete`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
if (!("data" in created)) throw new Error("setup");
|
||||
const result = await deleteOverride(created.data.id, adminUserId, false);
|
||||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
expect((result.oldData as any).note).toBe(`${N}to delete`);
|
||||
}
|
||||
const still = await prisma.work_plan_overrides.findUnique({
|
||||
where: { id: created.data.id },
|
||||
});
|
||||
expect(still?.is_deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listEntries / listOverrides (employee scoping)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.listEntries (employee scoping)", () => {
|
||||
it("returns entries for the actor user when scoped", async () => {
|
||||
await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2098-01-01",
|
||||
date_to: "2098-01-05",
|
||||
category: "work",
|
||||
note: `${N}scope test`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const rows = await listEntries(
|
||||
{ user_id: adminUserId, date_from: "2098-01-01", date_to: "2098-01-31" },
|
||||
adminUserId,
|
||||
true, // isAdmin
|
||||
);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("scopes non-admin to actor user_id when querying for another user", async () => {
|
||||
await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2098-02-01",
|
||||
date_to: "2098-02-05",
|
||||
category: "work",
|
||||
note: `${N}admin entry`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const rows = await listEntries(
|
||||
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
|
||||
noPermUserId,
|
||||
false, // not admin
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan.service.listOverrides (employee scoping)", () => {
|
||||
it("scopes non-admin to actor user_id", async () => {
|
||||
await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-01",
|
||||
category: "leave",
|
||||
note: `${N}admin override`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const rows = await listOverrides(
|
||||
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
|
||||
noPermUserId,
|
||||
false,
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP route tests (Fastify inject)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a Fastify app with only the plan routes registered under
|
||||
// /api/admin/plan. rateLimit max=1000 keeps the 7 tests from tripping it.
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminToken: string;
|
||||
let noPermToken: string;
|
||||
let noPermRoleId: number;
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(planRoutes, { prefix: "/api/admin/plan" });
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT access token. `requireAuth` re-loads auth data from the
|
||||
* DB via `loadAuthData(payload.sub)`, so the `role` claim is ignored —
|
||||
* only the `sub` (user id) matters for these tests.
|
||||
*/
|
||||
function generateToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function authGet(path: string, token: string) {
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: path,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function authPost(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function authPatch(path: string, token: string, body: unknown) {
|
||||
return app.inject({
|
||||
method: "PATCH",
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: body as object,
|
||||
});
|
||||
}
|
||||
|
||||
async function authDelete(path: string, token: string) {
|
||||
return app.inject({
|
||||
method: "DELETE",
|
||||
url: path,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Re-fetch the admin user with the roles relation so we can build a token.
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("admin user not found in test setup");
|
||||
adminUserId = admin.id;
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
// Create a no-permission role + user; the role has zero permissions so
|
||||
// any permission check returns 403.
|
||||
const stamp = Date.now();
|
||||
const noPermRole = await prisma.roles.create({
|
||||
data: {
|
||||
name: `noperm_plan_${stamp}`,
|
||||
display_name: "No Perm Plan Test",
|
||||
},
|
||||
});
|
||||
noPermRoleId = noPermRole.id;
|
||||
const noPermUser = await prisma.users.create({
|
||||
data: {
|
||||
username: `noperm_plan_${stamp}`,
|
||||
first_name: "No",
|
||||
last_name: "Perm",
|
||||
email: `noperm_plan_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: noPermRole.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
noPermUserId = noPermUser.id;
|
||||
noPermToken = generateToken({
|
||||
id: noPermUser.id,
|
||||
username: noPermUser.username,
|
||||
roleName: noPermRole.name,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
if (noPermUserId) {
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: noPermUserId } })
|
||||
.catch(() => {});
|
||||
}
|
||||
if (noPermRoleId) {
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: noPermRoleId } })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe("HTTP /api/admin/plan", () => {
|
||||
it("GET /users requires auth", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/plan/users",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("GET /users returns plan users for an admin", async () => {
|
||||
const res = await authGet("/api/admin/plan/users", adminToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("GET /grid returns days, users, cells", async () => {
|
||||
// Seed an entry so the cell on 2097-06-01 has a known note.
|
||||
await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-06-01",
|
||||
date_to: "2097-06-05",
|
||||
category: "work",
|
||||
note: `${N}grid test`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const res = await authGet(
|
||||
"/api/admin/plan/grid?date_from=2097-06-01&date_to=2097-06-05",
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.users).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe(
|
||||
`${N}grid test`,
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /grid rejects ranges > 92 days", async () => {
|
||||
const res = await authGet(
|
||||
"/api/admin/plan/grid?date_from=2097-01-01&date_to=2097-12-31",
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /entries requires attendance.manage", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", noPermToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-07-01",
|
||||
date_to: "2097-07-01",
|
||||
category: "work",
|
||||
note: `${N}forbidden`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /entries works for admin", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-08-01",
|
||||
date_to: "2097-08-01",
|
||||
category: "work",
|
||||
note: `${N}http create`,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.note).toBe(`${N}http create`);
|
||||
});
|
||||
|
||||
it("POST /entries accepts an empty note (note is optional)", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2097-08-05",
|
||||
date_to: "2097-08-05",
|
||||
category: "work",
|
||||
note: "",
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("POST /entries rejects a past date without ?force=1", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||
user_id: adminUserId,
|
||||
date_from: "2000-02-01",
|
||||
date_to: "2000-02-01",
|
||||
category: "work",
|
||||
note: `${N}past http`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /overrides with force=1 allows past date", async () => {
|
||||
const res = await authPost(
|
||||
"/api/admin/plan/overrides?force=1",
|
||||
adminToken,
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2000-01-15",
|
||||
category: "leave",
|
||||
note: `${N}forced past`,
|
||||
},
|
||||
);
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
95
src/__tests__/planAuditDescription.test.ts
Normal file
95
src/__tests__/planAuditDescription.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
planCategoryLabel,
|
||||
buildPlanAuditDescription,
|
||||
} from "../utils/planAuditDescription";
|
||||
|
||||
describe("planCategoryLabel", () => {
|
||||
it("maps known plan categories to Czech labels", () => {
|
||||
expect(planCategoryLabel("work")).toBe("Práce");
|
||||
expect(planCategoryLabel("preparation")).toBe("Příprava");
|
||||
expect(planCategoryLabel("travel")).toBe("Cesta / Montáž");
|
||||
expect(planCategoryLabel("leave")).toBe("Dovolená");
|
||||
expect(planCategoryLabel("sick")).toBe("Nemoc");
|
||||
expect(planCategoryLabel("training")).toBe("Školení");
|
||||
expect(planCategoryLabel("other")).toBe("Jiné");
|
||||
});
|
||||
|
||||
it("falls back to the raw value for an unknown category", () => {
|
||||
expect(planCategoryLabel("unknown_xyz")).toBe("unknown_xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPlanAuditDescription", () => {
|
||||
it("describes a multi-day entry with project", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: "Rekonstrukce haly",
|
||||
dateFrom: "2026-06-08",
|
||||
dateTo: "2026-06-12",
|
||||
});
|
||||
expect(desc).toBe(
|
||||
"Jan Novák · 08.06.2026 – 12.06.2026 · Práce · Rekonstrukce haly",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses a single-day range to one date", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: "Rekonstrukce haly",
|
||||
dateFrom: "2026-06-08",
|
||||
dateTo: "2026-06-08",
|
||||
});
|
||||
expect(desc).toBe("Jan Novák · 08.06.2026 · Práce · Rekonstrukce haly");
|
||||
});
|
||||
|
||||
it("omits the project segment when there is no project", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Petr Svoboda",
|
||||
categoryLabel: "Dovolená",
|
||||
projectName: null,
|
||||
dateFrom: "2026-06-10",
|
||||
dateTo: "2026-06-10",
|
||||
});
|
||||
expect(desc).toBe("Petr Svoboda · 10.06.2026 · Dovolená");
|
||||
});
|
||||
|
||||
it("appends the emergency-edit note when force is set", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: null,
|
||||
dateFrom: "2026-01-02",
|
||||
dateTo: "2026-01-02",
|
||||
force: true,
|
||||
});
|
||||
expect(desc).toBe("Jan Novák · 02.01.2026 · Práce · nouzová úprava");
|
||||
});
|
||||
|
||||
it("appends a custom suffix (e.g. replaced override)", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Nemoc",
|
||||
projectName: null,
|
||||
dateFrom: "2026-06-10",
|
||||
dateTo: "2026-06-10",
|
||||
suffix: "nahrazeno novým záznamem",
|
||||
});
|
||||
expect(desc).toBe(
|
||||
"Jan Novák · 10.06.2026 · Nemoc · nahrazeno novým záznamem",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a whitespace-only project name as no project", () => {
|
||||
const desc = buildPlanAuditDescription({
|
||||
userName: "Jan Novák",
|
||||
categoryLabel: "Práce",
|
||||
projectName: " ",
|
||||
dateFrom: "2026-06-08",
|
||||
dateTo: "2026-06-08",
|
||||
});
|
||||
expect(desc).toBe("Jan Novák · 08.06.2026 · Práce");
|
||||
});
|
||||
});
|
||||
129
src/__tests__/planCategory.test.ts
Normal file
129
src/__tests__/planCategory.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
listPlanCategories,
|
||||
createPlanCategory,
|
||||
updatePlanCategory,
|
||||
deactivatePlanCategory,
|
||||
deletePlanCategory,
|
||||
isCategoryKeyActive,
|
||||
slugifyCategory,
|
||||
} from "../services/planCategory.service";
|
||||
|
||||
const created: number[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("slugifyCategory", () => {
|
||||
it("strips Czech diacritics and non-alphanumerics", () => {
|
||||
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
|
||||
expect(slugifyCategory("Příprava")).toBe("priprava");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan category service", () => {
|
||||
it("seeds are present and ordered", async () => {
|
||||
const cats = await listPlanCategories(true);
|
||||
const keys = cats.map((c) => c.key);
|
||||
expect(keys).toContain("work");
|
||||
expect(keys).toContain("other");
|
||||
});
|
||||
|
||||
it("creates a category with a unique slug and next sort_order", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Kategorie ZZ",
|
||||
color: "#123456",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
expect(res.data.key).toBe("test_kategorie_zz");
|
||||
expect(res.data.color).toBe("#123456");
|
||||
expect(res.data.is_active).toBe(true);
|
||||
expect(await isCategoryKeyActive("test_kategorie_zz")).toBe(true);
|
||||
});
|
||||
|
||||
it("dedupes a colliding slug", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Kategorie ZZ",
|
||||
color: "#222222",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
expect(res.data.key).toBe("test_kategorie_zz_2");
|
||||
});
|
||||
|
||||
it("updates label and color (key immutable)", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Upd ZZ",
|
||||
color: "#aaaaaa",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
const upd = await updatePlanCategory(res.data.id, {
|
||||
label: "Test Upd2 ZZ",
|
||||
color: "#bbbbbb",
|
||||
});
|
||||
if ("error" in upd) throw new Error(upd.error);
|
||||
expect(upd.data.label).toBe("Test Upd2 ZZ");
|
||||
expect(upd.data.color).toBe("#bbbbbb");
|
||||
expect(upd.data.key).toBe("test_upd_zz");
|
||||
});
|
||||
|
||||
it("deactivate hides the key from active check", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Deact ZZ",
|
||||
color: "#cccccc",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
await deactivatePlanCategory(res.data.id);
|
||||
expect(await isCategoryKeyActive("test_deact_zz")).toBe(false);
|
||||
});
|
||||
|
||||
it("hard-deletes an unused category", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test Del ZZ",
|
||||
color: "#dddddd",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
const del = await deletePlanCategory(res.data.id);
|
||||
expect("data" in del).toBe(true);
|
||||
const stillThere = await prisma.plan_categories.findUnique({
|
||||
where: { id: res.data.id },
|
||||
});
|
||||
expect(stillThere).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to hard-delete a category in use", async () => {
|
||||
const res = await createPlanCategory({
|
||||
label: "Test InUse ZZ",
|
||||
color: "#eeeeee",
|
||||
});
|
||||
if ("error" in res) throw new Error(res.error);
|
||||
created.push(res.data.id);
|
||||
const user = await prisma.users.findFirst({ select: { id: true } });
|
||||
if (!user) throw new Error("no user in test DB");
|
||||
const entry = await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
date_from: new Date("2026-06-10T00:00:00.000Z"),
|
||||
date_to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
category: res.data.key,
|
||||
note: "test in-use",
|
||||
created_by: user.id,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const del = await deletePlanCategory(res.data.id);
|
||||
expect("error" in del).toBe(true);
|
||||
if ("error" in del) expect(del.status).toBe(409);
|
||||
} finally {
|
||||
await prisma.work_plan_entries.delete({ where: { id: entry.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
57
src/__tests__/schema-nan.test.ts
Normal file
57
src/__tests__/schema-nan.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CreateOrderSchema } from "../schemas/orders.schema";
|
||||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||
|
||||
describe("Zod NaN rejection", () => {
|
||||
describe("CreateOrderSchema", () => {
|
||||
it("rejects NaN string in quantity", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "not-a-number" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects NaN string in unit_price", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ unit_price: "abc" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid numbers", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: 2, unit_price: 100 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateQuotationSchema", () => {
|
||||
it("rejects NaN string in top-level vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: "bad",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: 21,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects NaN string in item quantity", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "bad" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
1244
src/__tests__/warehouse.test.ts
Normal file
1244
src/__tests__/warehouse.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,16 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
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 "./skeleton.css";
|
||||
import "./datepicker.css";
|
||||
import "./filemanager.css";
|
||||
import "./pagination.css";
|
||||
import "./responsive.css";
|
||||
import "./login.css";
|
||||
import "./dashboard.css";
|
||||
import "./attendance.css";
|
||||
import "./settings.css";
|
||||
import "./offers.css";
|
||||
import "./invoices.css";
|
||||
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
@@ -35,6 +21,7 @@ const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
||||
const PlanWork = lazy(() => import("./pages/PlanWork"));
|
||||
const Trips = lazy(() => import("./pages/Trips"));
|
||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
||||
@@ -46,77 +33,191 @@ const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
||||
const Orders = lazy(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
||||
const Projects = lazy(() => import("./pages/Projects"));
|
||||
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
|
||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
||||
const Settings = lazy(() => import("./pages/Settings"));
|
||||
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||
const Warehouse = lazy(() => import("./pages/Warehouse"));
|
||||
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
|
||||
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
|
||||
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
|
||||
const WarehouseReceiptDetail = lazy(
|
||||
() => import("./pages/WarehouseReceiptDetail"),
|
||||
);
|
||||
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
|
||||
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
|
||||
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
|
||||
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
|
||||
const WarehouseReservations = lazy(
|
||||
() => import("./pages/WarehouseReservations"),
|
||||
);
|
||||
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
|
||||
const WarehouseInventoryDetail = lazy(
|
||||
() => import("./pages/WarehouseInventoryDetail"),
|
||||
);
|
||||
const WarehouseInventoryForm = lazy(
|
||||
() => import("./pages/WarehouseInventoryForm"),
|
||||
);
|
||||
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>
|
||||
<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="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/new" element={<ProjectCreate />} />
|
||||
<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>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</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,434 +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-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-right: 0.25rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-vacation {
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-sick {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-holiday {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.attendance-leave-badge.badge-unpaid {
|
||||
background: var(--muted-light);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Working Status Badge */
|
||||
.attendance-working-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-working-badge.working {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.attendance-working-badge.finished {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* GPS Link */
|
||||
.attendance-gps-link {
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.attendance-gps-link:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Location Page */
|
||||
.attendance-location-map {
|
||||
height: 400px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
z-index: 0; /* stacking context - Leaflet z-indexy zustanou uvnitr */
|
||||
}
|
||||
|
||||
.attendance-location-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.attendance-location-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.attendance-location-card.empty {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.attendance-location-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-location-time {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attendance-location-address {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.attendance-location-coords {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -1,420 +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-color);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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,130 +0,0 @@
|
||||
/* ============================================================================
|
||||
Buttons
|
||||
============================================================================ */
|
||||
|
||||
.admin-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
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);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-primary:hover:not(:disabled) {
|
||||
background: 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-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;
|
||||
}
|
||||
}
|
||||
@@ -1,925 +0,0 @@
|
||||
/* ============================================================================
|
||||
Cards
|
||||
============================================================================ */
|
||||
|
||||
.admin-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.admin-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.admin-card-body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Badges
|
||||
============================================================================ */
|
||||
|
||||
.admin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 3px 9px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.admin-badge-wrap {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-badge-admin {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.admin-badge-viewer {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-badge-active {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.admin-badge-active:hover {
|
||||
background: color-mix(in srgb, var(--success) 20%, transparent);
|
||||
}
|
||||
|
||||
.admin-badge-inactive {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.admin-badge-inactive:hover {
|
||||
background: color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
}
|
||||
|
||||
.admin-badge-success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.admin-badge-warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.admin-badge-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-badge-info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-badge-danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Status Badges - Leave Requests */
|
||||
.badge-pending {
|
||||
background: color-mix(in srgb, var(--warning) 15%, transparent);
|
||||
color: var(--warning);
|
||||
}
|
||||
.badge-approved {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge-rejected {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.badge-cancelled {
|
||||
background: var(--muted-light);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Status Badges - Orders */
|
||||
.admin-badge-order-prijata {
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
}
|
||||
.admin-badge-order-realizace {
|
||||
background: color-mix(in srgb, var(--warning) 15%, transparent);
|
||||
color: var(--warning);
|
||||
}
|
||||
.admin-badge-order-dokoncena {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
.admin-badge-order-stornovana {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Status Badges - Projects */
|
||||
.admin-badge-project-aktivni {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
.admin-badge-project-dokonceny {
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
}
|
||||
.admin-badge-project-zruseny {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Badge on mobile - larger for touch */
|
||||
@media (max-width: 768px) {
|
||||
.admin-badge {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button.admin-badge {
|
||||
min-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Modals
|
||||
============================================================================ */
|
||||
|
||||
.admin-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
max-height: calc(100vh - 2rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
.admin-modal-lg {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.admin-modal-header {
|
||||
padding: 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-modal-body {
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.admin-modal-footer {
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-modal-overlay {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-modal,
|
||||
.admin-modal.admin-modal-lg {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
max-height: 100%;
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.admin-modal-header {
|
||||
padding: 1rem;
|
||||
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.admin-modal-body {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.admin-modal-footer {
|
||||
padding: 1rem;
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.admin-modal .admin-form-input,
|
||||
.admin-modal .admin-form-select,
|
||||
.admin-modal .admin-form-textarea {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirm Modal */
|
||||
.admin-confirm-modal {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.admin-confirm-content {
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.admin-confirm-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1.25rem;
|
||||
}
|
||||
|
||||
.admin-confirm-icon-danger {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-confirm-icon-warning {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.admin-confirm-icon-info {
|
||||
background: var(--info-light);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-confirm-icon-default {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-confirm-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-confirm-message {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-confirm-modal {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: calc(100% - 2rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
margin: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.admin-confirm-modal .admin-modal-footer {
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirm modal on small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.admin-confirm-content {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.admin-confirm-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.admin-confirm-message {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Toast Alerts
|
||||
============================================================================ */
|
||||
|
||||
.admin-alert-container {
|
||||
position: fixed;
|
||||
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 0.5rem;
|
||||
max-width: 400px;
|
||||
width: calc(100% - 2rem);
|
||||
pointer-events: none;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-alert-container {
|
||||
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
||||
right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.admin-toast-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-toast-message {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-toast-close {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.admin-toast-close:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-toast-success .admin-toast-icon {
|
||||
color: var(--success);
|
||||
}
|
||||
.admin-toast-error .admin-toast-icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
.admin-toast-warning .admin-toast-icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
.admin-toast-info .admin-toast-icon {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Tabs (Global)
|
||||
============================================================================ */
|
||||
|
||||
.admin-tabs {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 1px var(--border-color);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Empty State
|
||||
============================================================================ */
|
||||
|
||||
.admin-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-empty-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-empty-state p {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.admin-role-locked-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--warning-light);
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--warning);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Forbidden (403)
|
||||
============================================================================ */
|
||||
|
||||
.forbidden-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.forbidden-icon {
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.forbidden-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.forbidden-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
max-width: 400px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.forbidden-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.forbidden-link:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Stat Cards
|
||||
============================================================================ */
|
||||
|
||||
.admin-stat-card {
|
||||
position: relative;
|
||||
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: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--accent-color);
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.admin-stat-card.success::before {
|
||||
background: var(--success);
|
||||
}
|
||||
.admin-stat-card.warning::before {
|
||||
background: var(--warning);
|
||||
}
|
||||
.admin-stat-card.danger::before {
|
||||
background: var(--danger);
|
||||
}
|
||||
.admin-stat-card.info::before {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.admin-stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.admin-stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-stat-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-stat-footer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-stat-icon.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.admin-stat-icon.info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.admin-stat-icon.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.admin-stat-icon.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
KPI Grid
|
||||
============================================================================ */
|
||||
|
||||
.admin-kpi-grid {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-kpi-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.admin-kpi-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.admin-kpi-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.admin-kpi-1 {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.admin-kpi-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-kpi-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Editor Section Cards
|
||||
============================================================================ */
|
||||
|
||||
.admin-editor-section {
|
||||
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: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-editor-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Totals Summary
|
||||
============================================================================ */
|
||||
|
||||
.admin-totals-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.admin-totals-row {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: flex-end;
|
||||
min-width: 250px;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-totals-row span:last-child {
|
||||
min-width: 100px;
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-totals-total {
|
||||
border-top: 2px solid var(--text-primary);
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-totals-total span:last-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-totals-summary {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-totals-row {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Scope Sections
|
||||
============================================================================ */
|
||||
|
||||
.admin-scope-list {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-scope-section {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: visible;
|
||||
transition: border-color var(--transition);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.admin-scope-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-scope-section:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--border-color) 70%,
|
||||
var(--accent-color)
|
||||
);
|
||||
}
|
||||
|
||||
.admin-scope-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-scope-section-header .admin-scope-number {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-scope-section-header .admin-scope-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-scope-section-header .admin-scope-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-scope-section .admin-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Logo Section
|
||||
============================================================================ */
|
||||
|
||||
.admin-logo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-logo-preview {
|
||||
max-width: 200px;
|
||||
max-height: 100px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.admin-logo-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { forwardRef, useMemo } 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";
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
lang="cs"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="admin-form-input"
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
min={minDate || undefined}
|
||||
max={maxDate || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminDatePickerProps {
|
||||
mode?: "date" | "month" | "datetime" | "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 = useMemo(() => 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 commonProps = {
|
||||
selected: toDate(value),
|
||||
onChange: handleChange,
|
||||
locale: "cs",
|
||||
customInput: (
|
||||
<CustomInput
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
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,36 +8,15 @@ 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 ProjectLog {
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
project_name?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string | null;
|
||||
hours?: string | number | null;
|
||||
minutes?: string | number | null;
|
||||
}
|
||||
|
||||
interface AttendanceRecord {
|
||||
id: number;
|
||||
shift_date: string;
|
||||
user_name: string;
|
||||
leave_type?: string;
|
||||
leave_hours?: number;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
break_start?: string | null;
|
||||
break_end?: string | null;
|
||||
interface AttendanceRecord extends HookAttendanceRecord {
|
||||
arrival_lat?: number | string | null;
|
||||
arrival_lng?: number | string | null;
|
||||
departure_lat?: number | string | null;
|
||||
departure_lng?: number | string | null;
|
||||
project_name?: string;
|
||||
project_logs?: ProjectLog[];
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface AttendanceShiftTableProps {
|
||||
@@ -44,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)}`;
|
||||
@@ -51,60 +82,68 @@ function formatBreak(record: AttendanceRecord): string {
|
||||
if (record.break_start) {
|
||||
return `${formatTime(record.break_start)} - ?`;
|
||||
}
|
||||
return "\u2014";
|
||||
return "—";
|
||||
}
|
||||
|
||||
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,
|
||||
isActive = false;
|
||||
let durationValid = true;
|
||||
if (log.hours !== null && log.hours !== undefined) {
|
||||
h = parseInt(String(log.hours)) || 0;
|
||||
m = parseInt(String(log.minutes)) || 0;
|
||||
} else {
|
||||
isActive = !log.ended_at;
|
||||
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
||||
const mins = Math.floor(
|
||||
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
||||
);
|
||||
h = Math.floor(mins / 60);
|
||||
m = mins % 60;
|
||||
const start = log.started_at ? new Date(log.started_at) : null;
|
||||
if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
||||
const mins = Math.max(
|
||||
0,
|
||||
Math.floor((end.getTime() - start.getTime()) / 60000),
|
||||
);
|
||||
h = Math.floor(mins / 60);
|
||||
m = mins % 60;
|
||||
} else {
|
||||
durationValid = false;
|
||||
h = 0;
|
||||
m = 0;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span
|
||||
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 ? " \u25B8" : ""})
|
||||
</span>
|
||||
<StatusChip
|
||||
key={log.id ?? i}
|
||||
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 "\u2014";
|
||||
return "—";
|
||||
}
|
||||
|
||||
export default function AttendanceShiftTable({
|
||||
@@ -112,139 +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
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
||||
<td>{record.user_name}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatBreak(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? `${formatMinutes(workMinutes)} h`
|
||||
: "\u2014"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td>
|
||||
{hasLocation ? (
|
||||
<Link
|
||||
to={`/attendance/location/${record.id}`}
|
||||
className="attendance-gps-link"
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
>
|
||||
{"\uD83D\uDCCD"}
|
||||
</Link>
|
||||
) : (
|
||||
"\u2014"
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={record.notes || ""}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
onClick={() => onEdit(record)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(record)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable<AttendanceRecord>
|
||||
columns={columns}
|
||||
rows={records}
|
||||
rowKey={(record) => record.id}
|
||||
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
|
||||
|
||||
interface BulkAttendanceForm {
|
||||
month: string;
|
||||
@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
|
||||
}
|
||||
|
||||
interface BulkAttendanceModalProps {
|
||||
show: boolean;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
form: BulkAttendanceForm;
|
||||
setForm: (form: BulkAttendanceForm) => void;
|
||||
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
|
||||
}
|
||||
|
||||
export default function BulkAttendanceModal({
|
||||
show,
|
||||
isOpen,
|
||||
onClose,
|
||||
form,
|
||||
setForm,
|
||||
@@ -39,174 +39,137 @@ export default function BulkAttendanceModal({
|
||||
toggleUser,
|
||||
toggleAllUsers,
|
||||
}: BulkAttendanceModalProps) {
|
||||
useModalLock(show);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Vyplnit docházku za měsíc"
|
||||
maxWidth="lg"
|
||||
loading={submitting}
|
||||
onSubmit={onSubmit}
|
||||
submitText="Vyplnit měsíc"
|
||||
submitDisabled={form.user_ids.length === 0}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.25, mb: 2 }}
|
||||
>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
|
||||
Existující záznamy se přeskočí.
|
||||
</Typography>
|
||||
|
||||
<Field label="Měsíc">
|
||||
<MonthField
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => !submitting && onClose()}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
|
||||
označí. Existující záznamy se přeskočí.
|
||||
</p>
|
||||
</div>
|
||||
Zaměstnanci
|
||||
</Typography>
|
||||
<Typography
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
variant="caption"
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.25,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<CheckboxField
|
||||
key={user.id}
|
||||
label={user.name}
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Měsíc</label>
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Zaměstnanci
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent-color)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius-sm)",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<label key={user.id} className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<small className="admin-form-hint">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={submitting}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={submitting || form.user_ids.length === 0}
|
||||
>
|
||||
{submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Potvrdit",
|
||||
cancelText = "Zrušit",
|
||||
type = "default",
|
||||
confirmVariant,
|
||||
loading,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-confirm-modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-body admin-confirm-content">
|
||||
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
||||
<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>
|
||||
</div>
|
||||
<h2 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,28 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } 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) {
|
||||
return (
|
||||
<div className="admin-form-group" style={style}>
|
||||
<label className="admin-form-label">
|
||||
{label}
|
||||
{required && <span className="admin-form-required"> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error && <span className="admin-form-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
429
src/admin/components/OrderConfirmationModal.tsx
Normal file
429
src/admin/components/OrderConfirmationModal.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { useState, useCallback } from "react";
|
||||
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 {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}
|
||||
|
||||
interface OrderConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (
|
||||
lang: string,
|
||||
applyVat: boolean,
|
||||
items?: ConfirmationItem[],
|
||||
) => Promise<void>;
|
||||
initialItems: ConfirmationItem[];
|
||||
orderNumber: string;
|
||||
defaultVatRate: number;
|
||||
applyVat: boolean;
|
||||
}
|
||||
|
||||
export default function OrderConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGenerate,
|
||||
initialItems,
|
||||
orderNumber,
|
||||
defaultVatRate,
|
||||
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);
|
||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleUseExisting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onGenerate(lang, applyVatState, undefined);
|
||||
} catch (err) {
|
||||
console.error("Chyba při generování potvrzení:", err);
|
||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStep("choose");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onGenerate(lang, applyVatState, items);
|
||||
} catch (err) {
|
||||
console.error("Chyba při generování potvrzení:", err);
|
||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStep("choose");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const updateItem = useCallback(
|
||||
(
|
||||
index: number,
|
||||
field: keyof ConfirmationItem,
|
||||
value: string | number | boolean,
|
||||
) => {
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeItem = useCallback((index: number) => {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
description: "",
|
||||
quantity: 1,
|
||||
unit: "ks",
|
||||
unit_price: 0,
|
||||
is_included_in_total: true,
|
||||
vat_rate: defaultVatRate,
|
||||
},
|
||||
]);
|
||||
}, [defaultVatRate]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Potvrzení objednávky ${orderNumber}`}
|
||||
maxWidth={step === "edit" ? "lg" : "md"}
|
||||
loading={loading}
|
||||
submitText={
|
||||
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
|
||||
}
|
||||
submitDisabled={step === "edit" && items.length === 0}
|
||||
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
|
||||
>
|
||||
{step === "choose" ? (
|
||||
<Box>
|
||||
<Field label="Jazyk dokumentu">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={lang === "cs" ? "contained" : "outlined"}
|
||||
color={lang === "cs" ? "primary" : "inherit"}
|
||||
onClick={() => setLang("cs")}
|
||||
>
|
||||
Čeština
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={lang === "en" ? "contained" : "outlined"}
|
||||
color={lang === "en" ? "primary" : "inherit"}
|
||||
onClick={() => setLang("en")}
|
||||
>
|
||||
English
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<Field label="DPH">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={applyVatState ? "contained" : "outlined"}
|
||||
color={applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(true)}
|
||||
>
|
||||
S DPH
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={!applyVatState ? "contained" : "outlined"}
|
||||
color={!applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(false)}
|
||||
>
|
||||
Bez DPH
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<Field label="Obsah potvrzení">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setItems(initialItems.length > 0 ? initialItems : []);
|
||||
setStep("edit");
|
||||
}}
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Upravit položky
|
||||
</Button>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => setStep("choose")}
|
||||
disabled={loading}
|
||||
>
|
||||
Zpět
|
||||
</Button>
|
||||
</Box>
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||
>
|
||||
Položka {i + 1}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(i)}
|
||||
title="Odstranit"
|
||||
aria-label="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Popis"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(i, "unit", e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Cena"
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit_price", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="%DPH"
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
"& th": {
|
||||
textAlign: "left",
|
||||
fontSize: ".7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: ".05em",
|
||||
fontWeight: 700,
|
||||
color: "text.secondary",
|
||||
pb: 1,
|
||||
},
|
||||
"& td": { py: 0.5, pr: 1, verticalAlign: "middle" },
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<TextField
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 90 }}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit", e.target.value)
|
||||
}
|
||||
sx={{ width: 70 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 110 }}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(i)}
|
||||
title="Odstranit"
|
||||
aria-label="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat položku
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +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">
|
||||
<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"
|
||||
>
|
||||
<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" : ""}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
disabled={page >= total_pages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="admin-pagination-page"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
238
src/admin/components/PlanCategoriesModal.tsx
Normal file
238
src/admin/components/PlanCategoriesModal.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: PlanCategory[];
|
||||
}
|
||||
|
||||
const DEFAULT_NEW_COLOR = "#2563eb";
|
||||
|
||||
export default function PlanCategoriesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
}: Props) {
|
||||
const alert = useAlert();
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [newColor, setNewColor] = useState(DEFAULT_NEW_COLOR);
|
||||
// Id of the row showing the inline "really delete?" confirm, or null.
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
|
||||
const createCat = useApiMutation<
|
||||
{ label: string; color: string },
|
||||
PlanCategory
|
||||
>({
|
||||
url: "/api/admin/plan/categories",
|
||||
method: "POST",
|
||||
invalidate: ["plan", "dashboard", "audit-log"],
|
||||
});
|
||||
const updateCat = useApiMutation<
|
||||
{ id: number; label?: string; color?: string; is_active?: boolean },
|
||||
PlanCategory
|
||||
>({
|
||||
url: (v) => `/api/admin/plan/categories/${v.id}`,
|
||||
method: "PATCH",
|
||||
invalidate: ["plan", "dashboard", "audit-log"],
|
||||
});
|
||||
const deleteCat = useApiMutation<{ id: number }, { ok: true }>({
|
||||
url: (v) => `/api/admin/plan/categories/${v.id}`,
|
||||
method: "DELETE",
|
||||
invalidate: ["plan", "dashboard", "audit-log"],
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
const label = newLabel.trim();
|
||||
if (!label) {
|
||||
alert.error("Zadejte název kategorie");
|
||||
return;
|
||||
}
|
||||
createCat.mutate(
|
||||
{ label, color: newColor },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNewLabel("");
|
||||
setNewColor(DEFAULT_NEW_COLOR);
|
||||
},
|
||||
onError: (e) => alert.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const patch = (
|
||||
id: number,
|
||||
body: { label?: string; color?: string; is_active?: boolean },
|
||||
) => {
|
||||
updateCat.mutate(
|
||||
{ id, ...body },
|
||||
{ onError: (e) => alert.error(e.message) },
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
deleteCat.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => setConfirmDeleteId(null),
|
||||
// Server blocks deleting a category that is still in use (409) — show
|
||||
// the Czech message ("…můžete ji skrýt") and keep the row.
|
||||
onError: (e) => {
|
||||
alert.error(e.message);
|
||||
setConfirmDeleteId(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
title="Správa kategorií"
|
||||
submitText="Zavřít"
|
||||
hideCancel
|
||||
maxWidth="md"
|
||||
>
|
||||
<Box>
|
||||
{categories.map((c) => (
|
||||
<Box key={c.id} sx={{ ...rowSx, opacity: c.is_active ? 1 : 0.55 }}>
|
||||
<Box
|
||||
component="input"
|
||||
type="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
|
||||
// once when the picker closes.
|
||||
defaultValue={c.color}
|
||||
key={c.color}
|
||||
disabled={busy}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
if (e.target.value !== c.color) {
|
||||
patch(c.id, { color: e.target.value });
|
||||
}
|
||||
}}
|
||||
aria-label={`Barva – ${c.label}`}
|
||||
/>
|
||||
<TextField
|
||||
defaultValue={c.label}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== c.label) patch(c.id, { label: v });
|
||||
}}
|
||||
inputProps={{ "aria-label": `Název – ${c.label}` }}
|
||||
/>
|
||||
{confirmDeleteId === c.id ? (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => handleDelete(c.id)}
|
||||
>
|
||||
Opravdu smazat
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(c.id)}
|
||||
aria-label={`Smazat – ${c.label}`}
|
||||
title="Smazat kategorii"
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box
|
||||
sx={{ ...rowSx, mt: 1, pt: 2, borderTop: 1, borderColor: "divider" }}
|
||||
>
|
||||
<Box
|
||||
component="input"
|
||||
type="color"
|
||||
sx={colorInputSx}
|
||||
value={newColor}
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setNewColor(e.target.value)
|
||||
}
|
||||
aria-label="Barva nové kategorie"
|
||||
/>
|
||||
<TextField
|
||||
placeholder="Nová kategorie…"
|
||||
value={newLabel}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Přidat
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
508
src/admin/components/PlanCellModal.tsx
Normal file
508
src/admin/components/PlanCellModal.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
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,
|
||||
PlanCategory,
|
||||
planCategoryLabel,
|
||||
cellProjectLabel,
|
||||
} from "../lib/queries/plan";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
project_number?: string;
|
||||
}
|
||||
|
||||
export type PlanCellModalMode =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
| {
|
||||
kind: "edit-range";
|
||||
entryId: number;
|
||||
userId: number;
|
||||
date: string;
|
||||
range: {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "edit-override";
|
||||
overrideId: number;
|
||||
userId: number;
|
||||
date: string;
|
||||
cell: ResolvedCell;
|
||||
}
|
||||
| {
|
||||
kind: "day-in-range";
|
||||
entryId: number;
|
||||
userId: number;
|
||||
date: string;
|
||||
range: {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
| { kind: "view"; userId: number; date: string; cell: ResolvedCell };
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: PlanCellModalMode;
|
||||
projects: Project[];
|
||||
categories: PlanCategory[];
|
||||
onClose: () => void;
|
||||
onSaveEntry: (body: any) => Promise<void>;
|
||||
onUpdateEntry: (id: number, body: any) => Promise<void>;
|
||||
onDeleteEntry: (id: number) => Promise<void>;
|
||||
onSaveOverride: (body: any) => Promise<void>;
|
||||
onUpdateOverride: (id: number, body: any) => Promise<void>;
|
||||
onDeleteOverride: (id: number) => Promise<void>;
|
||||
onCreateOverrideFromRange: (
|
||||
entryId: number,
|
||||
userId: number,
|
||||
date: string,
|
||||
) => Promise<void>;
|
||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
||||
}
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function PlanCellModal(props: Props) {
|
||||
const { open, mode, onClose } = props;
|
||||
if (!open || mode.kind === "closed") return null;
|
||||
if (mode.kind === "view") return <ViewModal {...props} />;
|
||||
if (mode.kind === "day-in-range")
|
||||
return <DayInRangeModal {...props} mode={mode} />;
|
||||
return <EditForm {...props} />;
|
||||
}
|
||||
|
||||
function EditForm(props: Props) {
|
||||
const {
|
||||
mode,
|
||||
onClose,
|
||||
onSaveEntry,
|
||||
onUpdateEntry,
|
||||
onDeleteEntry,
|
||||
onSaveOverride,
|
||||
onUpdateOverride,
|
||||
onDeleteOverride,
|
||||
projects,
|
||||
} = props;
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
||||
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
|
||||
// the parameter type is the full union, so we narrow explicitly here.
|
||||
if (
|
||||
mode.kind !== "create" &&
|
||||
mode.kind !== "edit-range" &&
|
||||
mode.kind !== "edit-override"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOverride = mode.kind === "edit-override";
|
||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||
|
||||
const initial = (() => {
|
||||
if (mode.kind === "create") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: null as number | null,
|
||||
// Default to "work" when it's active, otherwise the first active
|
||||
// category, so a new entry never starts on a retired key.
|
||||
category: activeCategories.some((c) => c.key === "work")
|
||||
? "work"
|
||||
: (activeCategories[0]?.key ?? "work"),
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
if (mode.kind === "edit-range") {
|
||||
return {
|
||||
date_from: mode.range.date_from,
|
||||
date_to: mode.range.date_to,
|
||||
is_range: mode.range.date_from !== mode.range.date_to,
|
||||
project_id: mode.range.project_id,
|
||||
category: mode.range.category,
|
||||
note: mode.range.note,
|
||||
};
|
||||
}
|
||||
// mode.kind === "edit-override" (narrowed above)
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: mode.cell.project_id,
|
||||
category: mode.cell.category,
|
||||
note: mode.cell.note,
|
||||
};
|
||||
})();
|
||||
|
||||
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
||||
const [dateTo, setDateTo] = useState(initial.date_to);
|
||||
const [isRange, setIsRange] = useState(initial.is_range);
|
||||
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
|
||||
const [category, setCategory] = useState(initial.category);
|
||||
const [note, setNote] = useState(initial.note);
|
||||
|
||||
const title =
|
||||
mode.kind === "create"
|
||||
? "Nový záznam plánu"
|
||||
: mode.kind === "edit-range"
|
||||
? "Upravit rozsah"
|
||||
: "Upravit přepsání dne";
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode.kind === "create") {
|
||||
const userId = mode.userId;
|
||||
await onSaveEntry({
|
||||
user_id: userId,
|
||||
date_from: dateFrom,
|
||||
date_to: isRange ? dateTo : dateFrom,
|
||||
project_id: projectId,
|
||||
category,
|
||||
note,
|
||||
});
|
||||
} else if (mode.kind === "edit-range") {
|
||||
await onUpdateEntry(mode.entryId, {
|
||||
date_from: dateFrom,
|
||||
date_to: isRange ? dateTo : dateFrom,
|
||||
project_id: projectId,
|
||||
category,
|
||||
note,
|
||||
});
|
||||
} else {
|
||||
await onUpdateOverride(mode.overrideId, {
|
||||
project_id: projectId,
|
||||
category,
|
||||
note,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode.kind === "edit-range") {
|
||||
await onDeleteEntry(mode.entryId);
|
||||
} else if (mode.kind === "edit-override") {
|
||||
await onDeleteOverride(mode.overrideId);
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<Modal
|
||||
isOpen
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||
loading={submitting}
|
||||
>
|
||||
{mode.kind !== "create" && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={TrashIcon}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Field label="Datum od">
|
||||
<DateField
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
disabled={isOverride}
|
||||
/>
|
||||
</Field>
|
||||
{!isOverride && (
|
||||
<Field label="Rozsah dnů">
|
||||
<CheckboxField
|
||||
label="Více dní"
|
||||
checked={isRange}
|
||||
onChange={setIsRange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!isOverride && isRange && (
|
||||
<Field label="Datum do">
|
||||
<DateField value={dateTo} onChange={setDateTo} />
|
||||
</Field>
|
||||
)}
|
||||
<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={(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)}
|
||||
inputProps={{ maxLength: 500 }}
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDelete}
|
||||
title="Smazat záznam plánu?"
|
||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
loading={submitting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DayInRangeModal(
|
||||
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day-in-range" }> },
|
||||
) {
|
||||
const { mode, onClose, onCreateOverrideFromRange, onSwitchToEditRange } =
|
||||
props;
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
// Two clear paths: edit the whole range (default, safe) or carve out
|
||||
// a one-day override (deviates from the plan). Close = no action.
|
||||
const choices = [
|
||||
{
|
||||
key: "edit-range",
|
||||
title: "Upravit celý rozsah",
|
||||
desc: `Změní projekt, kategorii nebo poznámku pro všechny dny v rozsahu ${mode.range.date_from} – ${mode.range.date_to}.`,
|
||||
tone: "primary" as const,
|
||||
onClick: () => onSwitchToEditRange(mode.entryId, mode.userId, mode.date),
|
||||
cta: "Upravit rozsah",
|
||||
},
|
||||
{
|
||||
key: "override-day",
|
||||
title: "Upravit pouze tento den",
|
||||
desc: `Vytvoří přepsání pro ${mode.range.date_from === mode.date ? "tento" : mode.date}; zbytek rozsahu zůstane beze změny.`,
|
||||
tone: "secondary" as const,
|
||||
onClick: async () => {
|
||||
await onCreateOverrideFromRange(mode.entryId, mode.userId, mode.date);
|
||||
onClose();
|
||||
},
|
||||
cta: "Vytvořit přepsání",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Den je součástí rozsahu"
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít — ponechat rozsah beze změny"
|
||||
maxWidth="md"
|
||||
>
|
||||
<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:
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<AnimatePresence>
|
||||
{choices.map((c, i) => (
|
||||
<motion.div
|
||||
key={c.key}
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||
transition={{
|
||||
duration: reducedMotion ? 0 : 0.22,
|
||||
delay: reducedMotion ? 0 : 0.04 + i * 0.05,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor:
|
||||
c.tone === "primary" ? "primary.main" : "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography sx={{ fontWeight: 600 }}>{c.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{c.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant={c.tone === "primary" ? "contained" : "outlined"}
|
||||
color={c.tone === "primary" ? "primary" : "inherit"}
|
||||
onClick={c.onClick}
|
||||
>
|
||||
{c.cta}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewModal(props: Props) {
|
||||
const { mode, onClose, projects, categories } = props;
|
||||
if (mode.kind !== "view") return null;
|
||||
const c = mode.cell;
|
||||
// Prefer the server-embedded project label; fall back to the projects-list
|
||||
// lookup only for cells that predate the embed (stale cache).
|
||||
const fallback = c.project_id
|
||||
? (projects.find((p) => p.id === c.project_id) ?? null)
|
||||
: null;
|
||||
const projectLabel =
|
||||
cellProjectLabel(c) ??
|
||||
(fallback
|
||||
? fallback.project_number
|
||||
? `${fallback.project_number} — ${fallback.name}`
|
||||
: fallback.name
|
||||
: null);
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Detail plánu"
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít"
|
||||
hideCancel
|
||||
>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Kategorie:</strong>{" "}
|
||||
{planCategoryLabel(
|
||||
c.category,
|
||||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||||
)}
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Text:</strong> {c.note || "—"}
|
||||
</Typography>
|
||||
{c.rangeFrom && c.rangeTo && (
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||
{formatDate(c.rangeTo)}
|
||||
</Typography>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
608
src/admin/components/PlanGrid.tsx
Normal file
608
src/admin/components/PlanGrid.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import Box from "@mui/material/Box";
|
||||
import {
|
||||
GridData,
|
||||
ResolvedCell,
|
||||
planCategoryLabel,
|
||||
categoryMap,
|
||||
PlanCategory,
|
||||
} 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;
|
||||
canEdit: boolean;
|
||||
projects: Project[];
|
||||
categories: PlanCategory[];
|
||||
// The (userId, date) of the most recent successful mutation. The
|
||||
// matching cell gets a one-shot highlight pulse. `nonce` is part of
|
||||
// the value so back-to-back mutations on the same cell re-trigger
|
||||
// the animation (React needs a new key to re-mount the class).
|
||||
pulseKey?: { userId: number; date: string; nonce: number } | null;
|
||||
onCellClick: (
|
||||
userId: number,
|
||||
date: string,
|
||||
cell: ResolvedCell | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
|
||||
// Used in the date column "stamp" so the day name reads in full rather
|
||||
// than as a 2-letter abbreviation — easier to scan when the planner
|
||||
// spans several weeks.
|
||||
const CZECH_WEEKDAYS = [
|
||||
"Neděle",
|
||||
"Pondělí",
|
||||
"Úterý",
|
||||
"Středa",
|
||||
"Čtvrtek",
|
||||
"Pátek",
|
||||
"Sobota",
|
||||
];
|
||||
|
||||
function eachDay(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
const a = new Date(from + "T00:00:00.000Z");
|
||||
const b = new Date(to + "T00:00:00.000Z");
|
||||
for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
out.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function czechWeekday(dateStr: string): string {
|
||||
return CZECH_WEEKDAYS[new Date(dateStr + "T00:00:00.000Z").getUTCDay()];
|
||||
}
|
||||
|
||||
function isWeekend(dateStr: string): boolean {
|
||||
const day = new Date(dateStr + "T00:00:00.000Z").getUTCDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
// Today's local date as YYYY-MM-DD. Used to mark the current row and to
|
||||
// gate past-day editing. Uses local time (matches the project's date
|
||||
// conventions in CLAUDE.md and the server's `assertNotPastDate` guard in
|
||||
// src/services/plan.service.ts).
|
||||
function todayIso(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// True when the given YYYY-MM-DD cell date is strictly before today (local).
|
||||
// Used to gate the create / edit UI on past days — the server still enforces
|
||||
// the rule with a 403, but the client should never put the user in the
|
||||
// "I clicked, modal opened, error toast" state in the first place.
|
||||
function isPastDate(dateStr: string, today: string): boolean {
|
||||
return dateStr < today;
|
||||
}
|
||||
|
||||
// Split a full name into first / last for the column header.
|
||||
function splitName(full: string): { first: string; last: string } {
|
||||
const parts = full.trim().split(/\s+/);
|
||||
if (parts.length === 1) return { first: parts[0], last: "" };
|
||||
return { first: parts[0], last: parts.slice(1).join(" ") };
|
||||
}
|
||||
|
||||
// Short role label for the column header sub-line.
|
||||
function shortRole(role: string | null): string {
|
||||
if (!role) return "";
|
||||
// Keep it brief — column header is tight
|
||||
if (/^admin$/i.test(role)) return "Admin";
|
||||
if (/^viewer$/i.test(role)) return "Viewer";
|
||||
return role.length > 10 ? role.slice(0, 9) + "…" : role;
|
||||
}
|
||||
|
||||
export default function PlanGrid({
|
||||
data,
|
||||
canEdit,
|
||||
projects,
|
||||
categories,
|
||||
pulseKey,
|
||||
onCellClick,
|
||||
}: Props) {
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
|
||||
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
|
||||
// mutation on the same cell re-triggers the animation (CSS animations
|
||||
// don't restart unless the keyframe applies to a fresh element/class
|
||||
// binding).
|
||||
const pulseAttr = pulseKey
|
||||
? `${pulseKey.userId}|${pulseKey.date}|${pulseKey.nonce}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<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
|
||||
`table-layout: auto` happily blows past. The first column
|
||||
gets a fixed width via the col element so the date stamp
|
||||
doesn't get stretched to share space with person columns. */}
|
||||
<colgroup>
|
||||
<col className="plan-grid-date-col" />
|
||||
{users.map((u) => (
|
||||
<col key={u.id} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="plan-grid-date-col">Datum</th>
|
||||
{users.map((u) => {
|
||||
const { first, last } = splitName(u.full_name);
|
||||
return (
|
||||
<th key={u.id}>
|
||||
<span className="plan-person-head">
|
||||
<span className="plan-person-dot" aria-hidden />
|
||||
<span className="plan-person-name">
|
||||
<strong title={u.full_name}>
|
||||
{first}
|
||||
{last ? ` ${last}` : ""}
|
||||
</strong>
|
||||
{shortRole(u.role_name) && (
|
||||
<small>{shortRole(u.role_name)}</small>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((date) => {
|
||||
const dow = czechWeekday(date);
|
||||
const dayNum = date.slice(8, 10);
|
||||
const isToday = date === today;
|
||||
const trCls = [
|
||||
isWeekend(date) ? "plan-grid-weekend" : "",
|
||||
isToday ? "is-today" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<tr key={date} className={trCls}>
|
||||
<td className="plan-grid-date-col">
|
||||
<span className="plan-date-stamp">
|
||||
<span className="plan-date-daynum">{dayNum}</span>
|
||||
<span className="plan-date-dow">{dow}</span>
|
||||
</span>
|
||||
</td>
|
||||
{users.map((u) => {
|
||||
const cell = data.cells[u.id]?.[date] ?? null;
|
||||
const past = isPastDate(date, today);
|
||||
// Past-day cells are read-only in the UI: an empty past
|
||||
// cell is non-interactive (no create modal, no "+" hint),
|
||||
// a past cell with data opens in view mode only. The
|
||||
// server still enforces the past-date rule with a 403
|
||||
// (defense in depth), but the click path here never
|
||||
// reaches a create/edit submission for a past date.
|
||||
const isLocked = !canEdit || past;
|
||||
// `isPulsing` is true for the single (user, date) cell
|
||||
// that the most recent successful mutation touched.
|
||||
// CSS restarts the keyframe animation whenever the
|
||||
// `nonce` changes (we embed it in data-pulse on the
|
||||
// wrapper, see above), so back-to-back mutations on the
|
||||
// same cell re-trigger the pulse.
|
||||
const isPulsing =
|
||||
!!pulseKey &&
|
||||
pulseKey.userId === u.id &&
|
||||
pulseKey.date === date;
|
||||
let cls: string;
|
||||
if (cell) {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell";
|
||||
} else {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell plan-cell--empty";
|
||||
}
|
||||
if (isPulsing) cls += " plan-cell--pulse";
|
||||
return (
|
||||
<td key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
style={
|
||||
cell
|
||||
? ({
|
||||
"--cat-color": catMap[cell.category]?.color,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onCellClick(u.id, date, cell)}
|
||||
aria-label={
|
||||
cell
|
||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||
: isLocked
|
||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
||||
}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={cell}
|
||||
project={
|
||||
cell?.project_id
|
||||
? (projects.find(
|
||||
(p) => p.id === cell.project_id,
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
readonly={!canEdit}
|
||||
categoryLabel={
|
||||
cell ? planCategoryLabel(cell.category, catMap) : ""
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</PlanGridRoot>
|
||||
);
|
||||
}
|
||||
42
src/admin/components/PlanRangeChips.tsx
Normal file
42
src/admin/components/PlanRangeChips.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ResolvedCell, cellProjectLabel } from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
|
||||
interface Props {
|
||||
cell: ResolvedCell | null;
|
||||
project: Project | null;
|
||||
readonly?: boolean;
|
||||
categoryLabel: string;
|
||||
}
|
||||
|
||||
export default function PlanRangeChips({
|
||||
cell,
|
||||
project,
|
||||
readonly,
|
||||
categoryLabel,
|
||||
}: Props) {
|
||||
void readonly;
|
||||
if (!cell) return null;
|
||||
// Prefer the server-embedded project label (always present). Fall back to
|
||||
// the looked-up `project` prop only if the cell predates the embed (stale
|
||||
// cache).
|
||||
const fallbackLabel = project
|
||||
? project.project_number
|
||||
? `${project.project_number} — ${project.name}`
|
||||
: project.name
|
||||
: null;
|
||||
const projectLabel = cellProjectLabel(cell) ?? fallbackLabel;
|
||||
const projectTitle = projectLabel ?? undefined;
|
||||
return (
|
||||
<div className="plan-chip-block">
|
||||
<div className="plan-chip-line">
|
||||
<span className="plan-chip">{categoryLabel}</span>
|
||||
{projectLabel && (
|
||||
<span className="plan-chip-project" title={projectTitle}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cell.note && <div className="plan-cell-note">{cell.note}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,7 @@
|
||||
import { useMemo, useRef, useCallback } from "react";
|
||||
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
const Quill = ReactQuill.Quill;
|
||||
|
||||
if (!(Quill as any).__bohaRegistered) {
|
||||
const Font = Quill.import("attributors/class/font") as any;
|
||||
Font.whitelist = [
|
||||
"arial",
|
||||
"tahoma",
|
||||
"verdana",
|
||||
"georgia",
|
||||
"times-new-roman",
|
||||
"courier-new",
|
||||
"trebuchet-ms",
|
||||
"impact",
|
||||
"comic-sans-ms",
|
||||
"lucida-console",
|
||||
"palatino-linotype",
|
||||
"garamond",
|
||||
];
|
||||
Quill.register(Font, true);
|
||||
|
||||
const SizeStyle = Quill.import("attributors/style/size") as any;
|
||||
SizeStyle.whitelist = [
|
||||
"8px",
|
||||
"9px",
|
||||
"10px",
|
||||
"11px",
|
||||
"12px",
|
||||
"14px",
|
||||
"16px",
|
||||
"18px",
|
||||
"20px",
|
||||
"24px",
|
||||
"28px",
|
||||
"32px",
|
||||
"36px",
|
||||
"48px",
|
||||
];
|
||||
Quill.register(SizeStyle, true);
|
||||
(Quill as any).__bohaRegistered = true;
|
||||
}
|
||||
|
||||
const Font = Quill.import("attributors/class/font") as any;
|
||||
const SIZE_WHITELIST = [
|
||||
"8px",
|
||||
"9px",
|
||||
"10px",
|
||||
"11px",
|
||||
"12px",
|
||||
"14px",
|
||||
"16px",
|
||||
"18px",
|
||||
"20px",
|
||||
"24px",
|
||||
"28px",
|
||||
"32px",
|
||||
"36px",
|
||||
"48px",
|
||||
];
|
||||
import { RichEditorRoot } from "../ui/RichText";
|
||||
|
||||
const COLORS = [
|
||||
"#000000",
|
||||
@@ -95,8 +37,6 @@ const COLORS = [
|
||||
];
|
||||
|
||||
const TOOLBAR = [
|
||||
[{ font: Font.whitelist }],
|
||||
[{ size: SIZE_WHITELIST }],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ color: COLORS }, { background: COLORS }],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
@@ -107,8 +47,6 @@ const TOOLBAR = [
|
||||
];
|
||||
|
||||
const FORMATS = [
|
||||
"font",
|
||||
"size",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
@@ -159,9 +97,15 @@ export default function RichEditor({
|
||||
[onChange],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!quillRef.current) return;
|
||||
const editor = quillRef.current.getEditor();
|
||||
editor.root.style.fontFamily = "Tahoma, sans-serif";
|
||||
editor.root.style.fontSize = "14px";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-rich-editor"
|
||||
<RichEditorRoot
|
||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||
>
|
||||
<ReactQuill
|
||||
@@ -174,6 +118,6 @@ export default function RichEditor({
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</RichEditorRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Select,
|
||||
TextField,
|
||||
DateField,
|
||||
TimeField,
|
||||
Field,
|
||||
Alert,
|
||||
} from "../ui";
|
||||
import {
|
||||
calcFormWorkMinutes,
|
||||
calcProjectMinutesTotal,
|
||||
@@ -68,7 +78,7 @@ interface ProjectLogRowProps {
|
||||
|
||||
export interface ShiftFormModalProps {
|
||||
mode: "create" | "edit";
|
||||
show: boolean;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
form: ShiftFormData;
|
||||
@@ -81,11 +91,32 @@ 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) {
|
||||
const totalWork = calcFormWorkMinutes(form);
|
||||
const totalProject = calcProjectMinutesTotal(projectLogs);
|
||||
const totalProject = calcProjectMinutesTotal(
|
||||
projectLogs.map((l) => ({
|
||||
...l,
|
||||
project_id:
|
||||
l.project_id !== "" && l.project_id != null
|
||||
? Number(l.project_id)
|
||||
: undefined,
|
||||
})),
|
||||
);
|
||||
const remaining = totalWork - totalProject;
|
||||
const hasLogs = projectLogs.some((l) => l.project_id);
|
||||
|
||||
@@ -93,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,7 +211,7 @@ function ProjectLogRow({
|
||||
|
||||
export default function ShiftFormModal({
|
||||
mode,
|
||||
show,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
@@ -208,7 +223,6 @@ export default function ShiftFormModal({
|
||||
onShiftDateChange,
|
||||
editingRecord,
|
||||
}: ShiftFormModalProps) {
|
||||
useModalLock(show);
|
||||
const isCreate = mode === "create";
|
||||
const isWorkType = form.leave_type === "work";
|
||||
|
||||
@@ -239,274 +253,211 @@ export default function ShiftFormModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
subtitle={
|
||||
!isCreate && editingRecord
|
||||
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
||||
: undefined
|
||||
}
|
||||
maxWidth="lg"
|
||||
onSubmit={onSubmit}
|
||||
submitText="Uložit"
|
||||
cancelText="Zrušit"
|
||||
>
|
||||
{isCreate ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">
|
||||
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
</h2>
|
||||
{!isCreate && editingRecord && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{editingRecord.user_name} —{" "}
|
||||
{formatDate(editingRecord.shift_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
{isCreate ? (
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Zaměstnanec
|
||||
</label>
|
||||
<select
|
||||
value={form.user_id}
|
||||
onChange={(e) => updateField("user_id", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Datum směny
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ záznamu</label>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="holiday">Svátek</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!isWorkType && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Počet hodin</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
/>
|
||||
{isCreate && (
|
||||
<small className="admin-form-hint">
|
||||
8 hodin = celý den
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Příchod - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Příchod - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val) =>
|
||||
updateField("break_start_date", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
updateField("break_start_time", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Konec pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Konec pauzy - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Odchod - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Projekty</label>
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProjectLog}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat projekt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámka</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Uložit
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<Field label="Zaměstnanec" required>
|
||||
<Select
|
||||
value={form.user_id}
|
||||
onChange={(val) => updateField("user_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: user.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Datum směny" required>
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<Field label="Datum směny">
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Field label="Typ záznamu">
|
||||
<Select
|
||||
value={form.leave_type}
|
||||
onChange={(val) => updateField("leave_type", val)}
|
||||
options={[
|
||||
{ value: "work", label: "Práce" },
|
||||
{ value: "vacation", label: "Dovolená" },
|
||||
{ value: "sick", label: "Nemoc" },
|
||||
{ value: "unpaid", label: "Neplacené volno" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isWorkType && (
|
||||
<Field
|
||||
label="Počet hodin"
|
||||
hint={isCreate ? "8 hodin = celý den" : undefined}
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod - datum">
|
||||
<DateField
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Příchod - čas">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break start */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_start_date}
|
||||
onChange={(val) => updateField("break_start_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Začátek pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => updateField("break_start_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break end */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Konec pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Odchod - datum">
|
||||
<DateField
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod - čas">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<Field label="Projekty">
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={addProjectLog}
|
||||
>
|
||||
+ Přidat projekt
|
||||
</Button>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
interface SortIconProps {
|
||||
column: string;
|
||||
sort: string | null;
|
||||
order: string;
|
||||
}
|
||||
|
||||
export default function SortIcon({ column, sort, order }: SortIconProps) {
|
||||
if (sort !== column) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ opacity: 0.3, marginLeft: 4 }}
|
||||
>
|
||||
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
{order === "asc" ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Card, Button, EmptyState } from "../../ui";
|
||||
import { iconBadgeSx, type IconBadgeColor } from "../../theme";
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
getActivityIconClass,
|
||||
@@ -18,6 +22,16 @@ interface DashActivityFeedProps {
|
||||
activities: Activity[] | null;
|
||||
}
|
||||
|
||||
// Maps the legacy activity-icon class to the MUI palette key used to tint the
|
||||
// circular icon badge (danger → error, accent → primary, muted → default).
|
||||
const ACTIVITY_BADGE_COLOR: Record<string, string> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
danger: "error",
|
||||
accent: "primary",
|
||||
muted: "default",
|
||||
};
|
||||
|
||||
function getActivityIcon(action: string) {
|
||||
switch (action) {
|
||||
case "create":
|
||||
@@ -103,37 +117,95 @@ export default function DashActivityFeed({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-activity-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Audit log</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Audit log</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/audit-log"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{activities.map((act) => (
|
||||
<div key={act.id} className="dash-activity-row">
|
||||
<div
|
||||
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</div>
|
||||
<div className="dash-activity-main">
|
||||
<div className="dash-activity-text">{act.description}</div>
|
||||
<div className="dash-activity-sub">
|
||||
{act.username || "Systém"} ·{" "}
|
||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-activity-time admin-mono">
|
||||
{formatActivityTime(act.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<EmptyState title="Žádná aktivita" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{activities.map((act) => {
|
||||
const badgeColor =
|
||||
ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ??
|
||||
"default";
|
||||
const isDefault = badgeColor === "default";
|
||||
return (
|
||||
<Box
|
||||
key={act.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconBadgeSx(
|
||||
isDefault ? "default" : (badgeColor as IconBadgeColor),
|
||||
),
|
||||
]}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500 }}
|
||||
noWrap
|
||||
title={act.description}
|
||||
>
|
||||
{act.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{act.username || "Systém"} ·{" "}
|
||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatActivityTime(act.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LEAVE_TYPE_LABELS,
|
||||
STATUS_DOT_CLASS,
|
||||
STATUS_LABELS,
|
||||
} from "../../utils/dashboardHelpers";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import { Card, Button, StatusChip, EmptyState } from "../../ui";
|
||||
import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
|
||||
|
||||
interface AttendanceUser {
|
||||
user_id: number | string;
|
||||
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
|
||||
attendance: AttendanceData | null;
|
||||
}
|
||||
|
||||
// Maps the attendance status to the StatusChip / avatar tint color.
|
||||
// Mirrors the legacy dash-status-* dot classes: in→success, away→warning,
|
||||
// out→default, leave→error.
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"default" | "success" | "warning" | "error"
|
||||
> = {
|
||||
in: "success",
|
||||
away: "warning",
|
||||
out: "default",
|
||||
leave: "error",
|
||||
};
|
||||
|
||||
export default function DashAttendanceToday({
|
||||
attendance,
|
||||
}: DashAttendanceTodayProps) {
|
||||
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-attendance-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Docházka dnes</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Docházka dnes</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{attendance.users.map((u, i) => (
|
||||
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
|
||||
<div
|
||||
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</div>
|
||||
<div className="dash-presence-name">{u.name}</div>
|
||||
<div className="dash-presence-end">
|
||||
<span
|
||||
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{attendance.users.length === 0 ? (
|
||||
<EmptyState title="Žádná docházka" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{attendance.users.map((u, i) => {
|
||||
const color = STATUS_COLOR[u.status] ?? "default";
|
||||
const isDefault = color === "default";
|
||||
return (
|
||||
<Box
|
||||
key={`${u.user_id}-${i}`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
{u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]}
|
||||
</span>
|
||||
{u.arrived_at && (
|
||||
<span className="admin-mono dash-presence-time">
|
||||
{u.arrived_at}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
bgcolor: isDefault ? "grey.600" : `${color}.main`,
|
||||
color: "common.white",
|
||||
}}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</Avatar>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500, flex: 1, minWidth: 0 }}
|
||||
noWrap
|
||||
>
|
||||
{u.name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<StatusChip
|
||||
color={color}
|
||||
label={
|
||||
u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]
|
||||
}
|
||||
/>
|
||||
{u.arrived_at && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{u.arrived_at}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { StatCard, type StatCardColor } from "../../ui";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
interface KpiCard {
|
||||
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
||||
};
|
||||
}
|
||||
|
||||
const KPI_CLASS_MAP: Record<number, string> = {
|
||||
4: "admin-kpi-4",
|
||||
3: "admin-kpi-3",
|
||||
2: "admin-kpi-2",
|
||||
1: "admin-kpi-1",
|
||||
// Maps the legacy KPI color tokens to the StatCard color palette
|
||||
// (danger → error; the rest are 1:1).
|
||||
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
danger: "error",
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`admin-kpi-grid ${kpiClass}`}
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{kpiCards.map((kpi) => (
|
||||
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
|
||||
<div className="admin-stat-label">{kpi.label}</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{kpi.value}
|
||||
{kpi.sub && (
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
marginLeft: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
||||
</div>
|
||||
<StatCard
|
||||
key={kpi.label}
|
||||
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
|
||||
label={kpi.label}
|
||||
value={
|
||||
<>
|
||||
{kpi.value}
|
||||
{kpi.sub && (
|
||||
<Typography
|
||||
component="span"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
ml: 0.5,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={kpi.footer ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import { formatKm } from "../../utils/formatters";
|
||||
import AdminDatePicker from "../AdminDatePicker";
|
||||
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
||||
import { Button, Modal, Field, Select, DateField, TextField } from "../../ui";
|
||||
import apiFetch from "../../utils/api";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -46,6 +46,14 @@ interface DashQuickActionsProps {
|
||||
onPunch: () => void;
|
||||
}
|
||||
|
||||
// Maps the legacy quick-action color tokens to the MUI Button palette color.
|
||||
const ACTION_COLOR: Record<string, "success" | "error" | "info" | "warning"> = {
|
||||
success: "success",
|
||||
danger: "error",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
};
|
||||
|
||||
export default function DashQuickActions({
|
||||
dashData,
|
||||
punching,
|
||||
@@ -69,12 +77,10 @@ export default function DashQuickActions({
|
||||
});
|
||||
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
||||
|
||||
useModalLock(showTripModal);
|
||||
|
||||
const openTripModal = async () => {
|
||||
setTripForm({
|
||||
vehicle_id: "",
|
||||
trip_date: new Date().toISOString().split("T")[0],
|
||||
trip_date: todayLocalStr(),
|
||||
start_km: "",
|
||||
end_km: "",
|
||||
route_from: "",
|
||||
@@ -286,303 +292,192 @@ export default function DashQuickActions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="dash-quick-actions"
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
md: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{quickActions.map((action) =>
|
||||
action.onClick ? (
|
||||
<button
|
||||
{quickActions.map((action) => {
|
||||
const color = ACTION_COLOR[action.color] ?? "primary";
|
||||
const common = {
|
||||
variant: "contained" as const,
|
||||
color,
|
||||
startIcon: action.icon,
|
||||
sx: { py: 1.5, justifyContent: "flex-start" },
|
||||
};
|
||||
return action.onClick ? (
|
||||
<Button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
{...common}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Link
|
||||
<Button
|
||||
key={action.label}
|
||||
component={RouterLink}
|
||||
to={action.path!}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
{...common}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</motion.div>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTripModal && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShowTripModal(false)}
|
||||
<Modal
|
||||
isOpen={showTripModal}
|
||||
onClose={() => setShowTripModal(false)}
|
||||
title="Přidat jízdu"
|
||||
maxWidth="md"
|
||||
onSubmit={handleTripSubmit}
|
||||
submitText="Uložit"
|
||||
loading={tripSubmitting}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Vozidlo" required error={tripErrors.vehicle_id}>
|
||||
<Select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(val) => {
|
||||
handleTripVehicleChange(val);
|
||||
setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte vozidlo" },
|
||||
...tripVehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Přidat jízdu</h2>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Vozidlo
|
||||
</label>
|
||||
<select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleTripVehicleChange(e.target.value);
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
vehicle_id: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte vozidlo</option>
|
||||
{tripVehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.spz} - {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tripErrors.vehicle_id && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.vehicle_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Datum jízdy
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val: string) => {
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
trip_date: undefined,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{tripErrors.trip_date && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.trip_date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
|
||||
<DateField
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val) => {
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Počáteční stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
start_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
start_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.start_km && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.start_km}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Konečný stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
end_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
end_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.end_km && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.end_km}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Vzdálenost</label>
|
||||
<input
|
||||
type="text"
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konečný stav km" required error={tripErrors.end_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vzdálenost">
|
||||
<TextField
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
InputProps={{ readOnly: true }}
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo odjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_from: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
{tripErrors.route_from && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_from}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo příjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_to: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_to: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
{tripErrors.route_to && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_to}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
|
||||
<TextField
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
|
||||
}}
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
|
||||
<TextField
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
|
||||
}}
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ jízdy</label>
|
||||
<select
|
||||
value={tripForm.is_business}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
is_business: parseInt(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
<option value={0}>Soukromá</option>
|
||||
</select>
|
||||
</div>
|
||||
<Field label="Typ jízdy">
|
||||
<Select
|
||||
value={String(tripForm.is_business)}
|
||||
onChange={(val) =>
|
||||
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
|
||||
}
|
||||
options={[
|
||||
{ value: "1", label: "Služební" },
|
||||
{ value: "0", label: "Soukromá" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámky</label>
|
||||
<textarea
|
||||
value={tripForm.notes}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
notes: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTripModal(false)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={tripSubmitting}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTripSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={tripSubmitting}
|
||||
>
|
||||
{tripSubmitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={tripForm.notes}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||
}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
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";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface DeviceInfo {
|
||||
icon?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: number | string;
|
||||
is_current: boolean;
|
||||
device_info?: DeviceInfo;
|
||||
ip_address: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface DeleteModalState {
|
||||
isOpen: boolean;
|
||||
session: Session | null;
|
||||
@@ -77,9 +68,10 @@ function getDeviceIcon(iconType?: string) {
|
||||
|
||||
export default function DashSessions() {
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const { data: sessions = [], isPending: sessionsLoading } =
|
||||
useQuery(sessionsOptions());
|
||||
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
|
||||
isOpen: false,
|
||||
session: null,
|
||||
@@ -87,28 +79,6 @@ export default function DashSessions() {
|
||||
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useModalLock(deleteAllModal);
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/sessions`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSessions(
|
||||
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// session fetch failed silently
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, [fetchSessions]);
|
||||
|
||||
const handleDeleteSession = async () => {
|
||||
if (!deleteModal.session) {
|
||||
return;
|
||||
@@ -122,7 +92,7 @@ export default function DashSessions() {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setDeleteModal({ isOpen: false, session: null });
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
alert.success("Relace byla ukončena");
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se ukončit relaci");
|
||||
@@ -143,7 +113,7 @@ export default function DashSessions() {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setDeleteAllModal(false);
|
||||
setSessions((prev) => prev.filter((s) => s.is_current));
|
||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
alert.success(data.message || "Ostatní relace byly ukončeny");
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se ukončit relace");
|
||||
@@ -158,101 +128,114 @@ export default function DashSessions() {
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div
|
||||
className="admin-card-header"
|
||||
style={{
|
||||
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 }}>
|
||||
{sessionsLoading && (
|
||||
<div
|
||||
className="admin-skeleton"
|
||||
style={{ padding: "1rem", gap: "1rem" }}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="admin-skeleton-line w-1/2"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!sessionsLoading && sessions.length === 0 && (
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
<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 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : sessions.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center", py: 3 }}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</div>
|
||||
)}
|
||||
{!sessionsLoading && sessions.length > 0 && (
|
||||
<div className="dash-sessions-list">
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
<Box
|
||||
key={session.id}
|
||||
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
...(session.is_current
|
||||
? { bgcolor: "action.hover", borderRadius: 2, px: 1 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<div className="dash-session-icon">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "text.secondary",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
</Typography>
|
||||
{session.is_current && (
|
||||
<span
|
||||
className="admin-badge admin-badge-success"
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Aktuální
|
||||
</span>
|
||||
<StatusChip color="success" label="Aktuální" />
|
||||
)}
|
||||
</div>
|
||||
<div className="dash-session-meta">
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<span>{session.ip_address}</span>
|
||||
<span className="dash-session-meta-separator">|</span>
|
||||
<span>|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-session-actions">
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
{!session.is_current && (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
color="error"
|
||||
size="small"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
@@ -268,17 +251,17 @@ export default function DashSessions() {
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
||||
onConfirm={handleDeleteSession}
|
||||
@@ -286,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}
|
||||
@@ -297,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}
|
||||
/>
|
||||
</>
|
||||
|
||||
110
src/admin/components/dashboard/DashTodayPlan.tsx
Normal file
110
src/admin/components/dashboard/DashTodayPlan.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Card } from "../../ui";
|
||||
import { formatDate } from "../../utils/formatters";
|
||||
|
||||
/** The logged-in user's resolved work plan for today (see GET /api/admin/dashboard). */
|
||||
export interface TodayPlan {
|
||||
shift_date: string;
|
||||
category: string;
|
||||
category_label: string;
|
||||
category_color: string | null;
|
||||
project_id: number | null;
|
||||
project_number: string | null;
|
||||
project_name: string | null;
|
||||
note: string;
|
||||
source: "entry" | "override";
|
||||
rangeFrom: string | null;
|
||||
rangeTo: string | null;
|
||||
}
|
||||
|
||||
function projectLabel(p: TodayPlan): string {
|
||||
const parts = [p.project_number, p.project_name].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(" — ") : "Bez projektu";
|
||||
}
|
||||
|
||||
/**
|
||||
* "Vaše dnešní zařazení" — shows the logged-in user's work-plan entry for today
|
||||
* (category + project/site + note, with a range badge when the day is part of a
|
||||
* multi-day range). `plan === null` = the user has plan access but nothing is
|
||||
* scheduled today (muted empty state). Rendered near the clock-in on the
|
||||
* dashboard; the caller gates it on the plan permission.
|
||||
*/
|
||||
export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) {
|
||||
const color = plan?.category_color || "var(--mui-palette-primary-main)";
|
||||
return (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ mb: 1.5, fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Vaše dnešní zařazení
|
||||
</Typography>
|
||||
|
||||
{plan ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: `color-mix(in srgb, ${color} 12%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
bgcolor: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, fontSize: "0.8rem" }}
|
||||
>
|
||||
{plan.category_label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{plan.rangeFrom &&
|
||||
plan.rangeTo &&
|
||||
plan.rangeFrom !== plan.rangeTo && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
součást rozsahu {formatDate(plan.rangeFrom)} –{" "}
|
||||
{formatDate(plan.rangeTo)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ lineHeight: 1.25 }}>
|
||||
{projectLabel(plan)}
|
||||
</Typography>
|
||||
|
||||
{plan.note && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.note}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Pro dnešek nemáte naplánováno.
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
103
src/admin/components/warehouse/ItemPicker.tsx
Normal file
103
src/admin/components/warehouse/ItemPicker.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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;
|
||||
onChange: (itemId: number) => void;
|
||||
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 [inputValue, setInputValue] = useState(itemName ?? "");
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||
);
|
||||
const options: ItemOption[] = data?.data ?? [];
|
||||
|
||||
// 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 (
|
||||
<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}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
src/admin/components/warehouse/ReservationPicker.tsx
Normal file
54
src/admin/components/warehouse/ReservationPicker.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { Select } from "../../ui";
|
||||
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
||||
|
||||
interface ReservationPickerProps {
|
||||
itemId: number | null;
|
||||
projectId: number | null;
|
||||
value: number | null;
|
||||
onChange: (reservationId: number | null, remainingQty: number) => void;
|
||||
}
|
||||
|
||||
export default function ReservationPicker({
|
||||
itemId,
|
||||
projectId,
|
||||
value,
|
||||
onChange,
|
||||
}: ReservationPickerProps) {
|
||||
const { data: result } = useQuery(
|
||||
warehouseReservationListOptions({
|
||||
item_id: itemId ?? undefined,
|
||||
project_id: projectId ?? undefined,
|
||||
status: "ACTIVE",
|
||||
perPage: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
const reservations = result?.data ?? [];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value == null ? "" : String(value)}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
onChange(null, 0);
|
||||
return;
|
||||
}
|
||||
const reservationId = Number(val);
|
||||
const reservation = reservations.find((r) => r.id === reservationId);
|
||||
if (reservation) {
|
||||
onChange(reservationId, Number(reservation.remaining_qty));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">— žádná rezervace —</MenuItem>
|
||||
{reservations.map((r) => (
|
||||
<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"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
@@ -39,6 +40,15 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const counterRef = useRef(0);
|
||||
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutsRef.current.forEach(clearTimeout);
|
||||
timeoutsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addAlert = useCallback(
|
||||
(message: string, type = "success", duration = 4000) => {
|
||||
const id = `${Date.now()}-${counterRef.current++}`;
|
||||
@@ -47,7 +57,11 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
{ id, message, type: type as Alert["type"] },
|
||||
]);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeAlert(id), duration);
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutsRef.current.delete(timeoutId);
|
||||
removeAlert(id);
|
||||
}, duration);
|
||||
timeoutsRef.current.add(timeoutId);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
@@ -84,32 +84,32 @@ function mapUser(u: Record<string, unknown> | null): User | null {
|
||||
} as User;
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let tokenExpiresAt: number | null = null;
|
||||
let cachedUser: User | null = null;
|
||||
let sessionFetched = false;
|
||||
let silentRefreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(cachedUser);
|
||||
const [loading, setLoading] = useState(!sessionFetched);
|
||||
const accessTokenRef = useRef<string | null>(null);
|
||||
const tokenExpiresAtRef = useRef<number | null>(null);
|
||||
const cachedUserRef = useRef<User | null>(null);
|
||||
const sessionFetchedRef = useRef(false);
|
||||
const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const hadValidSessionRef = useRef(false);
|
||||
const [user, setUser] = useState<User | null>(cachedUserRef.current);
|
||||
const [loading, setLoading] = useState(!sessionFetchedRef.current);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cachedUser = user;
|
||||
}, [user]);
|
||||
|
||||
const getAccessTokenFn = useCallback((): string | null => {
|
||||
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null;
|
||||
return accessToken;
|
||||
if (
|
||||
!tokenExpiresAtRef.current ||
|
||||
Date.now() > tokenExpiresAtRef.current - 30000
|
||||
)
|
||||
return null;
|
||||
return accessTokenRef.current;
|
||||
}, []);
|
||||
|
||||
const setAccessTokenFn = useCallback(
|
||||
(token: string | null, expiresIn?: number) => {
|
||||
const ttl = expiresIn ?? 900; // default 15 min matching backend config
|
||||
accessToken = token;
|
||||
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null;
|
||||
accessTokenRef.current = token;
|
||||
tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null;
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
@@ -126,7 +126,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
||||
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
||||
if (silentRefreshInFlight) return silentRefreshInFlight;
|
||||
if (silentRefreshInFlightRef.current)
|
||||
return silentRefreshInFlightRef.current;
|
||||
|
||||
const promise = (async (): Promise<boolean> => {
|
||||
try {
|
||||
@@ -138,23 +139,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (data.success && data.data?.access_token) {
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
hadValidSessionRef.current = true;
|
||||
return true;
|
||||
}
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
accessTokenRef.current = null;
|
||||
tokenExpiresAtRef.current = null;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
setSessionExpired();
|
||||
cachedUserRef.current = null;
|
||||
if (hadValidSessionRef.current) setSessionExpired();
|
||||
return false;
|
||||
} catch {
|
||||
// Network error — don't kick the user out, just return false
|
||||
return false;
|
||||
} finally {
|
||||
silentRefreshInFlight = null;
|
||||
silentRefreshInFlightRef.current = null;
|
||||
}
|
||||
})();
|
||||
|
||||
silentRefreshInFlight = promise;
|
||||
silentRefreshInFlightRef.current = promise;
|
||||
return promise;
|
||||
}, [setAccessTokenFn]);
|
||||
|
||||
@@ -172,12 +174,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
headers,
|
||||
});
|
||||
if (response.status === 429 || response.status >= 500)
|
||||
return !!cachedUser;
|
||||
return !!cachedUserRef.current;
|
||||
const data = await response.json();
|
||||
if (data.success && data.data?.user) {
|
||||
if (data.data.access_token) setAccessTokenFn(data.data.access_token);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
hadValidSessionRef.current = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -185,15 +188,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const refreshed = await silentRefresh();
|
||||
if (refreshed) return true;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
cachedUserRef.current = null;
|
||||
accessTokenRef.current = null;
|
||||
tokenExpiresAtRef.current = null;
|
||||
return false;
|
||||
} catch {
|
||||
return !!cachedUser;
|
||||
return !!cachedUserRef.current;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
sessionFetched = true;
|
||||
sessionFetchedRef.current = true;
|
||||
}
|
||||
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
|
||||
|
||||
@@ -231,8 +234,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
sessionFetched = true;
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
sessionFetchedRef.current = true;
|
||||
hadValidSessionRef.current = true;
|
||||
return { success: true };
|
||||
}
|
||||
setError(data.error);
|
||||
@@ -264,14 +268,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
login_token: loginToken,
|
||||
totp_code: code,
|
||||
remember_me: remember,
|
||||
isBackup,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
sessionFetched = true;
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
sessionFetchedRef.current = true;
|
||||
hadValidSessionRef.current = true;
|
||||
return { success: true };
|
||||
}
|
||||
setError(data.error);
|
||||
@@ -296,11 +302,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
accessTokenRef.current = null;
|
||||
tokenExpiresAtRef.current = null;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
sessionFetched = false;
|
||||
cachedUserRef.current = null;
|
||||
sessionFetchedRef.current = false;
|
||||
hadValidSessionRef.current = false;
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
/* ============================================================================
|
||||
Dashboard
|
||||
============================================================================ */
|
||||
|
||||
/* Dashboard layout */
|
||||
.dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.dash .admin-page-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.dash-quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.dash-quick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-quick-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.dash-quick-btn-success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-quick-btn-info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.dash-quick-btn-warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-quick-btn-danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dash-quick-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
[data-theme="light"] .dash-quick-btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-info {
|
||||
background: var(--info);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-warning {
|
||||
background: var(--warning);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="light"] .dash-quick-btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Main content 3-col grid */
|
||||
.dash-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Card link */
|
||||
.dash-card-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.dash-card-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Activity rows */
|
||||
.dash-activity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dash-activity-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-activity-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-activity-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-activity-icon.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-activity-icon.info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.dash-activity-icon.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-activity-icon.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.dash-activity-icon.accent {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.dash-activity-icon.muted {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dash-activity-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-activity-text {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-activity-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.dash-activity-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Presence rows */
|
||||
.dash-presence-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dash-presence-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-presence-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dash-presence-avatar.dash-status-in {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-away {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-out {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dash-presence-avatar.dash-status-leave {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.dash-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-status-dot.dash-status-in {
|
||||
background: var(--success);
|
||||
}
|
||||
.dash-status-dot.dash-status-away {
|
||||
background: var(--warning);
|
||||
}
|
||||
.dash-status-dot.dash-status-out {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.dash-status-dot.dash-status-leave {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.dash-presence-label.dash-status-in {
|
||||
color: var(--success);
|
||||
}
|
||||
.dash-presence-label.dash-status-away {
|
||||
color: var(--warning);
|
||||
}
|
||||
.dash-presence-label.dash-status-out {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dash-presence-label.dash-status-leave {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.dash-presence-name {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dash-presence-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-presence-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dash-presence-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Right column */
|
||||
.dash-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Project rows */
|
||||
.dash-project-row {
|
||||
display: block;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-decoration: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dash-project-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-project-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-project-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dash-project-customer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Stat mini rows */
|
||||
.dash-stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dash-stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Empty row */
|
||||
.dash-empty-row {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Bottom stacked layout (profile + sessions) */
|
||||
.dash-bottom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dash .admin-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Profile grid inside account card */
|
||||
.dash-profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.dash-profile-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.dash-profile-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dash-profile-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Responsive
|
||||
============================================================================ */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dash-main-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.dash-right-col {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dash-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-right-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.dash-profile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Sessions / Devices
|
||||
============================================================================ */
|
||||
|
||||
.dash-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dash-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.dash-session-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dash-session-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dash-session-item-current {
|
||||
background: var(--row-current);
|
||||
}
|
||||
|
||||
.dash-session-item-current:hover {
|
||||
background: var(--row-current-hover);
|
||||
}
|
||||
|
||||
.dash-session-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dash-session-item-current .dash-session-icon {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.dash-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-session-device {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dash-session-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dash-session-meta-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
.dash-session-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-session-item {
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dash-session-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.dash-session-device {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dash-session-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/* ============================================================================
|
||||
React DatePicker Overrides
|
||||
============================================================================ */
|
||||
|
||||
.react-datepicker-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Prevent flash at top-left before popper calculates position */
|
||||
#datepicker-portal .react-datepicker-popper {
|
||||
opacity: 0;
|
||||
animation: dp-fade-in 0.01s forwards 0.02s;
|
||||
}
|
||||
|
||||
@keyframes dp-fade-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker {
|
||||
font-family: inherit !important;
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: var(--border-radius-sm) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
.react-datepicker__triangle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.react-datepicker__header {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
border-bottom: 1px solid var(--border-color) !important;
|
||||
padding-top: 0.75rem !important;
|
||||
}
|
||||
|
||||
.react-datepicker__current-month,
|
||||
.react-datepicker-time__header {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day-name {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* Days */
|
||||
.react-datepicker__day {
|
||||
color: var(--text-primary) !important;
|
||||
border-radius: 6px !important;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day:hover {
|
||||
background-color: var(--accent-light) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day--selected,
|
||||
.react-datepicker__day--keyboard-selected {
|
||||
background-color: var(--accent-color) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day--today {
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day--outside-month {
|
||||
color: var(--text-muted) !important;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.react-datepicker__day--disabled {
|
||||
color: var(--text-muted) !important;
|
||||
opacity: 0.3 !important;
|
||||
}
|
||||
|
||||
/* Navigation arrows */
|
||||
.react-datepicker__navigation {
|
||||
top: 0.75rem !important;
|
||||
}
|
||||
|
||||
.react-datepicker__navigation-icon::before {
|
||||
border-color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__navigation:hover *::before {
|
||||
border-color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
/* Year dropdown */
|
||||
.react-datepicker__year-dropdown,
|
||||
.react-datepicker__month-dropdown,
|
||||
.react-datepicker__year-read-view,
|
||||
.react-datepicker__month-read-view {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Time picker */
|
||||
.react-datepicker__time-container {
|
||||
border-left: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-container .react-datepicker__time {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-container
|
||||
.react-datepicker__time
|
||||
.react-datepicker__time-box {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-container
|
||||
.react-datepicker__time
|
||||
.react-datepicker__time-box
|
||||
ul.react-datepicker__time-list
|
||||
li.react-datepicker__time-list-item {
|
||||
color: var(--text-primary) !important;
|
||||
transition: background 0.15s !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-container
|
||||
.react-datepicker__time
|
||||
.react-datepicker__time-box
|
||||
ul.react-datepicker__time-list
|
||||
li.react-datepicker__time-list-item:hover {
|
||||
background-color: var(--accent-light) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-container
|
||||
.react-datepicker__time
|
||||
.react-datepicker__time-box
|
||||
ul.react-datepicker__time-list
|
||||
li.react-datepicker__time-list-item--selected {
|
||||
background-color: var(--accent-color) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Month picker */
|
||||
.react-datepicker__monthPicker {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
}
|
||||
|
||||
.react-datepicker-year-header {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-bottom: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__month-wrapper {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__month-text {
|
||||
color: var(--text-primary) !important;
|
||||
padding: 0.5rem !important;
|
||||
border-radius: 6px !important;
|
||||
transition: background 0.15s !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.react-datepicker__month-text:hover {
|
||||
background-color: var(--accent-light) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__month-text--keyboard-selected,
|
||||
.react-datepicker__month-text--selected {
|
||||
background-color: var(--accent-color) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.react-datepicker__month-text--today {
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.react-datepicker__input-container input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.react-datepicker__close-icon::after {
|
||||
background-color: var(--accent-color) !important;
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/* ============================================================================
|
||||
File Manager
|
||||
============================================================================ */
|
||||
|
||||
.fm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fm-full-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
user-select: all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fm-toolbar-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 12px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.fm-breadcrumb-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-breadcrumb-sep {
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fm-breadcrumb-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.fm-breadcrumb-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fm-breadcrumb-btn.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fm-new-folder {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.fm-new-folder .admin-form-input {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.fm-content {
|
||||
position: relative;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.fm-content.fm-drag-over {
|
||||
border: 2px dashed var(--accent-color);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.fm-dropzone-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
z-index: 5;
|
||||
color: var(--accent-color);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fm-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2.5rem 1rem;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fm-folder-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--accent-color);
|
||||
font-weight: 500;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fm-folder-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fm-item-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.fm-file-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fm-meta {
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fm-actions {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fm-name-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fm-symlink-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-tertiary);
|
||||
cursor: help;
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
/* ============================================================================
|
||||
Forms
|
||||
============================================================================ */
|
||||
|
||||
.admin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-form-input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-sizing: border-box;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.admin-form-input:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.admin-form-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-form-input[type="date"],
|
||||
.admin-form-input[type="time"],
|
||||
.admin-form-input[type="month"],
|
||||
.admin-form-input[type="number"] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
text-align: left;
|
||||
height: 36px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.admin-form-input[type="number"]::-webkit-inner-spin-button,
|
||||
.admin-form-input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-form-input[type="date"]::-webkit-date-and-time-value,
|
||||
.admin-form-input[type="time"]::-webkit-date-and-time-value,
|
||||
.admin-form-input[type="month"]::-webkit-date-and-time-value {
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-form-input[type="date"]::-webkit-datetime-edit,
|
||||
.admin-form-input[type="time"]::-webkit-datetime-edit,
|
||||
.admin-form-input[type="month"]::-webkit-datetime-edit {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-form-input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
.admin-form-input[type="time"]::-webkit-calendar-picker-indicator,
|
||||
.admin-form-input[type="month"]::-webkit-calendar-picker-indicator {
|
||||
filter: var(--calendar-icon-filter, none);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Select */
|
||||
.admin-form-select {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: var(--select-arrow);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.admin-form-select:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.admin-form-select option {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Textarea */
|
||||
.admin-form-textarea {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.admin-form-textarea:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.admin-form-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-form-checkbox input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-form-checkbox input + span::before {
|
||||
content: "";
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
transition:
|
||||
border-color var(--transition),
|
||||
box-shadow var(--transition),
|
||||
background var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-form-checkbox input:checked + span::before {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: 12px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.admin-form-checkbox input:focus + span::before {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.admin-form-checkbox:hover
|
||||
input:not(:checked):not(:disabled):not(:indeterminate)
|
||||
+ span::before {
|
||||
border-color: var(--border-color-hover);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.admin-form-checkbox input:indeterminate + span::before {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='12' x2='18' y2='12'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-size: 12px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.admin-form-checkbox input:disabled + span::before {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-form-checkbox:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.admin-form-checkbox span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Reorderable List */
|
||||
.admin-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-reorder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.admin-reorder-arrows {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-reorder-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-reorder-label.accent {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.admin-reorder-arrows .admin-btn-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-reorder-arrows .admin-btn-icon:hover:not(:disabled) {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-reorder-arrows .admin-btn-icon:disabled {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* Form Rows (Grid Layouts) */
|
||||
.admin-form-row {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.admin-form-row-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.admin-form-row-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.admin-form-row-5 {
|
||||
grid-template-columns: 1.2fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-form-row-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.admin-form-row-5 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-form-row,
|
||||
.admin-form-row-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.admin-form-row-5 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-form-row-4,
|
||||
.admin-form-row-5 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Utilities */
|
||||
.admin-form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Required field indicator */
|
||||
.admin-form-label.required::after {
|
||||
content: " *";
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Inline field errors */
|
||||
.admin-form-group.has-error .admin-form-input,
|
||||
.admin-form-group.has-error .admin-form-select,
|
||||
.admin-form-group.has-error .admin-form-textarea {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 3px var(--danger-light);
|
||||
}
|
||||
|
||||
.admin-form-group.has-error .admin-form-label {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-form-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger);
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Touch targets - min 44px on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.admin-form-input,
|
||||
.admin-form-select,
|
||||
.admin-form-textarea {
|
||||
min-height: 44px;
|
||||
font-size: 16px; /* prevent auto-zoom on iOS */
|
||||
}
|
||||
|
||||
.admin-form-checkbox {
|
||||
min-height: 44px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.admin-form-checkbox input + span::before {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.admin-form-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Customer Selector
|
||||
============================================================================ */
|
||||
|
||||
.admin-customer-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-customer-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--input-bg);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-customer-selected span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-customer-selected .admin-btn-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-customer-dropdown-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.admin-customer-dropdown-item div:first-child {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown-item div:last-child {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.admin-customer-dropdown-empty {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
interface ApiCallResult<T> {
|
||||
data: T | null;
|
||||
ok: boolean;
|
||||
response: Response | null;
|
||||
}
|
||||
|
||||
export default function useApiCall() {
|
||||
const alert = useAlert();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const call = useCallback(
|
||||
async <T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
errorMsg = "Chyba při načítání dat",
|
||||
): Promise<ApiCallResult<T>> => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
alert.error(data.error || errorMsg);
|
||||
return { data: null, ok: false, response };
|
||||
}
|
||||
return { data: data.data as T, ok: true, response };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return { data: null, ok: false, response: null };
|
||||
}
|
||||
alert.error(errorMsg);
|
||||
return { data: null, ok: false, response: null };
|
||||
}
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
return { call };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import apiFetch from "../utils/api";
|
||||
import { isHoliday } from "../../utils/czech-holidays";
|
||||
import {
|
||||
calcProjectMinutesTotal,
|
||||
calcFormWorkMinutes,
|
||||
@@ -9,9 +11,17 @@ import {
|
||||
formatDate,
|
||||
formatMinutes,
|
||||
getLeaveTypeName,
|
||||
getLeaveTypeBadgeClass,
|
||||
formatTimeOrDatetimePrint,
|
||||
calculateWorkMinutesPrint,
|
||||
isWeekendDate,
|
||||
getDaysInMonth,
|
||||
shiftDateForMonth,
|
||||
formatHoursDecimal,
|
||||
calculateNightMinutes,
|
||||
normalizeDateStr,
|
||||
holidaysInMonth,
|
||||
calculateFreeHolidayHours,
|
||||
getCzechWeekday,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import type {
|
||||
ShiftFormData,
|
||||
@@ -28,7 +38,7 @@ interface AlertContext {
|
||||
alert: { success: (msg: string) => void; error: (msg: string) => void };
|
||||
}
|
||||
|
||||
interface AttendanceRecord {
|
||||
export interface AttendanceRecord {
|
||||
id: number;
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
@@ -38,7 +48,7 @@ interface AttendanceRecord {
|
||||
departure_time?: string | null;
|
||||
break_start?: string | null;
|
||||
break_end?: string | null;
|
||||
notes?: string;
|
||||
notes?: string | null;
|
||||
project_id?: number | null;
|
||||
project_name?: string;
|
||||
project_logs?: Array<{
|
||||
@@ -72,7 +82,6 @@ interface UserTotal {
|
||||
working: boolean;
|
||||
vacation_hours: number;
|
||||
sick_hours: number;
|
||||
holiday_hours: number;
|
||||
unpaid_hours: number;
|
||||
overtime: number;
|
||||
missing: number;
|
||||
@@ -80,6 +89,16 @@ interface UserTotal {
|
||||
business_days: number;
|
||||
worked_hours: number;
|
||||
covered: number;
|
||||
// mzda-style summary fields (set by computeUserTotals)
|
||||
worked_hours_raw?: number;
|
||||
odpracovano?: number;
|
||||
vcetne_svatku?: number;
|
||||
prescas?: number;
|
||||
svatek?: number;
|
||||
weekend_hours?: number;
|
||||
night_minutes?: number;
|
||||
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
||||
records?: AttendanceRecord[];
|
||||
}
|
||||
|
||||
interface LeaveBalance {
|
||||
@@ -120,6 +139,11 @@ const combineDatetime = (date: string, time: string): string | null =>
|
||||
/**
|
||||
* Compute per-user totals from raw attendance records.
|
||||
* This replaces the server-side `user_totals` that the PHP backend returned.
|
||||
*
|
||||
* Adds mzda-style summary fields (odpracovano, vcetne_svatku, prescas, svatek,
|
||||
* weekend_hours, night_minutes) and a fund computed as
|
||||
* (rawBizDays + holidayCount) × 8 — holidays count as work days for fund
|
||||
* purposes, per the mzda.pdf model.
|
||||
*/
|
||||
function computeUserTotals(
|
||||
records: AttendanceRecord[],
|
||||
@@ -127,6 +151,7 @@ function computeUserTotals(
|
||||
month: string,
|
||||
): Record<string, UserTotal> {
|
||||
const totals: Record<string, UserTotal> = {};
|
||||
const recordsByUser = new Map<number, AttendanceRecord[]>();
|
||||
|
||||
for (const rec of records) {
|
||||
const uid = String(rec.user_id);
|
||||
@@ -143,7 +168,6 @@ function computeUserTotals(
|
||||
working: false,
|
||||
vacation_hours: 0,
|
||||
sick_hours: 0,
|
||||
holiday_hours: 0,
|
||||
unpaid_hours: 0,
|
||||
overtime: 0,
|
||||
missing: 0,
|
||||
@@ -151,15 +175,23 @@ function computeUserTotals(
|
||||
business_days: 0,
|
||||
worked_hours: 0,
|
||||
covered: 0,
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
|
||||
const t = totals[uid];
|
||||
t.records!.push(rec);
|
||||
if (!recordsByUser.has(rec.user_id)) recordsByUser.set(rec.user_id, []);
|
||||
recordsByUser.get(rec.user_id)!.push(rec);
|
||||
|
||||
const leaveType = rec.leave_type || "work";
|
||||
|
||||
if (leaveType === "work") {
|
||||
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
|
||||
t.minutes += calculateWorkMinutes(rec);
|
||||
t.minutes += calculateWorkMinutes({
|
||||
...rec,
|
||||
notes: rec.notes ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const leaveHours = Number(rec.leave_hours) || 8;
|
||||
switch (leaveType) {
|
||||
@@ -169,12 +201,14 @@ function computeUserTotals(
|
||||
case "sick":
|
||||
t.sick_hours += leaveHours;
|
||||
break;
|
||||
case "holiday":
|
||||
t.holiday_hours += leaveHours;
|
||||
break;
|
||||
case "unpaid":
|
||||
t.unpaid_hours += leaveHours;
|
||||
break;
|
||||
// "holiday" leave_type is no longer selectable in the UI — it is
|
||||
// auto-computed from Czech public holidays (see freeHolidayHours
|
||||
// below). Old records may still exist in the DB; treat them as a
|
||||
// paid non-work entry and skip so they don't double-count with
|
||||
// the auto-detected svátek.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +223,10 @@ function computeUserTotals(
|
||||
const yr = parseInt(yearStr, 10);
|
||||
const mo = parseInt(monthStr, 10) - 1;
|
||||
|
||||
// Count business days in month (Mon-Fri)
|
||||
// Count Mon-Fri business days in month (INCLUDING holidays).
|
||||
// The fund is rawBizDays × 8 — holidays stay in the count so the
|
||||
// user is paid for them via the fund. Svátek (free holiday hours) is
|
||||
// computed separately as "8h per holiday the user did not work".
|
||||
let rawBizDays = 0;
|
||||
const cur = new Date(yr, mo, 1);
|
||||
while (cur.getMonth() === mo) {
|
||||
@@ -198,23 +235,100 @@ function computeUserTotals(
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
|
||||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
||||
const holidayCount = holidayDates.length;
|
||||
|
||||
for (const uid of Object.keys(totals)) {
|
||||
const t = totals[uid];
|
||||
// Subtract holiday days from business days for this user
|
||||
const holidayDays = Math.round(t.holiday_hours / 8);
|
||||
const bizDays = Math.max(0, rawBizDays - holidayDays);
|
||||
const fund = bizDays * 8;
|
||||
const workedHours = Math.round((t.minutes / 60) * 10) / 10;
|
||||
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
|
||||
const leaveHours = t.vacation_hours + t.sick_hours;
|
||||
const covered = Math.round((workedHours + leaveHours) * 10) / 10;
|
||||
const userRecords = recordsByUser.get(Number(uid)) || [];
|
||||
|
||||
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
|
||||
const workedHoursRaw = t.minutes / 60;
|
||||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
||||
|
||||
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
|
||||
const weekendHours = userRecords
|
||||
.filter(
|
||||
(r) =>
|
||||
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
|
||||
)
|
||||
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
|
||||
|
||||
// Práce v noci: minutes in 22:00-06:00 across all work records.
|
||||
const nightMinutes = userRecords
|
||||
.filter((r) => (r.leave_type || "work") === "work")
|
||||
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
|
||||
|
||||
// Svátek (free): 8h per holiday the user did not work.
|
||||
const freeHolidayHours = calculateFreeHolidayHours(
|
||||
userRecords,
|
||||
holidayDates,
|
||||
);
|
||||
|
||||
// Worked holidays: holidays the user DID work (counted toward
|
||||
// Odpracováno, but their 8h must not flow into Přesčas).
|
||||
const workedDatesSet = new Set(
|
||||
userRecords
|
||||
.filter((r) => (r.leave_type || "work") === "work")
|
||||
.map((r) => normalizeDateStr(r.shift_date))
|
||||
.filter(Boolean),
|
||||
);
|
||||
let workedHolidayCount = 0;
|
||||
let workedHolidayHours = 0;
|
||||
for (const hd of holidayDates) {
|
||||
if (workedDatesSet.has(hd)) workedHolidayCount++;
|
||||
}
|
||||
// Sum the actual hours worked on holiday dates (for the KPI card's
|
||||
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
|
||||
for (const r of userRecords) {
|
||||
if ((r.leave_type || "work") !== "work") continue;
|
||||
const d = normalizeDateStr(r.shift_date);
|
||||
if (d && holidayDates.includes(d)) {
|
||||
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
|
||||
}
|
||||
}
|
||||
|
||||
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
|
||||
const fund = rawBizDays * 8;
|
||||
|
||||
// Včetně svátků a přesčasů (mzda formula):
|
||||
// odpracovano + vacation + remainder − min(freeHols, workedHols × 8)
|
||||
//
|
||||
// Two-way logic matching the print:
|
||||
// • If the user worked at least one holiday, that worked holiday's
|
||||
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
|
||||
// subtract one 8h per worked holiday (capped at the total free
|
||||
// holiday hours — can't go negative).
|
||||
// • If the user did NOT work any holiday, no adjustment is needed:
|
||||
// all unworked holidays are already paid via the Svátek line and
|
||||
// don't flow into Přesčas, so vcetne_svatku just equals the
|
||||
// work + vacation + remainder.
|
||||
const remainder = workedHoursRaw - odpracovano;
|
||||
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
|
||||
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
|
||||
|
||||
// Přesčas = vcetneSv − odpracovano (derived).
|
||||
const prescas = vcetneSv - odpracovano;
|
||||
|
||||
// Legacy fields (kept so existing UI doesn't break).
|
||||
const workedHours = Math.round(workedHoursRaw * 10) / 10;
|
||||
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
|
||||
|
||||
t.fund = fund;
|
||||
t.business_days = bizDays;
|
||||
t.business_days = rawBizDays;
|
||||
t.worked_hours = workedHours;
|
||||
t.covered = covered;
|
||||
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
|
||||
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
|
||||
|
||||
t.worked_hours_raw = workedHoursRaw;
|
||||
t.odpracovano = odpracovano;
|
||||
t.vcetne_svatku = vcetneSv;
|
||||
t.prescas = prescas;
|
||||
t.svatek = freeHolidayHours;
|
||||
t.weekend_hours = weekendHours;
|
||||
t.night_minutes = nightMinutes;
|
||||
t.worked_holiday_hours = workedHolidayHours;
|
||||
}
|
||||
|
||||
return totals;
|
||||
@@ -224,12 +338,13 @@ function computeUserTotals(
|
||||
// Print helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderFundStatus(userData: Record<string, any>): string {
|
||||
if (userData.overtime > 0)
|
||||
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`;
|
||||
if (userData.missing > 0)
|
||||
return `<span style="color:#dc2626">−${userData.missing}h</span>`;
|
||||
return '<span style="color:#16a34a">splněno</span>';
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function buildProjectLogsHtml(record: Record<string, any>): string {
|
||||
@@ -255,29 +370,11 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
|
||||
h = 0;
|
||||
m = 0;
|
||||
}
|
||||
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)</div>`;
|
||||
return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return record.project_name || "—";
|
||||
}
|
||||
|
||||
function buildLeaveSummaryHtml(
|
||||
userId: string,
|
||||
userData: Record<string, any>,
|
||||
printData: Record<string, any>,
|
||||
): string {
|
||||
const bal = printData.leave_balances[userId];
|
||||
let parts = `<strong>Dovolená ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
|
||||
if (userData.vacation_hours > 0)
|
||||
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`;
|
||||
if (userData.sick_hours > 0)
|
||||
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`;
|
||||
if (userData.holiday_hours > 0)
|
||||
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`;
|
||||
if (userData.overtime > 0)
|
||||
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`;
|
||||
return `<div class="leave-summary">${parts}</div>`;
|
||||
return escapeHtml(record.project_name || "—");
|
||||
}
|
||||
|
||||
function buildUserSectionHtml(
|
||||
@@ -285,71 +382,215 @@ function buildUserSectionHtml(
|
||||
userData: Record<string, any>,
|
||||
printData: Record<string, any>,
|
||||
): string {
|
||||
const leaveHtml = printData.leave_balances[userId]
|
||||
? buildLeaveSummaryHtml(userId, userData, printData)
|
||||
: "";
|
||||
// Build a date-keyed lookup of the user's records.
|
||||
const recordsByDate = new Map<string, Record<string, any>>();
|
||||
for (const r of userData.records || []) {
|
||||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
||||
}
|
||||
|
||||
const recordRows = (userData.records || [])
|
||||
.map((record: any) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = calculateWorkMinutesPrint(record);
|
||||
const hours = Math.floor(workMinutes / 60);
|
||||
const mins = workMinutes % 60;
|
||||
const breakCell =
|
||||
isLeave || !record.break_start || !record.break_end
|
||||
? "—"
|
||||
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`;
|
||||
// Iterate one row per day of the month.
|
||||
const [yearStr, monthStr] = printData.month.split("-");
|
||||
const yr = parseInt(yearStr, 10);
|
||||
const mo = parseInt(monthStr, 10);
|
||||
const daysInMonth = getDaysInMonth(yr, mo);
|
||||
|
||||
return `<tr>
|
||||
<td>${formatDate(record.shift_date)}</td>
|
||||
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
|
||||
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
|
||||
const userRecords = (userData.records || []) as Record<string, any>[];
|
||||
|
||||
const rows: string[] = [];
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = shiftDateForMonth(yr, mo, d);
|
||||
const record = recordsByDate.get(dateStr);
|
||||
const weekend = isWeekendDate(dateStr);
|
||||
const holiday = isHoliday(dateStr);
|
||||
const leaveType = (record?.leave_type as string) || "work";
|
||||
const rowClasses = [
|
||||
weekend && "weekend",
|
||||
holiday && "holiday",
|
||||
leaveType === "vacation" && "vacation",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (!record) {
|
||||
rows.push(`<tr class="${rowClasses}">
|
||||
<td>${escapeHtml(formatDate(dateStr))}</td>
|
||||
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
||||
<td>—</td>
|
||||
<td class="text-center">—</td>
|
||||
<td class="text-center">—</td>
|
||||
<td class="text-center">—</td>
|
||||
<td class="text-center">—</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = calculateWorkMinutesPrint(record);
|
||||
const hours = Math.floor(workMinutes / 60);
|
||||
const mins = workMinutes % 60;
|
||||
const breakCell =
|
||||
isLeave || !record.break_start || !record.break_end
|
||||
? "—"
|
||||
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
|
||||
|
||||
let hoursCell: string;
|
||||
if (workMinutes > 0 && !isLeave) {
|
||||
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
|
||||
} else if (isLeave) {
|
||||
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
|
||||
} else {
|
||||
hoursCell = "—";
|
||||
}
|
||||
|
||||
rows.push(`<tr class="${rowClasses}">
|
||||
<td>${escapeHtml(formatDate(dateStr))}</td>
|
||||
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
||||
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
|
||||
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td>
|
||||
<td class="text-center">${breakCell}</td>
|
||||
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
|
||||
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
|
||||
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
|
||||
<td class="text-center">${hoursCell}</td>
|
||||
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
||||
<td>${record.notes || ""}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
<td>${escapeHtml(record.notes || "")}</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
const fundRow =
|
||||
userData.fund !== null
|
||||
? `<tr>
|
||||
<td colspan="6" class="text-right">Fond měsíce:</td>
|
||||
<td class="text-center">${userData.covered}h / ${userData.fund}h</td>
|
||||
<td colspan="2">${renderFundStatus(userData)}</td>
|
||||
</tr>`
|
||||
: "";
|
||||
// ----- mzda-style summary numbers (computed here, not from userData,
|
||||
// because the backend's getPrintData only returns the legacy fields). -----
|
||||
|
||||
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
|
||||
let workedMinutes = 0;
|
||||
let weekendHoursRaw = 0;
|
||||
let nightMinutes = 0;
|
||||
for (const r of userRecords) {
|
||||
const leaveType = r.leave_type || "work";
|
||||
if (leaveType !== "work") continue;
|
||||
const m = calculateWorkMinutesPrint(r);
|
||||
workedMinutes += m;
|
||||
if (isWeekendDate(r.shift_date)) {
|
||||
weekendHoursRaw += m / 60;
|
||||
}
|
||||
nightMinutes += calculateNightMinutes(r);
|
||||
}
|
||||
|
||||
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
||||
// "holiday" leave_type is auto-computed from Czech holidays further down
|
||||
// and no longer selectable in the UI, so it's deliberately omitted here.
|
||||
let vacationHours = 0,
|
||||
sickHours = 0;
|
||||
for (const r of userRecords) {
|
||||
const lt = r.leave_type || "work";
|
||||
if (lt === "work") continue;
|
||||
const h = Number(r.leave_hours) || 8;
|
||||
if (lt === "vacation") vacationHours += h;
|
||||
else if (lt === "sick") sickHours += h;
|
||||
}
|
||||
|
||||
// Odpracováno: floor(worked / 8) × 8.
|
||||
const workedHoursRaw = workedMinutes / 60;
|
||||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
||||
|
||||
// Free Svátek: 8h per holiday date the user did not work.
|
||||
const holidayDates = holidaysInMonth(yr, mo);
|
||||
const holidayCount = holidayDates.length;
|
||||
const workedDates = new Set(
|
||||
userRecords
|
||||
.filter((r) => (r.leave_type || "work") === "work")
|
||||
.map((r) => normalizeDateStr(r.shift_date))
|
||||
.filter(Boolean),
|
||||
);
|
||||
let freeHolidayHours = 0;
|
||||
let workedHolidayCount = 0;
|
||||
for (const hd of holidayDates) {
|
||||
if (workedDates.has(hd)) workedHolidayCount++;
|
||||
else freeHolidayHours += 8;
|
||||
}
|
||||
|
||||
// Fund: count Mon-Fri (incl. holidays) × 8h.
|
||||
let rawBizDays = 0;
|
||||
const cur = new Date(yr, mo - 1, 1);
|
||||
while (cur.getMonth() === mo - 1) {
|
||||
const dow = cur.getDay();
|
||||
if (dow !== 0 && dow !== 6) rawBizDays++;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
const businessDays = rawBizDays;
|
||||
const fund = businessDays * 8;
|
||||
|
||||
// Včetně svátků a přesčasů: floor(worked/8)*8 − worked_holiday*8 + vacation + remainder.
|
||||
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
|
||||
// into Přesčas — so we subtract it here. Unworked holidays are paid via
|
||||
// the fund and don't affect this line.)
|
||||
const remainder = workedHoursRaw - odpracovano;
|
||||
const vcetneSv =
|
||||
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
|
||||
|
||||
// Přesčas = #2 - #1.
|
||||
const prescas = vcetneSv - odpracovano;
|
||||
|
||||
const summaryHtml = `<div class="user-summary">
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Odpracováno:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(vcetneSv * 60)}h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Dovolená:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Přesčas:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Svátek:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">So/Ne:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Práce v noci:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
|
||||
</div>
|
||||
<div class="summary-row summary-fund">
|
||||
<span class="summary-label">Fond měsíce:</span>
|
||||
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
|
||||
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
|
||||
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
|
||||
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return `<div class="user-section">
|
||||
<div class="user-header">
|
||||
<h3>${userData.name}</h3>
|
||||
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
|
||||
<h3>${escapeHtml(userData.name)}</h3>
|
||||
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
|
||||
</div>
|
||||
${leaveHtml}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:70px">Datum</th>
|
||||
<th style="width:70px">Typ</th>
|
||||
<th class="text-center" style="width:70px">Příchod</th>
|
||||
<th class="text-center" style="width:90px">Pauza</th>
|
||||
<th class="text-center" style="width:70px">Odchod</th>
|
||||
<th class="text-center" style="width:80px">Hodiny</th>
|
||||
<th style="width:55px">Datum</th>
|
||||
<th style="width:55px">Den</th>
|
||||
<th style="width:60px">Typ</th>
|
||||
<th class="text-center" style="width:55px">Příchod</th>
|
||||
<th class="text-center" style="width:80px">Pauza</th>
|
||||
<th class="text-center" style="width:55px">Odchod</th>
|
||||
<th class="text-center" style="width:85px">Hodiny</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr></thead>
|
||||
<tbody>${recordRows}</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="6" class="text-right">Odpracováno:</td>
|
||||
<td class="text-center">${formatMinutes(userData.minutes)} h</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
${fundRow}
|
||||
</tfoot>
|
||||
<tbody>${rows.join("")}</tbody>
|
||||
</table>
|
||||
${summaryHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -365,9 +606,15 @@ function buildPrintHtml(
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Docházka - ${pData.month_name}</title>
|
||||
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
|
||||
@@ -397,19 +644,47 @@ function buildPrintHtml(
|
||||
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
|
||||
.user-section td { font-size: 10px; }
|
||||
.user-section tr:nth-child(even) { background: #f9f9f9; }
|
||||
/* Weekend and holiday row highlighting — must come after the
|
||||
:nth-child(even) rule and use higher specificity (tr.X td) to win. */
|
||||
.user-section tr.weekend td { background: #fde68a; }
|
||||
.user-section tr.holiday td { background: #bbf7d0; }
|
||||
.user-section tr.weekend.holiday td { background: #fcd34d; }
|
||||
.user-section tr.vacation td { background: #bfdbfe; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.user-section tfoot td { background: #eee; font-weight: 600; }
|
||||
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
|
||||
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-sick { background: #fee2e2; color: #dc2626; }
|
||||
.badge-holiday { background: #dcfce7; color: #16a34a; }
|
||||
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
|
||||
.badge-overtime { background: #fef3c7; color: #d97706; }
|
||||
.leave-summary {
|
||||
margin-top: 10px; padding: 8px 15px; background: #f9f9f9;
|
||||
border: 1px solid #ddd; font-size: 10px;
|
||||
.user-summary {
|
||||
margin-top: 10px; padding: 10px 15px;
|
||||
background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
|
||||
}
|
||||
.user-summary .summary-row {
|
||||
display: flex; justify-content: space-between; padding: 2px 0;
|
||||
}
|
||||
.user-summary .summary-label { color: #555; }
|
||||
.user-summary .summary-value { font-weight: 600; }
|
||||
.user-summary .summary-fund {
|
||||
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
||||
}
|
||||
.legend {
|
||||
display: flex; flex-wrap: wrap; gap: 14px;
|
||||
margin-top: 8px; padding-top: 6px;
|
||||
border-top: 1px dashed #ddd;
|
||||
font-size: 9px; color: #555;
|
||||
}
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.legend-swatch {
|
||||
display: inline-block; width: 12px; height: 12px;
|
||||
border: 1px solid #333; vertical-align: middle;
|
||||
}
|
||||
.legend-swatch.weekend { background: #fde68a; }
|
||||
.legend-swatch.holiday { background: #bbf7d0; }
|
||||
.legend-swatch.weekend-holiday { background: #fcd34d; }
|
||||
.legend-swatch.vacation { background: #bfdbfe; }
|
||||
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
|
||||
.print-wrapper-table > thead > tr > td,
|
||||
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
|
||||
@@ -428,11 +703,11 @@ function buildPrintHtml(
|
||||
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
|
||||
<div class="print-header-text">
|
||||
<h1>EVIDENCE DOCHÁZKY</h1>
|
||||
<div class="company">${companyName}</div>
|
||||
<div class="company">${escapeHtml(companyName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="print-header-right">
|
||||
<div class="period">${pData.month_name}</div>
|
||||
<div class="period">${escapeHtml(pData.month_name)}</div>
|
||||
${filterNote}
|
||||
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
|
||||
</div>
|
||||
@@ -452,6 +727,7 @@ function buildPrintHtml(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
const queryClient = useQueryClient();
|
||||
// ---- Core state ----
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [month, setMonth] = useState(() => {
|
||||
@@ -653,7 +929,15 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
formData: ShiftFormData,
|
||||
): boolean => {
|
||||
const totalWork = calcFormWorkMinutes(formData);
|
||||
const totalProject = calcProjectMinutesTotal(logs);
|
||||
const totalProject = calcProjectMinutesTotal(
|
||||
logs.map((l) => ({
|
||||
...l,
|
||||
project_id:
|
||||
l.project_id !== "" && l.project_id != null
|
||||
? Number(l.project_id)
|
||||
: undefined,
|
||||
})),
|
||||
);
|
||||
if (totalWork > 0 && totalProject !== totalWork) {
|
||||
const wH = Math.floor(totalWork / 60);
|
||||
const wM = totalWork % 60;
|
||||
@@ -762,6 +1046,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -833,6 +1118,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setShowBulkModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -856,7 +1142,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
const userName = record.users
|
||||
? `${record.users.first_name} ${record.users.last_name}`.trim() ||
|
||||
record.users.username
|
||||
: ((record as Record<string, unknown>).user_name as string) ||
|
||||
: ((record as unknown as Record<string, unknown>).user_name as string) ||
|
||||
`User #${record.user_id}`;
|
||||
const enriched = { ...record, user_name: userName };
|
||||
setEditingRecord(enriched);
|
||||
@@ -967,6 +1253,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -996,6 +1283,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, record: null });
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
alert.success(
|
||||
result.message || result.data?.message || "Záznam smazán",
|
||||
@@ -1037,7 +1325,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
|
||||
: "";
|
||||
const filterNote = pData.selected_user_name
|
||||
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>`
|
||||
? `<div class="filters">Zaměstnanec: ${escapeHtml(pData.selected_user_name)}</div>`
|
||||
: "";
|
||||
const bodyContent = buildPrintHtml(
|
||||
pData,
|
||||
@@ -1051,7 +1339,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
printWindow.document.open();
|
||||
printWindow.document.write(bodyContent);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => printWindow.print();
|
||||
printWindow.addEventListener("load", () => printWindow.print(), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user