Compare commits
135 Commits
495fdf6da2
...
v1.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
40cb5a4d76 | ||
|
|
ecd97ae5a3 | ||
|
|
d14e97d7bd | ||
|
|
ef891f8e01 | ||
|
|
96ba5d034f | ||
|
|
2402b7cbc8 | ||
|
|
79b2fa5570 | ||
|
|
35fa172d36 | ||
|
|
000a77ccf4 | ||
|
|
ecd9f6a181 | ||
|
|
68e6d80903 | ||
|
|
af1b41994c | ||
|
|
9779112066 | ||
|
|
e8d6dc1567 | ||
|
|
f9dd49591e | ||
|
|
8cdf057ab3 | ||
|
|
a3ef37d0d2 | ||
|
|
e0ea997c24 | ||
|
|
cde560a2c3 | ||
|
|
e6198e1b67 |
@@ -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
|
||||
|
||||
439
CLAUDE.md
Normal file
439
CLAUDE.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# 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 |
|
||||
| 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 frontend (57 .tsx files)
|
||||
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||
│ ├── contexts/ # AuthContext, AlertContext
|
||||
│ ├── components/ # Layout, modals, tables, editors
|
||||
│ ├── pages/ # One file per page/feature
|
||||
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
|
||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
||||
└── __tests__/ # Vitest tests (auth, numbering)
|
||||
|
||||
prisma/
|
||||
├── schema.prisma # 32 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: 3000)
|
||||
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`).
|
||||
|
||||
- Test coverage is minimal: only `auth` and `numbering` are tested.
|
||||
- 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.
|
||||
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
|
||||
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
|
||||
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
|
||||
|
||||
### 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)
|
||||
|
||||
---
|
||||
|
||||
## 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>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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. **Silent error catches** — A few service functions swallow errors in catch blocks. Always log at minimum; never use empty catch blocks.
|
||||
|
||||
5. **HTML sanitization gap** — Rich text fields in invoices use DOMPurify, but quotation scope and order scope fields may not. If modifying those, add sanitization.
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
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).
|
||||
953
package-lock.json
generated
953
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.3.2",
|
||||
"version": "1.9.0",
|
||||
"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": "",
|
||||
@@ -34,6 +38,8 @@
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.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 +48,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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
469
prisma/seed.ts
Normal file
469
prisma/seed.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
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.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());
|
||||
49
src/__tests__/auth.service.test.ts
Normal file
49
src/__tests__/auth.service.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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 and logs error for invalid JWT", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const result = await verifyAccessToken("invalid-token");
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
expect(consoleSpy.mock.calls[0][0]).toMatch(/JWT verification error/);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null for 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).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);
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
generateOfferNumber,
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
|
||||
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,19 +1,33 @@
|
||||
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 AlertContainer from "./components/AlertContainer";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import "./admin.css";
|
||||
import "./variables.css";
|
||||
import "./base.css";
|
||||
import "./forms.css";
|
||||
import "./buttons.css";
|
||||
import "./layout.css";
|
||||
import "./components.css";
|
||||
import "./tables.css";
|
||||
import "./datepicker.css";
|
||||
import "./filemanager.css";
|
||||
import "./pagination.css";
|
||||
import "./responsive.css";
|
||||
import "./login.css";
|
||||
import "./dashboard.css";
|
||||
import "./attendance.css";
|
||||
import "./plan.css";
|
||||
import "./settings.css";
|
||||
import "./offers.css";
|
||||
import "./invoices.css";
|
||||
import "./warehouse.css";
|
||||
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
@@ -24,6 +38,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"));
|
||||
@@ -35,76 +50,187 @@ 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"));
|
||||
|
||||
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>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/admin"
|
||||
element={<AttendanceAdmin />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/balances"
|
||||
element={<AttendanceBalances />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/requests"
|
||||
element={<LeaveRequests />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/approval"
|
||||
element={<LeaveApproval />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="offers" element={<Offers />} />
|
||||
<Route path="offers/new" element={<OfferDetail />} />
|
||||
<Route path="offers/:id" element={<OfferDetail />} />
|
||||
<Route
|
||||
path="offers/customers"
|
||||
element={<OffersCustomers />}
|
||||
/>
|
||||
<Route
|
||||
path="offers/templates"
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route path="warehouse/items" element={<WarehouseItems />} />
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts"
|
||||
element={<WarehouseReceipts />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/new"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id"
|
||||
element={<WarehouseReceiptDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id/edit"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues"
|
||||
element={<WarehouseIssues />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/new"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id"
|
||||
element={<WarehouseIssueDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id/edit"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reservations"
|
||||
element={<WarehouseReservations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory"
|
||||
element={<WarehouseInventory />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/new"
|
||||
element={<WarehouseInventoryForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/:id"
|
||||
element={<WarehouseInventoryDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reports"
|
||||
element={<WarehouseReports />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/suppliers"
|
||||
element={<WarehouseSuppliers />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/locations"
|
||||
element={<WarehouseLocations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/categories"
|
||||
element={<WarehouseCategories />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
3228
src/admin/admin.css
3228
src/admin/admin.css
File diff suppressed because it is too large
Load Diff
@@ -318,12 +318,14 @@
|
||||
|
||||
/* Leave Type Badges */
|
||||
.attendance-leave-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-right: 0.25rem;
|
||||
line-height: 1.2;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
411
src/admin/base.css
Normal file
411
src/admin/base.css
Normal file
@@ -0,0 +1,411 @@
|
||||
/* ============================================================================
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
}
|
||||
153
src/admin/buttons.css
Normal file
153
src/admin/buttons.css
Normal file
@@ -0,0 +1,153 @@
|
||||
/* ============================================================================
|
||||
Buttons
|
||||
============================================================================ */
|
||||
|
||||
.admin-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 8px 14px;
|
||||
/* 1px transparent border on ALL buttons so switching between primary
|
||||
and secondary doesn't cause a 1px layout shift (the border is always
|
||||
1px — only the colour changes). */
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Prevent buttons from growing when text changes to spinner + loading text */
|
||||
.admin-modal-footer .admin-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
.admin-modal-footer .admin-btn .admin-spinner {
|
||||
margin: -2px 0;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
padding: 6px 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
|
||||
}
|
||||
|
||||
.admin-btn .admin-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.admin-btn-primary .admin-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-secondary .admin-spinner {
|
||||
border-color: rgba(var(--text-secondary-rgb, 107, 114, 128), 0.3);
|
||||
border-top-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-btn-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
border-color: color-mix(in srgb, var(--danger) 88%, #000);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.admin-btn-danger .admin-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.admin-btn-icon:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-btn-icon.accent {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-btn-icon.accent:hover {
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.admin-btn-icon.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-btn-icon.danger:hover {
|
||||
background: var(--danger-light);
|
||||
}
|
||||
|
||||
/* Touch targets - min 44px on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.admin-btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
1101
src/admin/components.css
Normal file
1101
src/admin/components.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import DatePicker, { registerLocale } from "react-datepicker";
|
||||
import { cs } from "date-fns/locale";
|
||||
import { parse, format } from "date-fns";
|
||||
@@ -75,6 +75,19 @@ function NativeInput({
|
||||
disabled,
|
||||
}: NativeInputProps) {
|
||||
const type = modeToInputType[mode] || "date";
|
||||
// For time inputs, min/max must be in HH:mm format, not date format
|
||||
const formatTimeMinMax = (val: string | undefined): string | undefined => {
|
||||
if (!val) return undefined;
|
||||
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
|
||||
// otherwise it's not a valid time min/max — return undefined
|
||||
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
|
||||
if (val.includes(":")) return val.substring(0, 5);
|
||||
return undefined;
|
||||
};
|
||||
const minProp =
|
||||
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
|
||||
const maxProp =
|
||||
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
@@ -84,14 +97,14 @@ function NativeInput({
|
||||
className="admin-form-input"
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
min={minDate || undefined}
|
||||
max={maxDate || undefined}
|
||||
min={minProp}
|
||||
max={maxProp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminDatePickerProps {
|
||||
mode?: "date" | "month" | "datetime" | "time";
|
||||
mode?: "date" | "month" | "time";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
minDate?: string;
|
||||
@@ -111,7 +124,7 @@ export default function AdminDatePicker({
|
||||
disabled,
|
||||
placeholder,
|
||||
}: AdminDatePickerProps) {
|
||||
const useNative = useMemo(() => isTouchDevice(), []);
|
||||
const useNative = isTouchDevice();
|
||||
|
||||
if (useNative) {
|
||||
return (
|
||||
@@ -165,17 +178,20 @@ export default function AdminDatePicker({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const customInput = (
|
||||
<CustomInput
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const commonProps = {
|
||||
selected: toDate(value),
|
||||
onChange: handleChange,
|
||||
locale: "cs",
|
||||
customInput: (
|
||||
<CustomInput
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
customInput,
|
||||
placeholderText: placeholder,
|
||||
minDate: parseMinMax(minDate),
|
||||
maxDate: parseMinMax(maxDate),
|
||||
popperPlacement: "bottom-start" as const,
|
||||
|
||||
@@ -8,34 +8,13 @@ import {
|
||||
getLeaveTypeName,
|
||||
getLeaveTypeBadgeClass,
|
||||
} from "../utils/attendanceHelpers";
|
||||
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 {
|
||||
@@ -51,7 +30,7 @@ function formatBreak(record: AttendanceRecord): string {
|
||||
if (record.break_start) {
|
||||
return `${formatTime(record.break_start)} - ?`;
|
||||
}
|
||||
return "\u2014";
|
||||
return "—";
|
||||
}
|
||||
|
||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
@@ -64,21 +43,30 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
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}
|
||||
key={log.id ?? i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
@@ -86,8 +74,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:
|
||||
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
|
||||
{log.project_name || `#${log.project_id}`}{" "}
|
||||
{durationValid
|
||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||
: "—"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -104,7 +94,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return "\u2014";
|
||||
return "—";
|
||||
}
|
||||
|
||||
export default function AttendanceShiftTable({
|
||||
@@ -143,8 +133,12 @@ export default function AttendanceShiftTable({
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
||||
60
|
||||
: calculateWorkMinutes({
|
||||
...record,
|
||||
notes: record.notes ?? undefined,
|
||||
});
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
@@ -161,18 +155,16 @@ export default function AttendanceShiftTable({
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
|
||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatBreak(record)}
|
||||
{isLeave ? "—" : formatBreak(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
|
||||
{isLeave ? "—" : formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? `${formatMinutes(workMinutes)} h`
|
||||
: "\u2014"}
|
||||
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td>
|
||||
@@ -183,10 +175,10 @@ export default function AttendanceShiftTable({
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
>
|
||||
{"\uD83D\uDCCD"}
|
||||
<span aria-hidden="true">{"📍"}</span>
|
||||
</Link>
|
||||
) : (
|
||||
"\u2014"
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
|
||||
@@ -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,11 +39,11 @@ export default function BulkAttendanceModal({
|
||||
toggleUser,
|
||||
toggleAllUsers,
|
||||
}: BulkAttendanceModalProps) {
|
||||
useModalLock(show);
|
||||
useModalLock(isOpen);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -57,13 +57,21 @@ export default function BulkAttendanceModal({
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-attendance-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
|
||||
<h2
|
||||
id="bulk-attendance-modal-title"
|
||||
className="admin-modal-title"
|
||||
>
|
||||
Vyplnit docházku za měsíc
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
@@ -14,6 +14,71 @@ interface ConfirmModalProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function ConfirmIcon({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case "danger":
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
case "info":
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
case "warning":
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -26,6 +91,19 @@ export default function ConfirmModal({
|
||||
confirmVariant,
|
||||
loading,
|
||||
}: ConfirmModalProps) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !loading) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, loading, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
@@ -39,6 +117,9 @@ export default function ConfirmModal({
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-confirm-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -46,20 +127,11 @@ export default function ConfirmModal({
|
||||
>
|
||||
<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>
|
||||
<ConfirmIcon type={type} />
|
||||
</div>
|
||||
<h2 className="admin-confirm-title">{title}</h2>
|
||||
<h2 id="confirm-modal-title" className="admin-confirm-title">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="admin-confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
useId,
|
||||
} from "react";
|
||||
|
||||
interface FormFieldProps {
|
||||
label: ReactNode;
|
||||
@@ -15,13 +21,22 @@ export default function FormField({
|
||||
required,
|
||||
style,
|
||||
}: FormFieldProps) {
|
||||
const generatedId = useId();
|
||||
const childProps = isValidElement(children)
|
||||
? (children.props as Record<string, unknown>)
|
||||
: null;
|
||||
const childId = childProps?.id ? String(childProps.id) : generatedId;
|
||||
const childWithId = isValidElement(children)
|
||||
? cloneElement(children, { id: childId } as React.Attributes)
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div className="admin-form-group" style={style}>
|
||||
<label className="admin-form-label">
|
||||
<label className="admin-form-label" htmlFor={childId}>
|
||||
{label}
|
||||
{required && <span className="admin-form-required"> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
{childWithId}
|
||||
{error && <span className="admin-form-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
118
src/admin/components/FormModal.tsx
Normal file
118
src/admin/components/FormModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, type ReactNode, type FormEvent } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
|
||||
export interface FormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: () => void; // called when user clicks primary button (only if provided)
|
||||
title: string;
|
||||
children: ReactNode; // modal body (form fields)
|
||||
submitLabel?: string; // primary button text
|
||||
cancelLabel?: string; // cancel button text
|
||||
loading?: boolean;
|
||||
hideFooter?: boolean; // for "view-only" modals
|
||||
size?: "sm" | "md" | "lg"; // optional
|
||||
closeOnBackdrop?: boolean; // default true
|
||||
closeOnEsc?: boolean; // default true
|
||||
}
|
||||
|
||||
export default function FormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
children,
|
||||
submitLabel = "Uložit",
|
||||
cancelLabel = "Zrušit",
|
||||
loading = false,
|
||||
hideFooter = false,
|
||||
size = "md",
|
||||
closeOnBackdrop = true,
|
||||
closeOnEsc = true,
|
||||
}: FormModalProps) {
|
||||
useModalLock(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEsc) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !loading) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, closeOnEsc, loading, onClose]);
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (closeOnBackdrop && !loading) onClose();
|
||||
};
|
||||
|
||||
const handlePrimaryClick = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (onSubmit) onSubmit();
|
||||
};
|
||||
|
||||
const titleId = "form-modal-title";
|
||||
const modalClassName =
|
||||
size === "lg" ? "admin-modal admin-modal-lg" : "admin-modal";
|
||||
|
||||
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={handleBackdropClick} />
|
||||
<motion.div
|
||||
className={modalClassName}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 id={titleId} className="admin-modal-title">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">{children}</div>
|
||||
|
||||
{!hideFooter && (
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !loading && onClose()}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{onSubmit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrimaryClick}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Zpracování..." : submitLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
396
src/admin/components/OrderConfirmationModal.tsx
Normal file
396
src/admin/components/OrderConfirmationModal.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
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 [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 (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className={
|
||||
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-confirmation-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2
|
||||
id="order-confirmation-modal-title"
|
||||
className="admin-modal-title"
|
||||
>
|
||||
Potvrzení objednávky {orderNumber}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
{step === "choose" ? (
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("cs")}
|
||||
className={
|
||||
lang === "cs"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Čeština
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("en")}
|
||||
className={
|
||||
lang === "en"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">DPH</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(true)}
|
||||
className={
|
||||
applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
S DPH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(false)}
|
||||
className={
|
||||
!applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Bez DPH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Obsah potvrzení</label>
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUseExisting}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary w-full"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Použít položky z objednávky"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setItems(initialItems.length > 0 ? initialItems : []);
|
||||
setStep("edit");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-secondary w-full"
|
||||
>
|
||||
Upravit položky
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ minWidth: "200px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "80px" }}
|
||||
step="0.001"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "100px" }}
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "70px" }}
|
||||
step="1"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat položku
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
{step === "edit" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("choose")}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zpět
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditGenerate}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={loading || items.length === 0}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Vygenerovat PDF"
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === "choose" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -36,13 +36,18 @@ export default function Pagination({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-pagination">
|
||||
<div
|
||||
className="admin-pagination"
|
||||
role="navigation"
|
||||
aria-label="Stránkování"
|
||||
>
|
||||
<div className="admin-pagination-info">{total} záznamů</div>
|
||||
<div className="admin-pagination-controls">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="admin-pagination-page"
|
||||
aria-label="Předchozí stránka"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
@@ -65,6 +70,8 @@ export default function Pagination({
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`admin-pagination-page ${p === page ? "active" : ""}`}
|
||||
aria-label={`Stránka ${p}`}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
@@ -74,6 +81,7 @@ export default function Pagination({
|
||||
disabled={page >= total_pages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="admin-pagination-page"
|
||||
aria-label="Další stránka"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
|
||||
210
src/admin/components/PlanCategoriesModal.tsx
Normal file
210
src/admin/components/PlanCategoriesModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState } from "react";
|
||||
import FormModal from "./FormModal";
|
||||
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;
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Správa kategorií"
|
||||
hideFooter
|
||||
size="md"
|
||||
>
|
||||
<div className="plan-cat-manager">
|
||||
{categories.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
// 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) => {
|
||||
if (e.target.value !== c.color) {
|
||||
patch(c.id, { color: e.target.value });
|
||||
}
|
||||
}}
|
||||
aria-label={`Barva – ${c.label}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
defaultValue={c.label}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== c.label) patch(c.id, { label: v });
|
||||
}}
|
||||
aria-label={`Název – ${c.label}`}
|
||||
/>
|
||||
{confirmDeleteId === c.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => handleDelete(c.id)}
|
||||
>
|
||||
Opravdu smazat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
||||
>
|
||||
{c.is_active ? "Skrýt" : "Obnovit"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(c.id)}
|
||||
aria-label={`Smazat – ${c.label}`}
|
||||
title="Smazat kategorii"
|
||||
>
|
||||
Smazat
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="plan-cat-row plan-cat-row--new">
|
||||
<input
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
value={newColor}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
aria-label="Barva nové kategorie"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Nová kategorie…"
|
||||
value={newLabel}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
disabled={busy}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Přidat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
482
src/admin/components/PlanCellModal.tsx
Normal file
482
src/admin/components/PlanCellModal.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import FormModal from "./FormModal";
|
||||
import FormField from "./FormField";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormModal
|
||||
isOpen
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||
loading={submitting}
|
||||
footerLeft={
|
||||
mode.kind !== "create" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
Smazat
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<FormField label="Datum od">
|
||||
<AdminDatePicker
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
disabled={isOverride}
|
||||
/>
|
||||
</FormField>
|
||||
{!isOverride && (
|
||||
<FormField label="Rozsah dnů">
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRange}
|
||||
onChange={(e) => setIsRange(e.target.checked)}
|
||||
/>
|
||||
<span>Více dní</span>
|
||||
</label>
|
||||
</FormField>
|
||||
)}
|
||||
{!isOverride && isRange && (
|
||||
<FormField label="Datum do">
|
||||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Projekt">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={projectId ?? ""}
|
||||
onChange={(e) =>
|
||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
>
|
||||
<option value="">— bez projektu —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Kategorie">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{/* If the row's current category was retired/hidden, still show it
|
||||
(marked) so the select reflects reality instead of silently
|
||||
snapping to the first active option. The server only re-checks
|
||||
the category if it changes, so keeping it saves fine. */}
|
||||
{category && !activeCategories.some((c) => c.key === category) && (
|
||||
<option value={category}>
|
||||
{planCategoryLabel(
|
||||
category,
|
||||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
||||
)}{" "}
|
||||
(skrytá)
|
||||
</option>
|
||||
)}
|
||||
{activeCategories.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Text poznámky (volitelný)">
|
||||
<textarea
|
||||
className="admin-form-textarea"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
</FormModal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={confirmDelete}
|
||||
title="Smazat záznam plánu?"
|
||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||
confirmText="Smazat"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={submitting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
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 (
|
||||
<FormModal
|
||||
isOpen
|
||||
title="Den je součástí rozsahu"
|
||||
onClose={onClose}
|
||||
hideFooter
|
||||
size="md"
|
||||
>
|
||||
<p className="plan-modal-intro">
|
||||
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||
<strong>
|
||||
{mode.range.date_from} – {mode.range.date_to}
|
||||
</strong>
|
||||
. Vyberte, co chcete udělat:
|
||||
</p>
|
||||
<ul className="plan-choices">
|
||||
<AnimatePresence>
|
||||
{choices.map((c, i) => (
|
||||
<motion.li
|
||||
key={c.key}
|
||||
className={`plan-choice plan-choice-${c.tone}`}
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||
transition={{
|
||||
duration: reducedMotion ? 0 : 0.22,
|
||||
delay: reducedMotion ? 0 : 0.04 + i * 0.05,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="plan-choice-body">
|
||||
<div className="plan-choice-title">{c.title}</div>
|
||||
<div className="plan-choice-desc">{c.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
c.tone === "primary"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
onClick={c.onClick}
|
||||
>
|
||||
{c.cta}
|
||||
</button>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zavřít — ponechat rozsah beze změny
|
||||
</button>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
||||
<p>
|
||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Kategorie:</strong>{" "}
|
||||
{planCategoryLabel(
|
||||
c.category,
|
||||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Text:</strong> {c.note || "—"}
|
||||
</p>
|
||||
{c.rangeFrom && c.rangeTo && (
|
||||
<p>
|
||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||
{formatDate(c.rangeTo)}
|
||||
</p>
|
||||
)}
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
260
src/admin/components/PlanGrid.tsx
Normal file
260
src/admin/components/PlanGrid.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
GridData,
|
||||
ResolvedCell,
|
||||
planCategoryLabel,
|
||||
categoryMap,
|
||||
PlanCategory,
|
||||
} from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
import PlanRangeChips from "./PlanRangeChips";
|
||||
|
||||
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 (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="plan-grid-wrap" 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { projectFilesOptions } from "../lib/queries/projects";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import apiFetch from "../utils/api";
|
||||
@@ -196,13 +198,11 @@ export default function ProjectFileManager({
|
||||
hasNasFolder,
|
||||
}: ProjectFileManagerProps) {
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isCancelling = useRef(false);
|
||||
|
||||
const [items, setItems] = useState<FileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
const [breadcrumb, setBreadcrumb] = useState<string[]>([""]);
|
||||
const [fullPath, setFullPath] = useState("");
|
||||
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -216,59 +216,25 @@ export default function ProjectFileManager({
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const canManage = hasPermission("projects.files");
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (path = "", options: { ignore?: boolean } = {}) => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ project_id: String(projectId) });
|
||||
if (path) {
|
||||
params.set("path", path);
|
||||
}
|
||||
const res = await apiFetch(`${API_BASE}/project-files?${params}`);
|
||||
if (options.ignore) return;
|
||||
if (res.status === 401) return;
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setItems(data.data.items || []);
|
||||
setBreadcrumb(data.data.breadcrumb || [""]);
|
||||
setCurrentPath(data.data.path || "");
|
||||
setFullPath(data.data.full_path || "");
|
||||
} else if (res.status === 404) {
|
||||
setItems([]);
|
||||
setBreadcrumb([""]);
|
||||
} else {
|
||||
setErrorMessage(data.error || "Nepodařilo se načíst soubory");
|
||||
}
|
||||
} catch {
|
||||
if (!options.ignore) {
|
||||
setErrorMessage("Chyba připojení");
|
||||
}
|
||||
} finally {
|
||||
if (!options.ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const opts = { ignore: false };
|
||||
fetchFiles("", opts);
|
||||
return () => {
|
||||
opts.ignore = true;
|
||||
};
|
||||
}, [fetchFiles]);
|
||||
const {
|
||||
data: filesData,
|
||||
isPending: filesLoading,
|
||||
error: filesError,
|
||||
} = useQuery(projectFilesOptions(projectId, currentPath));
|
||||
const items = filesData?.items ?? [];
|
||||
const breadcrumb = filesData?.breadcrumb ?? [""];
|
||||
const fullPath = filesData?.full_path ?? "";
|
||||
const errorMessage = filesError
|
||||
? filesError.message || "Nepodařilo se načíst soubory"
|
||||
: null;
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
setNewFolderMode(false);
|
||||
setRenamingItem(null);
|
||||
fetchFiles(path);
|
||||
setCurrentPath(path);
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = (index: number) => {
|
||||
@@ -331,7 +297,9 @@ export default function ProjectFileManager({
|
||||
? "Soubor byl nahrán"
|
||||
: `Nahráno ${successCount} souborů`;
|
||||
alert.success(msg);
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
}
|
||||
if (errorMsg) {
|
||||
alert.error(errorMsg);
|
||||
@@ -382,7 +350,9 @@ export default function ProjectFileManager({
|
||||
alert.success("Složka byla vytvořena");
|
||||
setNewFolderMode(false);
|
||||
setNewFolderName("");
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
||||
}
|
||||
@@ -443,7 +413,9 @@ export default function ProjectFileManager({
|
||||
? "Složka byla smazána"
|
||||
: "Soubor byl smazán",
|
||||
);
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se smazat");
|
||||
}
|
||||
@@ -478,7 +450,9 @@ export default function ProjectFileManager({
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert.success("Přejmenováno");
|
||||
fetchFiles(currentPath);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["projects", String(projectId), "files"],
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se přejmenovat");
|
||||
}
|
||||
@@ -494,31 +468,10 @@ export default function ProjectFileManager({
|
||||
setRenameValue(item.name);
|
||||
};
|
||||
|
||||
if (loading && items.length === 0 && !errorMessage) {
|
||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "0.5rem" }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: `${60 + i * 10}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -709,7 +662,7 @@ export default function ProjectFileManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && !loading ? (
|
||||
{items.length === 0 && !filesLoading ? (
|
||||
<div className="fm-empty">
|
||||
<svg
|
||||
width="32"
|
||||
@@ -768,10 +721,26 @@ export default function ProjectFileManager({
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename(item);
|
||||
if (e.key === "Escape") setRenamingItem(null);
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRename(item);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isCancelling.current = true;
|
||||
setRenamingItem(null);
|
||||
setRenameValue(item.name);
|
||||
setTimeout(() => {
|
||||
isCancelling.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isCancelling.current) {
|
||||
return;
|
||||
}
|
||||
handleRename(item);
|
||||
}}
|
||||
onBlur={() => handleRename(item)}
|
||||
/>
|
||||
) : (
|
||||
<FileNameCell
|
||||
|
||||
@@ -1,66 +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",
|
||||
];
|
||||
|
||||
const COLORS = [
|
||||
"#000000",
|
||||
"#1a1a1a",
|
||||
@@ -95,8 +36,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 +46,6 @@ const TOOLBAR = [
|
||||
];
|
||||
|
||||
const FORMATS = [
|
||||
"font",
|
||||
"size",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
@@ -159,9 +96,16 @@ 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="rich-editor"
|
||||
className="admin-rich-editor"
|
||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||
>
|
||||
<ReactQuill
|
||||
|
||||
@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
|
||||
|
||||
export interface ShiftFormModalProps {
|
||||
mode: "create" | "edit";
|
||||
show: boolean;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
form: ShiftFormData;
|
||||
@@ -85,7 +85,15 @@ export interface ShiftFormModalProps {
|
||||
|
||||
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);
|
||||
|
||||
@@ -196,7 +204,7 @@ function ProjectLogRow({
|
||||
|
||||
export default function ShiftFormModal({
|
||||
mode,
|
||||
show,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
@@ -208,7 +216,7 @@ export default function ShiftFormModal({
|
||||
onShiftDateChange,
|
||||
editingRecord,
|
||||
}: ShiftFormModalProps) {
|
||||
useModalLock(show);
|
||||
useModalLock(isOpen);
|
||||
const isCreate = mode === "create";
|
||||
const isWorkType = form.leave_type === "work";
|
||||
|
||||
@@ -240,7 +248,7 @@ export default function ShiftFormModal({
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -251,13 +259,16 @@ export default function ShiftFormModal({
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shift-form-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">
|
||||
<h2 id="shift-form-modal-title" className="admin-modal-title">
|
||||
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
</h2>
|
||||
{!isCreate && editingRecord && (
|
||||
@@ -326,7 +337,6 @@ export default function ShiftFormModal({
|
||||
<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>
|
||||
|
||||
@@ -116,7 +116,7 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/attendance/admin",
|
||||
label: "Správa",
|
||||
permission: "attendance.admin",
|
||||
permission: "attendance.manage",
|
||||
matchPrefix: "/attendance/admin",
|
||||
matchAlso: ["/attendance/create", "/attendance/location"],
|
||||
icon: (
|
||||
@@ -155,6 +155,26 @@ const menuSections: MenuSection[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/plan-work",
|
||||
label: "Plán prací",
|
||||
permission: ["attendance.manage", "attendance.record"],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -198,7 +218,7 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/trips/admin",
|
||||
label: "Správa",
|
||||
permission: "trips.admin",
|
||||
permission: "trips.manage",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -221,7 +241,7 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/vehicles",
|
||||
label: "Vozidla",
|
||||
permission: "trips.vehicles",
|
||||
permission: "vehicles.manage",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -315,7 +335,202 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/offers/customers",
|
||||
label: "Zákazníci",
|
||||
permission: "offers.view",
|
||||
permission: "customers.view",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Sklad",
|
||||
items: [
|
||||
{
|
||||
path: "/warehouse",
|
||||
label: "Přehled",
|
||||
permission: "warehouse.view",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/items",
|
||||
label: "Položky",
|
||||
permission: "warehouse.view",
|
||||
matchPrefix: "/warehouse/items",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/receipts",
|
||||
label: "Příjmy",
|
||||
permission: "warehouse.operate",
|
||||
matchPrefix: "/warehouse/receipts",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="17 11 12 6 7 11" />
|
||||
<line x1="12" y1="6" x2="12" y2="18" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/issues",
|
||||
label: "Výdeje",
|
||||
permission: "warehouse.operate",
|
||||
matchPrefix: "/warehouse/issues",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="7 13 12 18 17 13" />
|
||||
<line x1="12" y1="18" x2="12" y2="6" />
|
||||
<path d="M5 5h14" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/reservations",
|
||||
label: "Rezervace",
|
||||
permission: "warehouse.operate",
|
||||
matchPrefix: "/warehouse/reservations",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="22" y1="11" x2="16" y2="11" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/inventory",
|
||||
label: "Inventura",
|
||||
permission: "warehouse.inventory",
|
||||
matchPrefix: "/warehouse/inventory",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/reports",
|
||||
label: "Reporty",
|
||||
permission: "warehouse.view",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/categories",
|
||||
label: "Kategorie",
|
||||
permission: "warehouse.manage",
|
||||
matchPrefix: "/warehouse/categories",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/locations",
|
||||
label: "Lokace",
|
||||
permission: "warehouse.manage",
|
||||
matchPrefix: "/warehouse/locations",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/warehouse/suppliers",
|
||||
label: "Dodavatelé",
|
||||
permission: "warehouse.manage",
|
||||
matchPrefix: "/warehouse/suppliers",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -356,7 +571,13 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/settings",
|
||||
label: "Nastavení",
|
||||
permission: "settings.manage",
|
||||
permission: [
|
||||
"settings.company",
|
||||
"settings.banking",
|
||||
"settings.roles",
|
||||
"settings.system",
|
||||
"settings.templates",
|
||||
],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -109,10 +109,10 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
||||
}
|
||||
|
||||
const KPI_CLASS_MAP: Record<number, string> = {
|
||||
4: "dash-kpi-4",
|
||||
3: "dash-kpi-3",
|
||||
2: "dash-kpi-2",
|
||||
1: "dash-kpi-1",
|
||||
4: "admin-kpi-4",
|
||||
3: "admin-kpi-3",
|
||||
2: "admin-kpi-2",
|
||||
1: "admin-kpi-1",
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
@@ -121,11 +121,11 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "dash-kpi-4";
|
||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`dash-kpi-grid ${kpiClass}`}
|
||||
className={`admin-kpi-grid ${kpiClass}`}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
|
||||
@@ -89,6 +89,21 @@ export default function DashProfile({
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
if (dataToSave.new_password && !dataToSave.current_password) {
|
||||
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
||||
return;
|
||||
}
|
||||
if (dataToSave.current_password && !dataToSave.new_password) {
|
||||
alert.error("Pro změnu hesla zadejte nové heslo");
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip empty password fields so Zod doesn't reject ""
|
||||
if (!dataToSave.current_password)
|
||||
delete (dataToSave as any).current_password;
|
||||
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||
method: "PUT",
|
||||
@@ -329,9 +344,24 @@ export default function DashProfile({
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Aktuální heslo</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Nové heslo (ponechte prázdné pro zachování stávajícího)
|
||||
Nové heslo
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -343,27 +373,9 @@ export default function DashProfile({
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||
/>
|
||||
</div>
|
||||
{formData.new_password && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Aktuální heslo
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Zadejte aktuální heslo pro potvrzení"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import ConfirmModal from "../ConfirmModal";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
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 +65,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,
|
||||
@@ -89,26 +78,6 @@ export default function DashSessions() {
|
||||
|
||||
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 +91,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 +112,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");
|
||||
@@ -183,97 +152,83 @@ export default function DashSessions() {
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
{sessionsLoading ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
)}
|
||||
{!sessionsLoading && sessions.length === 0 && (
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</div>
|
||||
)}
|
||||
{!sessionsLoading && sessions.length > 0 && (
|
||||
<div className="sessions-list">
|
||||
{sessions.map((session) => (
|
||||
) : (
|
||||
<>
|
||||
{sessions.length === 0 && (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`session-item ${session.is_current ? "session-item-current" : ""}`}
|
||||
className="text-secondary"
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<div className="session-icon">
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</div>
|
||||
<div className="session-info">
|
||||
<div className="session-device">
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
{session.is_current && (
|
||||
<span
|
||||
className="admin-badge admin-badge-success"
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Aktuální
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-meta">
|
||||
<span>{session.ip_address}</span>
|
||||
<span className="session-meta-separator">|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
{!session.is_current && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
Žádné aktivní relace
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sessions.length > 0 && (
|
||||
<div className="dash-sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
||||
>
|
||||
<div className="dash-session-icon">
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</div>
|
||||
<div className="dash-session-info">
|
||||
<div className="dash-session-device">
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
{session.is_current && (
|
||||
<span
|
||||
className="admin-badge admin-badge-success"
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Aktuální
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dash-session-meta">
|
||||
<span>{session.ip_address}</span>
|
||||
<span className="dash-session-meta-separator">|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-session-actions">
|
||||
{!session.is_current && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
74
src/admin/components/warehouse/BatchPicker.tsx
Normal file
74
src/admin/components/warehouse/BatchPicker.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../../lib/apiAdapter";
|
||||
|
||||
interface Batch {
|
||||
id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
received_at: string;
|
||||
is_consumed: boolean;
|
||||
}
|
||||
|
||||
interface BatchesResponse {
|
||||
batches: Batch[];
|
||||
total_stock: number;
|
||||
reserved_quantity: number;
|
||||
available_quantity: number;
|
||||
}
|
||||
|
||||
interface BatchPickerProps {
|
||||
itemId: number | null;
|
||||
value: number | null;
|
||||
onChange: (batchId: number, unitPrice: number) => void;
|
||||
}
|
||||
|
||||
export default function BatchPicker({
|
||||
itemId,
|
||||
value,
|
||||
onChange,
|
||||
}: BatchPickerProps) {
|
||||
const { data: response } = useQuery({
|
||||
queryKey: ["warehouse", "batches", itemId],
|
||||
queryFn: () =>
|
||||
jsonQuery<BatchesResponse>(
|
||||
`/api/admin/warehouse/items/${itemId}/batches`,
|
||||
),
|
||||
enabled: !!itemId,
|
||||
});
|
||||
|
||||
const batches = response?.batches ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{response && response.reserved_quantity > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-tertiary)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
|
||||
{response.reserved_quantity} ks)
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const batchId = Number(e.target.value);
|
||||
const batch = batches.find((b) => b.id === batchId);
|
||||
if (batch) onChange(batch.id, Number(batch.unit_price));
|
||||
}}
|
||||
>
|
||||
<option value="">-- auto FIFO --</option>
|
||||
{batches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
|
||||
ks | {Number(b.unit_price).toFixed(2)} Kč
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/admin/components/warehouse/ItemPicker.tsx
Normal file
228
src/admin/components/warehouse/ItemPicker.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
|
||||
|
||||
interface ItemPickerProps {
|
||||
value: number | null;
|
||||
onChange: (itemId: number) => void;
|
||||
itemName?: string;
|
||||
}
|
||||
|
||||
export default function ItemPicker({
|
||||
value,
|
||||
onChange,
|
||||
itemName,
|
||||
}: ItemPickerProps) {
|
||||
const [search, setSearch] = useState(itemName ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
|
||||
const items = data?.data ?? [];
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeIndexRef = useRef<number>(-1);
|
||||
const listboxId = useId();
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
||||
position: "fixed";
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
zIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 2,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 100,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset active index when items change
|
||||
useEffect(() => {
|
||||
if (activeIndex >= items.length) {
|
||||
activeIndexRef.current = -1;
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [items, activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
updatePosition();
|
||||
const onScroll = () => updatePosition();
|
||||
const onClose = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
} else {
|
||||
setDropdownStyle(null);
|
||||
activeIndexRef.current = -1;
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [open, updatePosition]);
|
||||
|
||||
// Close on click-outside via mousedown on document
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (containerRef.current && target instanceof Node) {
|
||||
if (!containerRef.current.contains(target)) {
|
||||
// Don't close if click landed on an option in the portal
|
||||
const listEl = document.getElementById(listboxId);
|
||||
if (listEl && listEl.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
||||
}, [open, listboxId]);
|
||||
|
||||
const handleSelect = (itemId: number, name: string) => {
|
||||
onChange(itemId);
|
||||
setOpen(false);
|
||||
setSearch(name);
|
||||
};
|
||||
|
||||
const setActive = (index: number) => {
|
||||
activeIndexRef.current = index;
|
||||
setActiveIndex(index);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
if (items.length > 0) setActive(0);
|
||||
return;
|
||||
}
|
||||
const next =
|
||||
activeIndexRef.current < items.length - 1
|
||||
? activeIndexRef.current + 1
|
||||
: 0;
|
||||
setActive(next);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
if (items.length > 0) setActive(items.length - 1);
|
||||
return;
|
||||
}
|
||||
const prev =
|
||||
activeIndexRef.current > 0
|
||||
? activeIndexRef.current - 1
|
||||
: items.length - 1;
|
||||
setActive(prev);
|
||||
} else if (e.key === "Enter") {
|
||||
if (
|
||||
open &&
|
||||
activeIndexRef.current >= 0 &&
|
||||
activeIndexRef.current < items.length
|
||||
) {
|
||||
e.preventDefault();
|
||||
const item = items[activeIndexRef.current];
|
||||
handleSelect(item.id, item.name);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
} else if (e.key === "Home") {
|
||||
if (open && items.length > 0) {
|
||||
e.preventDefault();
|
||||
setActive(0);
|
||||
}
|
||||
} else if (e.key === "End") {
|
||||
if (open && items.length > 0) {
|
||||
e.preventDefault();
|
||||
setActive(items.length - 1);
|
||||
}
|
||||
} else if (e.key === "Tab") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeId =
|
||||
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "relative" }}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-owns={listboxId}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat položku..."
|
||||
value={search}
|
||||
role="searchbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeId}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
updatePosition();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{open &&
|
||||
items.length > 0 &&
|
||||
dropdownStyle &&
|
||||
createPortal(
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="admin-item-picker-list"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={value === item.id}
|
||||
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(item.id, item.name);
|
||||
}}
|
||||
>
|
||||
<span className="admin-item-picker-name">{item.name}</span>
|
||||
{item.item_number && (
|
||||
<span className="admin-item-picker-number">
|
||||
{item.item_number}
|
||||
</span>
|
||||
)}
|
||||
{item.available_quantity !== undefined && (
|
||||
<span className="admin-item-picker-qty">
|
||||
{item.available_quantity} {item.unit}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/admin/components/warehouse/LocationSelect.tsx
Normal file
31
src/admin/components/warehouse/LocationSelect.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
warehouseLocationListOptions,
|
||||
type WarehouseLocation,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface LocationSelectProps {
|
||||
value: number | null;
|
||||
onChange: (locationId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function LocationSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: LocationSelectProps) {
|
||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- bez lokace --</option>
|
||||
{locations?.map((loc: WarehouseLocation) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{loc.code} - {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
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 { 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
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
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));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">— žádná rezervace —</option>
|
||||
{reservations.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
33
src/admin/components/warehouse/SupplierSelect.tsx
Normal file
33
src/admin/components/warehouse/SupplierSelect.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
warehouseSupplierListOptions,
|
||||
type WarehouseSupplier,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface SupplierSelectProps {
|
||||
value: number | null;
|
||||
onChange: (supplierId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function SupplierSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: SupplierSelectProps) {
|
||||
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
|
||||
const suppliers = data?.data ?? [];
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- bez dodavatele --</option>
|
||||
{suppliers.map((s: WarehouseSupplier) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
{s.ico ? ` (${s.ico})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
249
src/admin/components/warehouse/WarehouseMovementTable.tsx
Normal file
249
src/admin/components/warehouse/WarehouseMovementTable.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { ReactNode, useId, useRef } from "react";
|
||||
import ItemPicker from "./ItemPicker";
|
||||
import LocationSelect from "./LocationSelect";
|
||||
import BatchPicker from "./BatchPicker";
|
||||
|
||||
export interface MovementItem {
|
||||
key: string;
|
||||
item_id: number | null;
|
||||
item_name?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
location_id: number | null;
|
||||
batch_id: number | null;
|
||||
reservation_id: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export type MovementRowRenderer<T> = (
|
||||
item: T,
|
||||
index: number,
|
||||
updateItem: (field: string, value: unknown) => void,
|
||||
removeItem: () => void,
|
||||
) => ReactNode;
|
||||
|
||||
interface WarehouseMovementTableProps<T extends { key: string }> {
|
||||
items: T[];
|
||||
onChange: (items: T[]) => void;
|
||||
mode: "receipt" | "issue" | "inventory";
|
||||
defaultItem?: () => Omit<T, "key">;
|
||||
renderRow?: MovementRowRenderer<T>;
|
||||
renderHeader?: () => ReactNode;
|
||||
}
|
||||
|
||||
function parseDecimal(raw: string): number {
|
||||
// Strip leading minus if you want negative support; here we want only positive
|
||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
|
||||
item_id: null,
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
location_id: null,
|
||||
batch_id: null,
|
||||
reservation_id: null,
|
||||
notes: null,
|
||||
});
|
||||
|
||||
export default function WarehouseMovementTable<T extends { key: string }>({
|
||||
items,
|
||||
onChange,
|
||||
mode,
|
||||
defaultItem,
|
||||
renderRow,
|
||||
renderHeader,
|
||||
}: WarehouseMovementTableProps<T>) {
|
||||
const baseId = useId();
|
||||
const counterRef = useRef(0);
|
||||
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
|
||||
|
||||
const addItem = () => {
|
||||
const factory =
|
||||
defaultItem ??
|
||||
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
|
||||
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
|
||||
};
|
||||
const removeItem = (index: number) => {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
const updateItem = (index: number, field: string, value: unknown) => {
|
||||
const updated = [...items];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
// If a custom row renderer is supplied, the caller is fully in charge of
|
||||
// the row layout (and typically the column headers too — see inventory mode).
|
||||
if (renderRow) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-warehouse-movement-table">
|
||||
<table className="admin-table">
|
||||
{renderHeader && (
|
||||
<thead>
|
||||
<tr>{renderHeader()}</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.key}>
|
||||
{renderRow(
|
||||
item,
|
||||
index,
|
||||
(field, value) => updateItem(index, field, value),
|
||||
() => removeItem(index),
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat řádek
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
|
||||
const movementItems = items as unknown as MovementItem[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-warehouse-movement-table">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="admin-warehouse-col-item">Položka</th>
|
||||
{mode === "issue" && (
|
||||
<th className="admin-warehouse-col-batch">Šarže</th>
|
||||
)}
|
||||
<th className="admin-warehouse-col-qty">Množství</th>
|
||||
<th className="admin-warehouse-col-price">Cena/ks</th>
|
||||
<th className="admin-warehouse-col-location">Lokace</th>
|
||||
<th className="admin-warehouse-col-notes">Poznámka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movementItems.map((item, index) => (
|
||||
<tr key={item.key}>
|
||||
<td className="admin-warehouse-col-item">
|
||||
<ItemPicker
|
||||
value={item.item_id}
|
||||
itemName={item.item_name}
|
||||
onChange={(id) => updateItem(index, "item_id", id)}
|
||||
/>
|
||||
</td>
|
||||
{mode === "issue" && (
|
||||
<td className="admin-warehouse-col-batch">
|
||||
<BatchPicker
|
||||
itemId={item.item_id}
|
||||
value={item.batch_id}
|
||||
onChange={(batchId, unitPrice) => {
|
||||
updateItem(index, "batch_id", batchId);
|
||||
updateItem(index, "unit_price", unitPrice);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="admin-warehouse-col-qty">
|
||||
<input
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.quantity || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
"quantity",
|
||||
parseDecimal(e.target.value),
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
step="0.001"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+([\.,][0-9]+)?"
|
||||
aria-label={`Množství, řádek ${index + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-price">
|
||||
<input
|
||||
type="number"
|
||||
className="admin-form-input"
|
||||
value={item.unit_price || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
"unit_price",
|
||||
parseDecimal(e.target.value),
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={mode === "issue"}
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+([\.,][0-9]+)?"
|
||||
aria-label={`Cena za kus, řádek ${index + 1}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-location">
|
||||
<LocationSelect
|
||||
value={item.location_id}
|
||||
onChange={(id) => updateItem(index, "location_id", id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="admin-warehouse-col-notes">
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
value={item.notes ?? ""}
|
||||
onChange={(e) => updateItem(index, "notes", e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
onClick={() => removeItem(index)}
|
||||
title="Odebrat řádek"
|
||||
aria-label="Odebrat řádek"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat řádek
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,103 +1,3 @@
|
||||
/* ============================================================================
|
||||
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);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Dashboard
|
||||
============================================================================ */
|
||||
@@ -113,26 +13,6 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* KPI grid */
|
||||
.dash-kpi-grid {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.dash-kpi-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.dash-kpi-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.dash-kpi-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.dash-kpi-1 {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.dash-quick-actions {
|
||||
display: grid;
|
||||
@@ -512,16 +392,9 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.dash-kpi-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dash-kpi-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -543,9 +416,6 @@
|
||||
.dash-quick-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.dash-kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dash-profile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -555,12 +425,12 @@
|
||||
Sessions / Devices
|
||||
============================================================================ */
|
||||
|
||||
.sessions-list {
|
||||
.dash-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
.dash-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
@@ -569,23 +439,23 @@
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.session-item:last-child {
|
||||
.dash-session-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
.dash-session-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.session-item-current {
|
||||
.dash-session-item-current {
|
||||
background: var(--row-current);
|
||||
}
|
||||
|
||||
.session-item-current:hover {
|
||||
.dash-session-item-current:hover {
|
||||
background: var(--row-current-hover);
|
||||
}
|
||||
|
||||
.session-icon {
|
||||
.dash-session-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
@@ -597,17 +467,17 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-item-current .session-icon {
|
||||
.dash-session-item-current .dash-session-icon {
|
||||
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
.dash-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-device {
|
||||
.dash-session-device {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
@@ -616,7 +486,7 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
.dash-session-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
@@ -626,30 +496,30 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.session-meta-separator {
|
||||
.dash-session-meta-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
.dash-session-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.session-item {
|
||||
.dash-session-item {
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.session-icon {
|
||||
.dash-session-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.session-device {
|
||||
.dash-session-device {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
.dash-session-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
199
src/admin/datepicker.css
Normal file
199
src/admin/datepicker.css
Normal file
@@ -0,0 +1,199 @@
|
||||
/* ============================================================================
|
||||
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;
|
||||
}
|
||||
171
src/admin/filemanager.css
Normal file
171
src/admin/filemanager.css
Normal file
@@ -0,0 +1,171 @@
|
||||
/* ============================================================================
|
||||
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;
|
||||
}
|
||||
488
src/admin/forms.css
Normal file
488
src/admin/forms.css
Normal file
@@ -0,0 +1,488 @@
|
||||
/* ============================================================================
|
||||
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(() => {
|
||||
@@ -561,7 +837,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/users?limit=1000`);
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?action=attendance_users`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const apiUsers: ApiUser[] = result.data;
|
||||
@@ -651,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;
|
||||
@@ -760,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(
|
||||
@@ -831,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(
|
||||
@@ -854,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);
|
||||
@@ -965,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(
|
||||
@@ -994,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",
|
||||
@@ -1035,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,
|
||||
@@ -1049,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 {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import apiFetch from "../utils/api";
|
||||
import useDebounce from "./useDebounce";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface PaginationData {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface UseListDataOptions {
|
||||
dataKey?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
extraParams?: Record<string, string>;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export default function useListData<T = unknown>(
|
||||
endpoint: string,
|
||||
options: UseListDataOptions = {},
|
||||
) {
|
||||
const {
|
||||
dataKey,
|
||||
search = "",
|
||||
sort,
|
||||
order,
|
||||
page = 1,
|
||||
perPage = 25,
|
||||
extraParams = {},
|
||||
errorMsg = "Nepodařilo se načíst data",
|
||||
} = options;
|
||||
const alert = useAlert();
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
});
|
||||
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||
if (sort) params.set("sort", sort);
|
||||
if (order) params.set("order", order);
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v) params.set(k, v);
|
||||
});
|
||||
|
||||
const url = endpoint.startsWith("/")
|
||||
? `${endpoint}?${params}`
|
||||
: `${API_BASE}/${endpoint}?${params}`;
|
||||
const response = await apiFetch(url, { signal: controller.signal });
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const data = dataKey
|
||||
? result.data[dataKey]
|
||||
: Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data?.items || [];
|
||||
setItems(data || []);
|
||||
const pag =
|
||||
result.pagination ||
|
||||
(!Array.isArray(result.data) && result.data?.pagination) ||
|
||||
null;
|
||||
setPagination(
|
||||
pag || {
|
||||
total: data?.length ?? 0,
|
||||
page,
|
||||
per_page: perPage,
|
||||
total_pages: 1,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
alert.error(result.error || errorMsg);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
alert.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setInitialLoad(false);
|
||||
}
|
||||
}, [
|
||||
endpoint,
|
||||
debouncedSearch,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
perPage,
|
||||
dataKey,
|
||||
JSON.stringify(extraParams),
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
items,
|
||||
setItems,
|
||||
loading,
|
||||
initialLoad,
|
||||
pagination,
|
||||
refetch: fetchData,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
let activeLocks = 0;
|
||||
|
||||
export default function useModalLock(isOpen: boolean): void {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
if (activeLocks === 0) document.body.style.overflow = "hidden";
|
||||
activeLocks++;
|
||||
return () => {
|
||||
activeLocks = Math.max(0, activeLocks - 1);
|
||||
if (activeLocks === 0) document.body.style.overflow = "";
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
}
|
||||
|
||||
44
src/admin/hooks/usePaginatedQuery.ts
Normal file
44
src/admin/hooks/usePaginatedQuery.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
useQuery,
|
||||
keepPreviousData,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import type { UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around useQuery for paginated list endpoints.
|
||||
* Accepts the return value of queryOptions() from lib/queries/*
|
||||
* and extracts items + pagination from the response.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function usePaginatedQuery<T>(options: any) {
|
||||
const query = useQuery({
|
||||
...options,
|
||||
placeholderData: keepPreviousData,
|
||||
} as UseQueryOptions<PaginatedResult<T>>);
|
||||
|
||||
const data = query.data as PaginatedResult<T> | undefined;
|
||||
|
||||
return {
|
||||
items: (data?.data ?? []) as T[],
|
||||
pagination: data?.pagination ?? null,
|
||||
isPending: query.isPending,
|
||||
isFetching: query.isFetching,
|
||||
isPlaceholderData: query.isPlaceholderData,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export { useQueryClient, useQuery, keepPreviousData };
|
||||
527
src/admin/hooks/usePlanWork.ts
Normal file
527
src/admin/hooks/usePlanWork.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useMutation,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import apiFetch from "../utils/api";
|
||||
import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan";
|
||||
|
||||
export type ViewMode = "week" | "month";
|
||||
export type ModalMode =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
| { kind: "edit-range"; entryId: number; userId: number; date: string }
|
||||
| { kind: "edit-override"; overrideId: number; userId: number; date: string }
|
||||
| { kind: "day-in-range"; userId: number; date: string; entryId: number }
|
||||
| { kind: "view"; userId: number; date: string };
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const day = d.getUTCDay(); // 0 = Sun
|
||||
const diff = day === 0 ? -6 : 1 - day; // Monday-start
|
||||
const r = new Date(d);
|
||||
r.setUTCDate(d.getUTCDate() + diff);
|
||||
return r;
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setUTCDate(d.getUTCDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function apiCall(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<unknown> {
|
||||
const res = await apiFetch(url, options);
|
||||
if (!res.ok) {
|
||||
let message = "Chyba serveru";
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.error === "string") message = body.error;
|
||||
} catch {
|
||||
// body wasn't JSON; use default
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UsePlanWorkArgs {
|
||||
initialDate?: Date;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
const qc = useQueryClient();
|
||||
const [view, setView] = useState<ViewMode>("week");
|
||||
const [anchor, setAnchor] = useState<Date>(initialDate ?? new Date());
|
||||
const [filterActive, setFilterActive] = useState(true);
|
||||
const [modal, setModal] = useState<ModalMode>({ kind: "closed" });
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (view === "week") {
|
||||
const from = startOfWeek(anchor);
|
||||
const to = addDays(from, 6);
|
||||
return { from, to };
|
||||
}
|
||||
const from = new Date(
|
||||
Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth(), 1),
|
||||
);
|
||||
const to = new Date(
|
||||
Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0),
|
||||
);
|
||||
return { from, to };
|
||||
}, [view, anchor]);
|
||||
|
||||
const gridQuery = useQuery({
|
||||
queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
|
||||
queryFn: () =>
|
||||
apiFetch(
|
||||
`/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`,
|
||||
).then((r) => r.json().then((b: any) => b.data as GridData)),
|
||||
// keepPreviousData holds the old range's data visible while the new
|
||||
// range's fetch is in flight. Without this, `gridQuery.data` is
|
||||
// immediately undefined on key change and the grid flashes empty.
|
||||
// `isPlaceholderData` is true while the displayed data is stale.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: planKeys.all });
|
||||
// Plan mutations write audit-log rows, so they touch the audit domain
|
||||
// too. Invalidate the dashboard activity feed and the audit-log page so
|
||||
// they reflect the change without a manual F5 (prefix-matches all
|
||||
// ["audit-log", {...}] filter variants). The dashboard query is active
|
||||
// when visible and refetches immediately despite its staleTime.
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["audit-log"] });
|
||||
}, [qc]);
|
||||
|
||||
// --- Optimistic cache patches ---------------------------------------------
|
||||
// After a mutation succeeds, we patch the visible grid query's data
|
||||
// *before* the refetch resolves. This is what stops the full-grid
|
||||
// re-animation: the visible data updates in place (and the new cell
|
||||
// gets a one-shot pulse via PlanWork's lastMutated state), so the grid
|
||||
// never unmounts. The subsequent invalidate+refetch confirms the patch
|
||||
// from the server (which is the source of truth) and is a no-op if the
|
||||
// server's data matches our patch.
|
||||
//
|
||||
// If the mutation fails, the mutation's onError handler in PlanWork
|
||||
// rolls the cache back to the snapshot we took before the patch.
|
||||
|
||||
const currentGridKey = planKeys.grid(
|
||||
isoDate(range.from),
|
||||
isoDate(range.to),
|
||||
view,
|
||||
);
|
||||
|
||||
// Walk the days in [from, to] inclusive (YYYY-MM-DD strings). Used to
|
||||
// figure out which cell keys to patch for a range entry.
|
||||
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;
|
||||
}
|
||||
|
||||
// Snapshot the currently-cached grid for a given key. Returns
|
||||
// `undefined` if there's nothing cached (we don't patch empty data).
|
||||
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
|
||||
return qc.getQueryData<GridData>(key as any);
|
||||
}
|
||||
|
||||
// Apply a per-date cell patch to the cached grid at `key`. Returns the
|
||||
// previous cells[userId] map so the caller can roll back on error.
|
||||
function patchCells(
|
||||
key: readonly unknown[],
|
||||
userId: number,
|
||||
dates: string[],
|
||||
mutator: (prev: ResolvedCell | null) => ResolvedCell | null,
|
||||
): Record<string, ResolvedCell | null> | null {
|
||||
const prev = snapshotGrid(key);
|
||||
if (!prev) return null;
|
||||
const userPrev = prev.cells[userId] ?? {};
|
||||
const rolled: Record<string, ResolvedCell | null> = {};
|
||||
const userNext: Record<string, ResolvedCell | null> = { ...userPrev };
|
||||
for (const date of dates) {
|
||||
rolled[date] = userPrev[date] ?? null;
|
||||
userNext[date] = mutator(rolled[date]);
|
||||
}
|
||||
qc.setQueryData(key, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [userId]: userNext },
|
||||
});
|
||||
return rolled;
|
||||
}
|
||||
|
||||
// Build a ResolvedCell for a freshly-created entry. The server returns
|
||||
// a row with the same fields, so we mirror that shape. We don't have
|
||||
// entryId/overrideId from the request body — but the createEntry
|
||||
// response *does* include id; we could plumb it through. For now we
|
||||
// leave entryId as a placeholder that the refetch will replace.
|
||||
// (PlanGrid only uses source/entryId/overrideId to decide edit flow;
|
||||
// a fresh cell from an entry is "entry" source — the refetch fills
|
||||
// the actual id within ~200ms.)
|
||||
function makeEntryCell(args: {
|
||||
userId: number;
|
||||
date: string;
|
||||
projectId: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
rangeFrom: string;
|
||||
rangeTo: string;
|
||||
entryId: number | null;
|
||||
}): ResolvedCell {
|
||||
return {
|
||||
source: "entry",
|
||||
entryId: args.entryId,
|
||||
overrideId: null,
|
||||
user_id: args.userId,
|
||||
shift_date: args.date,
|
||||
project_id: args.projectId,
|
||||
// Optimistic placeholder — the invalidate+refetch fills the real
|
||||
// project label (resolveGrid embeds it) within ~200ms.
|
||||
project_number: null,
|
||||
project_name: null,
|
||||
category: args.category,
|
||||
note: args.note,
|
||||
rangeFrom: args.rangeFrom,
|
||||
rangeTo: args.rangeTo,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOverrideCell(args: {
|
||||
userId: number;
|
||||
date: string;
|
||||
projectId: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
overrideId: number | null;
|
||||
}): ResolvedCell {
|
||||
return {
|
||||
source: "override",
|
||||
entryId: null,
|
||||
overrideId: args.overrideId,
|
||||
user_id: args.userId,
|
||||
shift_date: args.date,
|
||||
project_id: args.projectId,
|
||||
// Optimistic placeholder — refetch fills the real project label.
|
||||
project_number: null,
|
||||
project_name: null,
|
||||
category: args.category,
|
||||
note: args.note,
|
||||
rangeFrom: null,
|
||||
rangeTo: null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
const createEntry = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
apiCall("/api/admin/plan/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (data: any, body: any) => {
|
||||
// Patch the visible grid with the new entry. We use the response
|
||||
// id (server-assigned) for the new entry's entryId; the rest of
|
||||
// the ResolvedCell mirrors the request body.
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
const id = data && typeof data.id === "number" ? data.id : null;
|
||||
const rolled = patchCells(currentGridKey, body.user_id, days, () =>
|
||||
makeEntryCell({
|
||||
userId: body.user_id,
|
||||
date: days[0],
|
||||
projectId: body.project_id ?? null,
|
||||
category: body.category,
|
||||
note: body.note,
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
}),
|
||||
);
|
||||
// Stash the rollback on the mutation object so PlanWork can call
|
||||
// it from onError.
|
||||
(createEntry as any)._rolled = rolled;
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateEntry = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
// We don't know the new full range without the response, but we
|
||||
// do have the body's date_from/date_to. If those are present,
|
||||
// patch the new range. Old cells outside the new range are NOT
|
||||
// cleared here — they'll either still be valid (date_from/to
|
||||
// were partial updates) or the refetch will fix them.
|
||||
const { id, body } = vars;
|
||||
if (body.date_from && body.date_to) {
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
// Find the user that owns this entry in the current grid by
|
||||
// looking for any cell with entryId === id (we already know
|
||||
// the id from vars; it doesn't change across the update).
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
let ownerUserId: number | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const cell of Object.values(byDate)) {
|
||||
if (cell && cell.entryId === id) {
|
||||
ownerUserId = Number(uidStr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ownerUserId !== null) break;
|
||||
}
|
||||
}
|
||||
if (ownerUserId !== null) {
|
||||
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) =>
|
||||
makeEntryCell({
|
||||
userId: ownerUserId!,
|
||||
date: days[0],
|
||||
projectId:
|
||||
body.project_id === undefined
|
||||
? (prev?.project_id ?? null)
|
||||
: body.project_id,
|
||||
category: body.category ?? prev?.category ?? "work",
|
||||
note: body.note ?? prev?.note ?? "",
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
}),
|
||||
);
|
||||
(updateEntry as any)._rolled = rolled;
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteEntry = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
// For delete we need to know the entry's user_id and full range.
|
||||
// Look it up from the current grid: find the user that has a cell
|
||||
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [, cell] of Object.entries(byDate)) {
|
||||
if (
|
||||
cell &&
|
||||
cell.entryId === vars.id &&
|
||||
cell.rangeFrom &&
|
||||
cell.rangeTo
|
||||
) {
|
||||
const days = eachDay(cell.rangeFrom, cell.rangeTo);
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
days,
|
||||
() => null,
|
||||
);
|
||||
(deleteEntry as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const createOverride = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
apiCall("/api/admin/plan/overrides", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (data: any, vars: any) => {
|
||||
const id = data && typeof data.id === "number" ? data.id : null;
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
vars.user_id,
|
||||
[vars.shift_date],
|
||||
() =>
|
||||
makeOverrideCell({
|
||||
userId: vars.user_id,
|
||||
date: vars.shift_date,
|
||||
projectId: vars.project_id ?? null,
|
||||
category: vars.category,
|
||||
note: vars.note,
|
||||
overrideId: id,
|
||||
}),
|
||||
);
|
||||
(createOverride as any)._rolled = rolled;
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateOverride = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
// Find the user/date for this overrideId in the current grid, then
|
||||
// patch that single cell with the new values.
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === vars.id) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
(prev) =>
|
||||
makeOverrideCell({
|
||||
userId: Number(uidStr),
|
||||
date,
|
||||
projectId: vars.body.project_id ?? prev?.project_id ?? null,
|
||||
category: vars.body.category ?? prev?.category ?? "work",
|
||||
note: vars.body.note ?? prev?.note ?? "",
|
||||
overrideId: vars.id,
|
||||
}),
|
||||
);
|
||||
(updateOverride as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteOverride = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === vars.id) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
() => null,
|
||||
);
|
||||
(deleteOverride as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Roll back an optimistic patch on mutation error. Called from
|
||||
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
|
||||
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
|
||||
// on it (see the `(createEntry as any)._rolled = …` writes above). Typed as
|
||||
// `unknown` + an explicit cast so callers can pass the mutation object
|
||||
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
||||
function rollbackMutation(mutation: unknown, userId: number) {
|
||||
const stash = mutation as {
|
||||
_rolled?: Record<string, ResolvedCell | null> | null;
|
||||
};
|
||||
if (!stash._rolled) return;
|
||||
const prev = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (!prev) return;
|
||||
const userPrev = prev.cells[userId] ?? {};
|
||||
const userNext = { ...userPrev };
|
||||
for (const [date, cell] of Object.entries(stash._rolled)) {
|
||||
userNext[date] = cell;
|
||||
}
|
||||
qc.setQueryData(currentGridKey, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [userId]: userNext },
|
||||
});
|
||||
stash._rolled = null;
|
||||
}
|
||||
|
||||
return {
|
||||
view,
|
||||
setView,
|
||||
anchor,
|
||||
setAnchor,
|
||||
filterActive,
|
||||
setFilterActive,
|
||||
range,
|
||||
grid: gridQuery.data,
|
||||
gridLoading: gridQuery.isLoading,
|
||||
gridFetching: gridQuery.isFetching,
|
||||
gridIsPlaceholder: gridQuery.isPlaceholderData,
|
||||
modal,
|
||||
setModal,
|
||||
canEdit,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
createOverride,
|
||||
updateOverride,
|
||||
deleteOverride,
|
||||
invalidate,
|
||||
// Exposed so PlanWork can roll back an optimistic patch when a
|
||||
// mutation throws. Pass the failed mutation and the userId it
|
||||
// targeted.
|
||||
rollbackMutation,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCell(
|
||||
grid: GridData | undefined,
|
||||
userId: number,
|
||||
date: string,
|
||||
): ResolvedCell | null {
|
||||
return grid?.cells?.[userId]?.[date] ?? null;
|
||||
}
|
||||
24
src/admin/hooks/useReducedMotion.ts
Normal file
24
src/admin/hooks/useReducedMotion.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Returns true when the user has expressed a preference for reduced motion
|
||||
* (OS-level setting: `prefers-reduced-motion: reduce`).
|
||||
*
|
||||
* SSR-safe: starts as `false` (the default state), so the first render uses
|
||||
* the normal animation. The effect then reconciles with the live matchMedia
|
||||
* state on the client and re-renders if needed.
|
||||
*/
|
||||
export default function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setReduced(mq.matches);
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface SortState {
|
||||
sort: string;
|
||||
@@ -13,10 +13,10 @@ export default function useTableSort(
|
||||
sort: defaultSort,
|
||||
order: defaultOrder,
|
||||
});
|
||||
const userClicked = useRef(false);
|
||||
const [userClicked, setUserClicked] = useState(false);
|
||||
|
||||
const handleSort = useCallback((column: string) => {
|
||||
userClicked.current = true;
|
||||
setUserClicked(true);
|
||||
setState((prev) => {
|
||||
if (prev.sort === column) {
|
||||
return { sort: column, order: prev.order === "asc" ? "desc" : "asc" };
|
||||
@@ -25,7 +25,7 @@ export default function useTableSort(
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activeSort = userClicked.current ? state.sort : null;
|
||||
const activeSort = userClicked ? state.sort : null;
|
||||
|
||||
return { sort: state.sort, order: state.order, handleSort, activeSort };
|
||||
}
|
||||
|
||||
582
src/admin/layout.css
Normal file
582
src/admin/layout.css
Normal file
@@ -0,0 +1,582 @@
|
||||
/* ============================================================================
|
||||
Layout
|
||||
============================================================================ */
|
||||
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-layout {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Sidebar
|
||||
============================================================================ */
|
||||
|
||||
/* -- Theme variables for sidebar -- */
|
||||
[data-theme="dark"] .admin-sidebar {
|
||||
--sb-bg: #141414;
|
||||
--sb-border: #2a2a2a;
|
||||
--sb-text: #a0a0a0;
|
||||
--sb-text-hover: #ddd;
|
||||
--sb-hover-bg: #1f1f1f;
|
||||
--sb-active-bg: #ffffff;
|
||||
--sb-active-text: #141414;
|
||||
--sb-label: #444;
|
||||
--sb-muted: #555;
|
||||
--sb-scrollbar: #333;
|
||||
}
|
||||
|
||||
[data-theme="light"] .admin-sidebar {
|
||||
--sb-bg: #ffffff;
|
||||
--sb-border: #e8e6e1;
|
||||
--sb-text: #7c7c84;
|
||||
--sb-text-hover: #1a1a1a;
|
||||
--sb-hover-bg: #f5f4f2;
|
||||
--sb-active-bg: #141414;
|
||||
--sb-active-text: #ffffff;
|
||||
--sb-label: #a0a0a0;
|
||||
--sb-muted: #a0a0a0;
|
||||
--sb-scrollbar: #ddd;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
z-index: 50;
|
||||
background: var(--sb-bg);
|
||||
border-right: 1px solid var(--sb-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
visibility 0.3s ease;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.admin-sidebar.open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-sidebar {
|
||||
right: auto;
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] .admin-sidebar {
|
||||
box-shadow:
|
||||
1px 0 0 0 var(--sb-border),
|
||||
4px 0 16px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Sidebar Overlay (mobile) */
|
||||
.admin-sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 49;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.admin-sidebar-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-sidebar-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar Header */
|
||||
.admin-sidebar-header {
|
||||
padding: 0 18px;
|
||||
height: 73px;
|
||||
border-bottom: 1px solid var(--sb-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar-logo {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.admin-sidebar-close {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--sb-text);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.admin-sidebar-close:hover {
|
||||
background: var(--sb-hover-bg);
|
||||
color: var(--sb-text-hover);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-sidebar-close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.admin-sidebar-nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.admin-sidebar-nav::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.admin-sidebar-nav::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.admin-sidebar-nav::-webkit-scrollbar-thumb {
|
||||
background: var(--sb-scrollbar);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
/* Nav Section */
|
||||
.admin-nav-section {
|
||||
padding: 14px 10px 6px;
|
||||
}
|
||||
|
||||
.admin-nav-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--sb-label);
|
||||
padding: 0 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Nav Item */
|
||||
.admin-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 7px;
|
||||
color: var(--sb-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 13px;
|
||||
font-weight: 450;
|
||||
margin-bottom: 1px;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-nav-item:hover {
|
||||
background: var(--sb-hover-bg);
|
||||
color: var(--sb-text-hover);
|
||||
}
|
||||
|
||||
.admin-nav-item.active {
|
||||
background: var(--sb-active-bg);
|
||||
color: var(--sb-active-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-nav-item.active svg {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.admin-nav-item svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.admin-nav-item {
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.admin-sidebar-footer {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--sb-border);
|
||||
padding: 14px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-name {
|
||||
color: var(--sb-text-hover);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-user-role {
|
||||
color: var(--sb-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--sb-text);
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-logout-btn:hover {
|
||||
background: var(--sb-hover-bg);
|
||||
color: var(--sb-text-hover);
|
||||
}
|
||||
|
||||
.admin-logout-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-sidebar-footer {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.admin-user-chip {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.admin-logout-btn {
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Main Content Area
|
||||
============================================================================ */
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-main {
|
||||
margin-left: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.admin-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
height: calc(73px + env(safe-area-inset-top, 0px));
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-header {
|
||||
left: 220px;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-menu-btn {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.admin-menu-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-menu-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-header-theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-header-theme-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.admin-theme-icon {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0;
|
||||
transform: rotate(180deg) scale(0.5);
|
||||
}
|
||||
|
||||
.admin-theme-icon.visible {
|
||||
opacity: 1;
|
||||
transform: rotate(0) scale(1);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
padding-top: calc(73px + 1rem + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-content {
|
||||
padding: 28px 32px;
|
||||
padding-top: calc(73px + 28px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Page Headers
|
||||
============================================================================ */
|
||||
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-page-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-heading);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-page-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-page-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-page-actions .admin-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Grid System
|
||||
============================================================================ */
|
||||
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-grid > .admin-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-grid-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-grid-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-grid-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-grid-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page header on mobile */
|
||||
@media (max-width: 480px) {
|
||||
.admin-page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.admin-page-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 12px !important;
|
||||
padding-top: calc(73px + 12px + env(safe-area-inset-top, 0px)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid - single column on small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.admin-grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Settings Grid
|
||||
============================================================================ */
|
||||
|
||||
.admin-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-settings-grid > .admin-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
83
src/admin/lib/apiAdapter.ts
Normal file
83
src/admin/lib/apiAdapter.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
/**
|
||||
* Thin adapter that converts apiFetch responses into the shape TanStack Query expects.
|
||||
* - Checks response.ok and result.success
|
||||
* - Throws on errors so TanStack Query can handle retry/error states
|
||||
* - Returns result.data directly (unwrapped from the API envelope)
|
||||
*/
|
||||
export async function jsonQuery<T>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(url, options);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
let result: { success: boolean; data?: unknown; error?: string };
|
||||
try {
|
||||
result = (await response.json()) as typeof result;
|
||||
} catch {
|
||||
throw new Error("Invalid JSON response");
|
||||
}
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || `Request failed (${response.status})`);
|
||||
}
|
||||
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export async function paginatedJsonQuery<T>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
): Promise<PaginatedResult<T>> {
|
||||
const response = await apiFetch(url, options);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
let result: {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
pagination?: PaginationMeta;
|
||||
};
|
||||
try {
|
||||
result = (await response.json()) as typeof result;
|
||||
} catch {
|
||||
throw new Error("Invalid JSON response");
|
||||
}
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || `Request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const items = Array.isArray(result.data)
|
||||
? result.data
|
||||
: ((result.data as { items?: T[] })?.items ?? []);
|
||||
const pagination = result.pagination ??
|
||||
(result.data as { pagination?: PaginationMeta })?.pagination ?? {
|
||||
total: items.length,
|
||||
page: 1,
|
||||
per_page: items.length,
|
||||
total_pages: 1,
|
||||
};
|
||||
|
||||
return { data: items as T[], pagination };
|
||||
}
|
||||
185
src/admin/lib/queries/attendance.ts
Normal file
185
src/admin/lib/queries/attendance.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
interface LocationRaw {
|
||||
users?: { first_name: string; last_name: string };
|
||||
user_name?: string;
|
||||
shift_date: string;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
arrival_lat?: string | number | null;
|
||||
arrival_lng?: string | number | null;
|
||||
arrival_accuracy?: number | null;
|
||||
arrival_address?: string | null;
|
||||
departure_lat?: string | number | null;
|
||||
departure_lng?: string | number | null;
|
||||
departure_accuracy?: number | null;
|
||||
departure_address?: string | null;
|
||||
}
|
||||
|
||||
export interface LocationRecord {
|
||||
user_name: string;
|
||||
shift_date: string;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
arrival_lat?: string | number | null;
|
||||
arrival_lng?: string | number | null;
|
||||
arrival_accuracy?: number | null;
|
||||
arrival_address?: string | null;
|
||||
departure_lat?: string | number | null;
|
||||
departure_lng?: string | number | null;
|
||||
departure_accuracy?: number | null;
|
||||
departure_address?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectLogEntry {
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
project_name?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string | null;
|
||||
hours?: string | number | null;
|
||||
minutes?: string | number | null;
|
||||
}
|
||||
|
||||
export interface AttendanceRecord {
|
||||
id: number;
|
||||
shift_date: string;
|
||||
leave_type?: string;
|
||||
leave_hours?: number;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
break_start?: string | null;
|
||||
break_end?: string | null;
|
||||
notes?: string;
|
||||
project_name?: string;
|
||||
project_logs?: ProjectLogEntry[];
|
||||
}
|
||||
|
||||
export interface BalanceEntry {
|
||||
name: string;
|
||||
vacation_total: number;
|
||||
vacation_used: number;
|
||||
vacation_remaining: number;
|
||||
sick_used: number;
|
||||
}
|
||||
|
||||
export interface UserShort {
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BalancesData {
|
||||
users: UserShort[];
|
||||
balances: Record<string, BalanceEntry>;
|
||||
}
|
||||
|
||||
export interface FundUserData {
|
||||
name: string;
|
||||
worked: number;
|
||||
covered: number;
|
||||
overtime: number;
|
||||
missing: number;
|
||||
}
|
||||
|
||||
export interface MonthFundData {
|
||||
month_name: string;
|
||||
fund: number;
|
||||
fund_to_date: number;
|
||||
business_days: number;
|
||||
users?: Record<string, FundUserData>;
|
||||
}
|
||||
|
||||
export interface FundData {
|
||||
months: Record<string, MonthFundData>;
|
||||
holidays: unknown[];
|
||||
users: UserShort[];
|
||||
balances: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProjectUser {
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
project_id: number | null;
|
||||
project_number?: string;
|
||||
project_name?: string;
|
||||
hours: number;
|
||||
users: ProjectUser[];
|
||||
}
|
||||
|
||||
export interface MonthProjectData {
|
||||
month_name: string;
|
||||
projects: ProjectEntry[];
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
months: Record<string, MonthProjectData>;
|
||||
}
|
||||
|
||||
export const attendanceHistoryOptions = (filters: {
|
||||
month?: string;
|
||||
userId?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "history", filters],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "1000");
|
||||
if (filters.month) params.set("month", filters.month);
|
||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||
return jsonQuery<AttendanceRecord[]>(
|
||||
`/api/admin/attendance?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const attendanceBalancesOptions = (year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "balances", year],
|
||||
queryFn: () =>
|
||||
jsonQuery<BalancesData>(
|
||||
`/api/admin/attendance?action=balances&year=${year}`,
|
||||
),
|
||||
});
|
||||
|
||||
export const attendanceWorkFundOptions = (year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "workfund", year],
|
||||
queryFn: () =>
|
||||
jsonQuery<FundData>(`/api/admin/attendance?action=workfund&year=${year}`),
|
||||
});
|
||||
|
||||
export const attendanceProjectReportOptions = (year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "project-report", year],
|
||||
queryFn: () =>
|
||||
jsonQuery<ProjectData>(
|
||||
`/api/admin/attendance?action=project_report&year=${year}`,
|
||||
),
|
||||
});
|
||||
|
||||
export const attendanceLocationOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "location", id],
|
||||
queryFn: async (): Promise<LocationRecord> => {
|
||||
const response = await apiFetch(
|
||||
`/api/admin/attendance?action=location&id=${id}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Záznam nebyl nalezen");
|
||||
}
|
||||
const raw = (result.data.record || result.data) as LocationRaw;
|
||||
const userName = raw.users
|
||||
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
|
||||
: raw.user_name || "";
|
||||
const { users: _users, ...rest } = raw;
|
||||
return { ...rest, user_name: userName };
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
28
src/admin/lib/queries/auditLog.ts
Normal file
28
src/admin/lib/queries/auditLog.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export const auditLogOptions = (filters: {
|
||||
search?: string;
|
||||
action?: string;
|
||||
entityType?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["audit-log", filters],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.action) params.set("action", filters.action);
|
||||
if (filters.entityType) params.set("entity_type", filters.entityType);
|
||||
if (filters.dateFrom) params.set("date_from", filters.dateFrom);
|
||||
if (filters.dateTo) params.set("date_to", filters.dateTo);
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
const qs = params.toString();
|
||||
return jsonQuery<{
|
||||
data: Record<string, unknown>[];
|
||||
pagination: Record<string, unknown>;
|
||||
}>(`/api/admin/audit-log${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
});
|
||||
28
src/admin/lib/queries/common.ts
Normal file
28
src/admin/lib/queries/common.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface BankAccount {
|
||||
id: number;
|
||||
account_name: string;
|
||||
bank_name: string;
|
||||
account_number: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
currency: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export const bankAccountsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["bank-accounts"],
|
||||
queryFn: () => jsonQuery<BankAccount[]>("/api/admin/bank-accounts"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
export const supplierListOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["suppliers"],
|
||||
queryFn: () =>
|
||||
jsonQuery<string[]>("/api/admin/received-invoices/suppliers"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
42
src/admin/lib/queries/dashboard.ts
Normal file
42
src/admin/lib/queries/dashboard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export const dashboardOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
export const require2FAOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["settings", "2fa"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
|
||||
});
|
||||
|
||||
export interface Session {
|
||||
id: number | string;
|
||||
is_current: boolean;
|
||||
device_info?: {
|
||||
icon?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
};
|
||||
ip_address: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const sessionsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async (): Promise<Session[]> => {
|
||||
const response = await apiFetch("/api/admin/sessions");
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
return Array.isArray(data.data) ? data.data : data.data?.sessions || [];
|
||||
}
|
||||
throw new Error(data.error || "Nepodařilo se načíst relace");
|
||||
},
|
||||
});
|
||||
209
src/admin/lib/queries/invoices.ts
Normal file
209
src/admin/lib/queries/invoices.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_name: string | null;
|
||||
status: string;
|
||||
issue_date: string;
|
||||
due_date: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface InvoiceStats {
|
||||
paid_month: CurrencyAmount[];
|
||||
paid_month_czk: number;
|
||||
paid_month_count: number;
|
||||
awaiting: CurrencyAmount[];
|
||||
awaiting_czk: number;
|
||||
awaiting_count: number;
|
||||
overdue: CurrencyAmount[];
|
||||
overdue_czk: number;
|
||||
overdue_count: number;
|
||||
vat_month: CurrencyAmount[];
|
||||
vat_month_czk: number;
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
vat_rate?: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_id: number | null;
|
||||
customer_name: string;
|
||||
customer?: {
|
||||
company_id?: string;
|
||||
vat_id?: string;
|
||||
};
|
||||
status: string;
|
||||
issue_date: string;
|
||||
due_date: string;
|
||||
taxable_date?: string;
|
||||
tax_date?: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
exchange_rate: string;
|
||||
notes?: string;
|
||||
payment_method?: string;
|
||||
variable_symbol?: string;
|
||||
constant_symbol?: string;
|
||||
issued_by?: string | null;
|
||||
paid_date?: string;
|
||||
billing_text?: string;
|
||||
bank_name?: string;
|
||||
bank_swift?: string;
|
||||
bank_iban?: string;
|
||||
bank_account?: string;
|
||||
bank_account_id?: number | null;
|
||||
items?: InvoiceItem[];
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
total: number;
|
||||
order?: {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status?: string;
|
||||
} | null;
|
||||
order_id?: number;
|
||||
order_number?: string;
|
||||
has_pdf?: boolean;
|
||||
valid_transitions?: string[];
|
||||
}
|
||||
|
||||
export interface ReceivedInvoice {
|
||||
id: number;
|
||||
supplier_name: string;
|
||||
invoice_number: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
vat_rate: number;
|
||||
issue_date: string;
|
||||
due_date: string;
|
||||
notes: string;
|
||||
status: string;
|
||||
file_name?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ReceivedStats {
|
||||
total_month: CurrencyAmount[];
|
||||
total_month_czk: number | null;
|
||||
vat_month: CurrencyAmount[];
|
||||
vat_month_czk: number | null;
|
||||
unpaid: CurrencyAmount[];
|
||||
unpaid_czk: number | null;
|
||||
unpaid_count: number;
|
||||
month_count: number;
|
||||
}
|
||||
|
||||
export const invoiceListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
status?: string;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", "list", filters],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.sort) params.set("sort", filters.sort);
|
||||
if (filters.order) params.set("order", filters.order);
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery<Invoice>(
|
||||
`/api/admin/invoices${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const receivedInvoiceListOptions = (filters: {
|
||||
month?: number;
|
||||
year?: number;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"invoices",
|
||||
"received",
|
||||
{
|
||||
month: filters.month,
|
||||
year: filters.year,
|
||||
search: filters.search,
|
||||
sort: filters.sort,
|
||||
order: filters.order,
|
||||
page: filters.page,
|
||||
perPage: filters.perPage,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.month) params.set("month", String(filters.month));
|
||||
if (filters.year) params.set("year", String(filters.year));
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.sort) params.set("sort", filters.sort);
|
||||
if (filters.order) params.set("order", filters.order);
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery<ReceivedInvoice>(
|
||||
`/api/admin/received-invoices${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const invoiceStatsOptions = (month: number, year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", "stats", month, year],
|
||||
queryFn: () =>
|
||||
jsonQuery<InvoiceStats>(
|
||||
`/api/admin/invoices/stats?month=${month}&year=${year}`,
|
||||
),
|
||||
});
|
||||
|
||||
export const receivedInvoiceStatsOptions = (month: number, year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", "received", "stats", month, year],
|
||||
queryFn: () =>
|
||||
jsonQuery<ReceivedStats>(
|
||||
`/api/admin/received-invoices/stats?month=${month}&year=${year}`,
|
||||
),
|
||||
});
|
||||
|
||||
export const invoiceDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", id],
|
||||
queryFn: () => jsonQuery<InvoiceDetail>(`/api/admin/invoices/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
42
src/admin/lib/queries/leave.ts
Normal file
42
src/admin/lib/queries/leave.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface LeaveRequest {
|
||||
id: number;
|
||||
leave_type: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
total_days: number;
|
||||
total_hours: number;
|
||||
status: string;
|
||||
notes?: string;
|
||||
reviewer_note?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const leaveRequestsOptions = (mine = true) =>
|
||||
queryOptions({
|
||||
queryKey: ["leave-requests", mine ? "mine" : "all"],
|
||||
queryFn: () =>
|
||||
jsonQuery<LeaveRequest[]>(
|
||||
`/api/admin/leave-requests${mine ? "?mine=1" : ""}`,
|
||||
),
|
||||
});
|
||||
|
||||
export const leavePendingOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["leave", "pending"],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>[]>(
|
||||
"/api/admin/leave-requests?status=pending",
|
||||
),
|
||||
});
|
||||
|
||||
export const leaveProcessedOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["leave", "processed"],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>[]>(
|
||||
"/api/admin/leave-requests?status=approved,rejected",
|
||||
),
|
||||
});
|
||||
113
src/admin/lib/queries/mutations.ts
Normal file
113
src/admin/lib/queries/mutations.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type UseMutationOptions,
|
||||
} from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface ApiResponseBody<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function performMutation<TIn, TOut>(opts: {
|
||||
url: string | ((input: TIn) => string);
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
body?: TIn;
|
||||
}): Promise<TOut> {
|
||||
const url =
|
||||
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
|
||||
const method =
|
||||
typeof opts.method === "function"
|
||||
? opts.method(opts.body as TIn)
|
||||
: opts.method;
|
||||
const response = await apiFetch(url, {
|
||||
method,
|
||||
headers:
|
||||
opts.body !== undefined
|
||||
? { "Content-Type": "application/json" }
|
||||
: undefined,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
const err: ApiError = new Error("Unauthorized");
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let result: ApiResponseBody<TOut>;
|
||||
try {
|
||||
result = (await response.json()) as ApiResponseBody<TOut>;
|
||||
} catch {
|
||||
throw new Error("Invalid JSON response");
|
||||
}
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
const err: ApiError = new Error(
|
||||
result.error || `Request failed (${response.status})`,
|
||||
);
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result.data as TOut;
|
||||
}
|
||||
|
||||
export interface ApiMutationOptions<TIn, TOut> {
|
||||
url: string | ((input: TIn) => string);
|
||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
||||
invalidate?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that wraps `useMutation` with `apiFetch` and broad-invalidation.
|
||||
*
|
||||
* Usage:
|
||||
* const createCategory = useApiMutation<CategoryForm, WarehouseCategory>({
|
||||
* url: "/api/admin/warehouse/categories",
|
||||
* method: "POST",
|
||||
* invalidate: ["warehouse"],
|
||||
* });
|
||||
* createCategory.mutate(form, {
|
||||
* onSuccess: () => { ... },
|
||||
* });
|
||||
*
|
||||
* The returned `data` from a successful mutation is `TOut`. Errors are thrown
|
||||
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
|
||||
*/
|
||||
export function useApiMutation<TIn, TOut>(
|
||||
opts: ApiMutationOptions<TIn, TOut> &
|
||||
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
||||
) {
|
||||
const { url, method, invalidate, onSuccess, ...rest } = opts;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TOut, ApiError, TIn, unknown>({
|
||||
mutationFn: (input: TIn) =>
|
||||
performMutation<TIn, TOut>({ url, method, body: input }),
|
||||
...rest,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
if (invalidate) {
|
||||
for (const key of invalidate) {
|
||||
queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
}
|
||||
// Forward to user-provided onSuccess with the 4-arg signature expected by TanStack.
|
||||
(
|
||||
onSuccess as
|
||||
| ((d: TOut, v: TIn, r: unknown, c: unknown) => void)
|
||||
| undefined
|
||||
)?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
160
src/admin/lib/queries/offers.ts
Normal file
160
src/admin/lib/queries/offers.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface ItemTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
default_price: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface ScopeSection {
|
||||
_key: string;
|
||||
title: string;
|
||||
title_cz: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ScopeTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
scope_template_sections?: ScopeSection[];
|
||||
}
|
||||
|
||||
export interface CustomField {
|
||||
name: string;
|
||||
value: string;
|
||||
showLabel: boolean;
|
||||
_key?: string;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
company_id?: string;
|
||||
vat_id?: string;
|
||||
quotation_count: number;
|
||||
custom_fields?: CustomField[];
|
||||
customer_field_order?: string[];
|
||||
}
|
||||
|
||||
export const offerCustomersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-customers"],
|
||||
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
export const itemTemplatesOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-templates", "items"],
|
||||
queryFn: () =>
|
||||
jsonQuery<ItemTemplate[]>("/api/admin/offers-templates?action=items"),
|
||||
});
|
||||
|
||||
export const scopeTemplatesOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-templates", "scopes"],
|
||||
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
|
||||
});
|
||||
|
||||
export const offerListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["offers", "list", filters],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.sort) params.set("sort", filters.sort);
|
||||
if (filters.order) params.set("order", filters.order);
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.customer_id)
|
||||
params.set("customer_id", String(filters.customer_id));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
});
|
||||
|
||||
export interface OfferItemData {
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface OfferSectionData {
|
||||
title: string;
|
||||
title_cz: string;
|
||||
content: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface OfferLockInfo {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
export interface OfferOrderInfo {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface OfferDetailData {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_id: number | null;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
items?: OfferItemData[];
|
||||
sections?: OfferSectionData[];
|
||||
status: string;
|
||||
order: OfferOrderInfo | null;
|
||||
locked_by: OfferLockInfo | null;
|
||||
}
|
||||
|
||||
export const offerDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["offers", id],
|
||||
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
|
||||
enabled: !!id,
|
||||
// 404s (deleted/missing offers) are not transient. Retrying just spams
|
||||
// GETs and fires the detail-page redirect toast repeatedly.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
export const offerNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offers", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/offers/next-number",
|
||||
),
|
||||
});
|
||||
84
src/admin/lib/queries/orders.ts
Normal file
84
src/admin/lib/queries/orders.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface OrderItem {
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: number | boolean;
|
||||
}
|
||||
|
||||
export interface OrderSection {
|
||||
id?: number;
|
||||
title: string;
|
||||
title_cz?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface OrderInvoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
}
|
||||
|
||||
export interface OrderProject {
|
||||
id: number;
|
||||
project_number: string;
|
||||
name: string;
|
||||
has_nas_folder?: boolean;
|
||||
}
|
||||
|
||||
export interface OrderData {
|
||||
id: number;
|
||||
order_number: string;
|
||||
quotation_id: number;
|
||||
quotation_number: string;
|
||||
project_code?: string;
|
||||
customer_name: string;
|
||||
customer_order_number: string;
|
||||
currency: string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
attachment_name?: string;
|
||||
apply_vat: number | boolean;
|
||||
vat_rate: number;
|
||||
language?: string;
|
||||
items: OrderItem[];
|
||||
sections: OrderSection[];
|
||||
scope_title?: string;
|
||||
scope_description?: string;
|
||||
valid_transitions?: string[];
|
||||
invoice?: OrderInvoice;
|
||||
project?: OrderProject;
|
||||
}
|
||||
|
||||
export const orderListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", "list", filters],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.sort) params.set("sort", filters.sort);
|
||||
if (filters.order) params.set("order", filters.order);
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const orderDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", id],
|
||||
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user