Compare commits

..

1 Commits

Author SHA1 Message Date
BOHA
dc9720a67a 1.3.0 2026-03-27 10:17:05 +01:00
216 changed files with 18517 additions and 36452 deletions

View File

@@ -0,0 +1,43 @@
---
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\`

View File

@@ -7,13 +7,13 @@ HOST=127.0.0.1
APP_ENV=local
# Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
JWT_SECRET=generate-with-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=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
# File storage
NAS_PATH=Z:/02_PROJEKTY

390
CLAUDE.md
View File

@@ -1,390 +0,0 @@
# 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.
### 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.
---
## 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`
Do not push directly to production or restart services without confirmation.

View File

@@ -1,37 +0,0 @@
{
"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"
]
}

953
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.8.0",
"version": "1.3.0",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -17,11 +17,7 @@
"db:push": "prisma db push",
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
"test:watch": "vitest"
},
"keywords": [],
"author": "",
@@ -38,8 +34,6 @@
"@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",
@@ -48,7 +42,6 @@
"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",

View File

@@ -98,7 +98,6 @@ 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,
@@ -109,24 +108,7 @@ 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;
@@ -185,18 +167,14 @@ 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;
@@ -261,7 +239,6 @@ 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;
@@ -317,7 +294,6 @@ 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`)
@@ -365,7 +341,6 @@ 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`),
@@ -411,13 +386,10 @@ 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`)
@@ -439,6 +411,7 @@ 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,
@@ -599,7 +572,6 @@ 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`),
@@ -625,28 +597,12 @@ 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;
@@ -718,3 +674,4 @@ 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;

View File

@@ -0,0 +1,2 @@
-- Add billing_text column to invoices table
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;

View File

@@ -0,0 +1,3 @@
-- 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`;

View File

@@ -0,0 +1,9 @@
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;

View File

@@ -0,0 +1 @@
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;

View File

@@ -0,0 +1,2 @@
-- 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`;

View File

@@ -0,0 +1,4 @@
-- 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`;

View File

@@ -0,0 +1 @@
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;

View File

@@ -0,0 +1,4 @@
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;

View File

@@ -0,0 +1,3 @@
ALTER TABLE `company_settings`
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;

View File

@@ -0,0 +1,13 @@
-- 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;

View File

@@ -0,0 +1,18 @@
-- 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');

View File

@@ -1,4 +0,0 @@
-- 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`;

View File

@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;

View File

@@ -1,14 +0,0 @@
-- 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`;

View File

@@ -1,277 +0,0 @@
-- 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;

View File

@@ -1,17 +0,0 @@
-- 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}';

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

View File

@@ -46,7 +46,6 @@ 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")
@@ -101,18 +100,18 @@ model company_settings {
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes? @db.MediumBlob
logo_data_dark Bytes?
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? @unique @db.VarChar(36)
uuid String? @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.00) @db.Decimal(4, 2)
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
@@ -128,12 +127,6 @@ 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 {
@@ -159,9 +152,8 @@ model customers {
model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
description String? @db.VarChar(500)
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)
@@ -173,7 +165,7 @@ model invoice_items {
model invoices {
id Int @id @default(autoincrement())
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
invoice_number String? @db.VarChar(50)
order_id Int?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
@@ -204,7 +196,6 @@ 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")
}
@@ -262,8 +253,6 @@ 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 {
@@ -297,7 +286,7 @@ model order_sections {
model orders {
id Int @id @default(autoincrement())
order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
order_number String? @db.VarChar(50)
customer_order_number String? @db.VarChar(100)
attachment_data Bytes?
attachment_name String? @db.VarChar(255)
@@ -349,7 +338,7 @@ model project_notes {
model projects {
id Int @id @default(autoincrement())
project_number String? @unique @db.VarChar(50)
project_number String? @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
@@ -361,10 +350,7 @@ 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[]
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")
@@ -386,7 +372,10 @@ 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")
@@ -394,8 +383,8 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100)
quotation_number String? @db.VarChar(50)
project_code String? @db.VarChar(50)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date
@@ -403,13 +392,17 @@ 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[]
@@ -494,7 +487,11 @@ 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")
@@ -581,16 +578,12 @@ 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")
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active")
@@ -631,9 +624,8 @@ 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], map: "invoice_type_invoice_id_alert_type")
@@unique([invoice_type, invoice_id, alert_type])
}
enum received_invoices_status {
@@ -648,272 +640,3 @@ 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")
}

View File

@@ -1,469 +0,0 @@
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());

View File

@@ -1,49 +0,0 @@
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);
});
});
});

View File

@@ -1,19 +0,0 @@
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);
});
});

View File

@@ -1,34 +0,0 @@
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();
});
});

View File

@@ -1,64 +0,0 @@
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/);
});
});
});

View File

@@ -1,55 +0,0 @@
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);
});
});

View File

@@ -1,55 +0,0 @@
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");
});
});
});

View File

@@ -1,50 +1,21 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect } from "vitest";
import {
generateSharedNumber,
generateOfferNumber,
} from "../services/numbering.service";
import prisma from "../config/database";
describe("generateSharedNumber", () => {
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 () => {
it("returns correct format (YYtypeCode + 4 digits)", async () => {
const num = await generateSharedNumber();
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);
const yy = String(new Date().getFullYear()).slice(-2);
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
});
});
describe("generateOfferNumber", () => {
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 () => {
it("returns correct format (YEAR/PREFIX/NNN)", async () => {
const num = await generateOfferNumber();
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);
const year = new Date().getFullYear();
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
});
});

View File

@@ -1,57 +0,0 @@
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);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,19 @@
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 "./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 "./admin.css";
import "./login.css";
import "./dashboard.css";
import "./attendance.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"));
@@ -48,186 +35,76 @@ 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>
<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="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>
<AlertContainer />
<ErrorBoundary>
<Suspense
fallback={
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<Routes>
<Route path="login" element={<Login />} />
<Route element={<AdminLayout />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
<Route
path="attendance/history"
element={<AttendanceHistory />}
/>
<Route path="attendance/admin" element={<AttendanceAdmin />} />
<Route
path="attendance/balances"
element={<AttendanceBalances />}
/>
<Route path="attendance/requests" element={<LeaveRequests />} />
<Route path="attendance/approval" element={<LeaveApproval />} />
<Route
path="attendance/create"
element={<AttendanceCreate />}
/>
<Route
path="attendance/location/:id"
element={<AttendanceLocation />}
/>
<Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} />
<Route path="vehicles" element={<Vehicles />} />
<Route path="offers" element={<Offers />} />
<Route path="offers/new" element={<OfferDetail />} />
<Route path="offers/:id" element={<OfferDetail />} />
<Route path="offers/customers" element={<OffersCustomers />} />
<Route path="offers/templates" element={<OffersTemplates />} />
<Route path="orders" element={<Orders />} />
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/new" element={<ProjectCreate />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="invoices" element={<Invoices />} />
<Route path="invoices/new" element={<InvoiceDetail />} />
<Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} />
<Route path="audit-log" element={<AuditLog />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AlertProvider>
</AuthProvider>
);

3228
src/admin/admin.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -318,14 +318,12 @@
/* Leave Type Badges */
.attendance-leave-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.55rem;
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--border-radius-sm);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.2;
margin-right: 0.25rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
}

View File

@@ -1,411 +0,0 @@
/* ============================================================================
Reset & Base
============================================================================ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
html,
body,
#root {
min-height: 100%;
min-height: 100dvh;
max-width: 100vw;
}
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-primary);
overflow-x: hidden;
overscroll-behavior-x: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
.admin-sidebar,
.admin-header,
.admin-card,
.admin-modal {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
#root {
overflow-x: hidden;
touch-action: pan-y pinch-zoom;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
}
h1 {
font-size: clamp(2.5rem, 5vw, 4rem);
}
h2 {
font-size: clamp(2rem, 4vw, 3rem);
}
h3 {
font-size: clamp(1.25rem, 2vw, 1.5rem);
}
p {
color: var(--text-secondary);
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
transition: var(--transition);
}
img {
max-width: 100%;
height: auto;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
::selection {
background: var(--accent-color);
color: #fff;
}
/* ============================================================================
Base / Utilities
============================================================================ */
.text-warning {
color: var(--warning) !important;
}
.text-danger {
color: var(--danger) !important;
}
.text-success {
color: var(--success) !important;
}
.text-muted {
color: var(--text-muted) !important;
}
.text-secondary {
color: var(--text-secondary) !important;
}
.text-tertiary {
color: var(--text-tertiary) !important;
}
.text-accent {
color: var(--accent-color) !important;
}
.fw-600 {
font-weight: 600 !important;
}
.link-accent {
color: var(--accent-color);
font-weight: 500;
text-decoration: none;
}
.link-accent:hover {
text-decoration: underline;
}
/* Layout utilities */
.flex-1 {
flex: 1;
}
.flex-row {
display: flex;
align-items: center;
}
.flex-row-gap {
display: flex;
align-items: center;
gap: var(--space-3);
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Spacing utilities */
.mb-2 {
margin-bottom: var(--space-2);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.mt-2 {
margin-top: var(--space-2);
}
.mt-6 {
margin-top: var(--space-6);
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: var(--space-4);
}
.gap-5 {
gap: var(--space-5);
}
/* Typography utilities */
.fw-500 {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
/* Spinner variant */
.admin-spinner-sm {
width: 16px;
height: 16px;
border-width: 2px;
}
/* Monospace for data values (times, dates, numbers, IDs) */
.admin-mono {
font-family: var(--font-mono);
font-size: 0.875em;
letter-spacing: -0.01em;
}
/* Drag handle */
.admin-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-muted);
cursor: grab;
border-radius: 4px;
padding: 0;
transition:
color 0.15s,
background 0.15s;
touch-action: none;
}
.admin-drag-handle:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.admin-drag-handle:active {
cursor: grabbing;
}
/* Error stack (DEV only) */
.admin-error-stack {
max-width: 600px;
max-height: 200px;
overflow: auto;
padding: 0.75rem 1rem;
margin: 0;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--danger-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;
}
}

View File

@@ -1,130 +0,0 @@
/* ============================================================================
Buttons
============================================================================ */
.admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 8px 14px;
border: none;
border-radius: var(--border-radius-sm);
font-size: 13px;
font-weight: 550;
font-family: inherit;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.admin-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* Prevent buttons from growing when text changes to spinner + loading text */
.admin-modal-footer .admin-btn {
min-width: 100px;
}
.admin-modal-footer .admin-btn .admin-spinner {
margin: -2px 0;
}
.admin-btn-sm {
padding: 6px 11px;
font-size: 12px;
}
.admin-btn-primary {
background: var(--accent-color);
color: #fff;
}
.admin-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
}
.admin-btn .admin-spinner {
width: 16px;
height: 16px;
border-width: 2px;
}
.admin-btn-primary .admin-spinner {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: #fff;
}
.admin-btn-secondary .admin-spinner {
border-color: rgba(var(--text-secondary-rgb, 107, 114, 128), 0.3);
border-top-color: var(--text-secondary);
}
.admin-btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.admin-btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--border-color-hover);
color: var(--text-primary);
}
.admin-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: var(--transition);
}
.admin-btn-icon:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.admin-btn-icon.accent {
color: var(--info);
}
.admin-btn-icon.accent:hover {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-btn-icon.danger {
color: var(--danger);
}
.admin-btn-icon.danger:hover {
background: var(--danger-light);
}
/* Touch targets - min 44px on mobile */
@media (max-width: 768px) {
.admin-btn {
min-height: 44px;
padding: 10px 16px;
}
.admin-btn-sm {
min-height: 36px;
}
.admin-btn-icon {
min-width: 44px;
min-height: 44px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { forwardRef } from "react";
import { forwardRef, useMemo } from "react";
import DatePicker, { registerLocale } from "react-datepicker";
import { cs } from "date-fns/locale";
import { parse, format } from "date-fns";
@@ -75,19 +75,6 @@ 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}
@@ -97,14 +84,14 @@ function NativeInput({
className="admin-form-input"
required={required}
disabled={disabled}
min={minProp}
max={maxProp}
min={minDate || undefined}
max={maxDate || undefined}
/>
);
}
interface AdminDatePickerProps {
mode?: "date" | "month" | "time";
mode?: "date" | "month" | "datetime" | "time";
value: string;
onChange: (value: string) => void;
minDate?: string;
@@ -124,7 +111,7 @@ export default function AdminDatePicker({
disabled,
placeholder,
}: AdminDatePickerProps) {
const useNative = isTouchDevice();
const useNative = useMemo(() => isTouchDevice(), []);
if (useNative) {
return (
@@ -178,20 +165,17 @@ 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,
placeholderText: placeholder,
customInput: (
<CustomInput
required={required}
placeholder={placeholder}
disabled={disabled}
/>
),
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const,

View File

@@ -8,13 +8,34 @@ import {
getLeaveTypeName,
getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface AttendanceRecord extends HookAttendanceRecord {
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;
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 {
@@ -30,7 +51,7 @@ function formatBreak(record: AttendanceRecord): string {
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`;
}
return "";
return "\u2014";
}
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
@@ -43,30 +64,21 @@ 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 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;
}
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
);
h = Math.floor(mins / 60);
m = mins % 60;
}
return (
<span
key={log.id ?? i}
key={log.id || i}
className="admin-badge"
style={{
fontSize: "0.7rem",
@@ -74,10 +86,8 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`}{" "}
{durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
: "—"}
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
</span>
);
})}
@@ -94,7 +104,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
</span>
);
}
return "";
return "\u2014";
}
export default function AttendanceShiftTable({
@@ -133,12 +143,8 @@ export default function AttendanceShiftTable({
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
: calculateWorkMinutes({
...record,
notes: record.notes ?? undefined,
});
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng);
@@ -155,16 +161,18 @@ export default function AttendanceShiftTable({
</span>
</td>
<td className="admin-mono">
{isLeave ? "" : formatDatetime(record.arrival_time)}
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "" : formatBreak(record)}
{isLeave ? "\u2014" : formatBreak(record)}
</td>
<td className="admin-mono">
{isLeave ? "" : formatDatetime(record.departure_time)}
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
{workMinutes > 0
? `${formatMinutes(workMinutes)} h`
: "\u2014"}
</td>
<td>{renderProjectCell(record)}</td>
<td>
@@ -175,10 +183,10 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
>
<span aria-hidden="true">{"📍"}</span>
{"\uD83D\uDCCD"}
</Link>
) : (
""
"\u2014"
)}
</td>
<td

View File

@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
}
interface BulkAttendanceModalProps {
isOpen: boolean;
show: boolean;
onClose: () => void;
form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void;
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
}
export default function BulkAttendanceModal({
isOpen,
show,
onClose,
form,
setForm,
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(isOpen);
useModalLock(show);
return (
<AnimatePresence>
{isOpen && (
{show && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -57,21 +57,13 @@ 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
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
<p
style={{
color: "var(--text-secondary)",

View File

@@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from "react";
import type { ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface ConfirmModalProps {
@@ -14,71 +14,6 @@ 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,
@@ -91,19 +26,6 @@ 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 && (
@@ -117,9 +39,6 @@ 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 }}
@@ -127,11 +46,20 @@ export default function ConfirmModal({
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<ConfirmIcon type={type} />
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<h2 id="confirm-modal-title" className="admin-confirm-title">
{title}
</h2>
<h2 className="admin-confirm-title">{title}</h2>
<p className="admin-confirm-message">{message}</p>
</div>
<div className="admin-modal-footer">

View File

@@ -1,10 +1,4 @@
import {
type CSSProperties,
type ReactNode,
isValidElement,
cloneElement,
useId,
} from "react";
import type { CSSProperties, ReactNode } from "react";
interface FormFieldProps {
label: ReactNode;
@@ -21,22 +15,13 @@ 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" htmlFor={childId}>
<label className="admin-form-label">
{label}
{required && <span className="admin-form-required"> *</span>}
</label>
{childWithId}
{children}
{error && <span className="admin-form-error">{error}</span>}
</div>
);

View File

@@ -1,118 +0,0 @@
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>
);
}

View File

@@ -1,396 +0,0 @@
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>
);
}

View File

@@ -36,18 +36,13 @@ export default function Pagination({
};
return (
<div
className="admin-pagination"
role="navigation"
aria-label="Stránkování"
>
<div className="admin-pagination">
<div className="admin-pagination-info">{total} záznamů</div>
<div className="admin-pagination-controls">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="admin-pagination-page"
aria-label="Předchozí stránka"
>
<svg
width="16"
@@ -70,8 +65,6 @@ 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>
@@ -81,7 +74,6 @@ export default function Pagination({
disabled={page >= total_pages}
onClick={() => onPageChange(page + 1)}
className="admin-pagination-page"
aria-label="Další stránka"
>
<svg
width="16"

View File

@@ -1,6 +1,4 @@
import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { projectFilesOptions } from "../lib/queries/projects";
import { useState, useEffect, useRef, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import ConfirmModal from "./ConfirmModal";
import apiFetch from "../utils/api";
@@ -198,11 +196,13 @@ 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,25 +216,59 @@ 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 {
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 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 navigateTo = (path: string) => {
setNewFolderMode(false);
setRenamingItem(null);
setCurrentPath(path);
fetchFiles(path);
};
const handleBreadcrumbClick = (index: number) => {
@@ -297,9 +331,7 @@ export default function ProjectFileManager({
? "Soubor byl nahrán"
: `Nahráno ${successCount} souborů`;
alert.success(msg);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
fetchFiles(currentPath);
}
if (errorMsg) {
alert.error(errorMsg);
@@ -350,9 +382,7 @@ export default function ProjectFileManager({
alert.success("Složka byla vytvořena");
setNewFolderMode(false);
setNewFolderName("");
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
fetchFiles(currentPath);
} else {
alert.error(data.error || "Nepodařilo se vytvořit složku");
}
@@ -413,9 +443,7 @@ export default function ProjectFileManager({
? "Složka byla smazána"
: "Soubor byl smazán",
);
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
fetchFiles(currentPath);
} else {
alert.error(data.error || "Nepodařilo se smazat");
}
@@ -450,9 +478,7 @@ export default function ProjectFileManager({
const data = await res.json();
if (data.success) {
alert.success("Přejmenováno");
queryClient.invalidateQueries({
queryKey: ["projects", String(projectId), "files"],
});
fetchFiles(currentPath);
} else {
alert.error(data.error || "Nepodařilo se přejmenovat");
}
@@ -468,10 +494,31 @@ export default function ProjectFileManager({
setRenameValue(item.name);
};
if (filesLoading && items.length === 0 && !errorMessage) {
if (loading && items.length === 0 && !errorMessage) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<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>
);
}
@@ -662,7 +709,7 @@ export default function ProjectFileManager({
</div>
)}
{items.length === 0 && !filesLoading ? (
{items.length === 0 && !loading ? (
<div className="fm-empty">
<svg
width="32"
@@ -721,26 +768,10 @@ export default function ProjectFileManager({
}}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleRename(item);
}
if (e.key === "Escape") {
e.preventDefault();
isCancelling.current = true;
setRenamingItem(null);
setRenameValue(item.name);
setTimeout(() => {
isCancelling.current = false;
}, 0);
}
}}
onBlur={() => {
if (isCancelling.current) {
return;
}
handleRename(item);
if (e.key === "Enter") handleRename(item);
if (e.key === "Escape") setRenamingItem(null);
}}
onBlur={() => handleRename(item)}
/>
) : (
<FileNameCell

View File

@@ -1,7 +1,66 @@
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import { useMemo, useRef, useCallback } 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",
@@ -36,6 +95,8 @@ const COLORS = [
];
const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
["bold", "italic", "underline", "strike"],
[{ color: COLORS }, { background: COLORS }],
[{ list: "ordered" }, { list: "bullet" }],
@@ -46,6 +107,8 @@ const TOOLBAR = [
];
const FORMATS = [
"font",
"size",
"bold",
"italic",
"underline",
@@ -96,16 +159,9 @@ export default function RichEditor({
[onChange],
);
useLayoutEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.root.style.fontSize = "14px";
}, []);
return (
<div
className="admin-rich-editor"
className="rich-editor"
style={{ "--re-min-height": minHeight } as React.CSSProperties}
>
<ReactQuill

View File

@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
export interface ShiftFormModalProps {
mode: "create" | "edit";
isOpen: boolean;
show: boolean;
onClose: () => void;
onSubmit: () => void;
form: ShiftFormData;
@@ -85,15 +85,7 @@ export interface ShiftFormModalProps {
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form);
const totalProject = calcProjectMinutesTotal(
projectLogs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
const totalProject = calcProjectMinutesTotal(projectLogs);
const remaining = totalWork - totalProject;
const hasLogs = projectLogs.some((l) => l.project_id);
@@ -204,7 +196,7 @@ function ProjectLogRow({
export default function ShiftFormModal({
mode,
isOpen,
show,
onClose,
onSubmit,
form,
@@ -216,7 +208,7 @@ export default function ShiftFormModal({
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(isOpen);
useModalLock(show);
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
@@ -248,7 +240,7 @@ export default function ShiftFormModal({
return (
<AnimatePresence>
{isOpen && (
{show && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -259,16 +251,13 @@ 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 id="shift-form-modal-title" className="admin-modal-title">
<h2 className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
@@ -337,6 +326,7 @@ 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>

View File

@@ -116,7 +116,7 @@ const menuSections: MenuSection[] = [
{
path: "/attendance/admin",
label: "Správa",
permission: "attendance.manage",
permission: "attendance.admin",
matchPrefix: "/attendance/admin",
matchAlso: ["/attendance/create", "/attendance/location"],
icon: (
@@ -198,7 +198,7 @@ const menuSections: MenuSection[] = [
{
path: "/trips/admin",
label: "Správa",
permission: "trips.manage",
permission: "trips.admin",
icon: (
<svg
viewBox="0 0 24 24"
@@ -221,7 +221,7 @@ const menuSections: MenuSection[] = [
{
path: "/vehicles",
label: "Vozidla",
permission: "vehicles.manage",
permission: "trips.vehicles",
icon: (
<svg
viewBox="0 0 24 24"
@@ -315,202 +315,7 @@ const menuSections: MenuSection[] = [
{
path: "/offers/customers",
label: "Zákazníci",
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",
permission: "offers.view",
icon: (
<svg
viewBox="0 0 24 24"
@@ -551,13 +356,7 @@ const menuSections: MenuSection[] = [
{
path: "/settings",
label: "Nastavení",
permission: [
"settings.company",
"settings.banking",
"settings.roles",
"settings.system",
"settings.templates",
],
permission: "settings.manage",
icon: (
<svg
viewBox="0 0 24 24"

View File

@@ -109,10 +109,10 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
}
const KPI_CLASS_MAP: Record<number, string> = {
4: "admin-kpi-4",
3: "admin-kpi-3",
2: "admin-kpi-2",
1: "admin-kpi-1",
4: "dash-kpi-4",
3: "dash-kpi-3",
2: "dash-kpi-2",
1: "dash-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)] || "admin-kpi-4";
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "dash-kpi-4";
return (
<motion.div
className={`admin-kpi-grid ${kpiClass}`}
className={`dash-kpi-grid ${kpiClass}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}

View File

@@ -89,21 +89,6 @@ 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",
@@ -344,24 +329,9 @@ 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
Nové heslo (ponechte prázdné pro zachování stávajícího)
</label>
<input
type="password"
@@ -373,9 +343,27 @@ 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">

View File

@@ -1,15 +1,27 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect, useCallback } from "react";
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;
@@ -65,10 +77,9 @@ function getDeviceIcon(iconType?: string) {
export default function DashSessions() {
const alert = useAlert();
const queryClient = useQueryClient();
const { data: sessions = [], isPending: sessionsLoading } =
useQuery(sessionsOptions());
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false,
session: null,
@@ -78,6 +89,26 @@ 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;
@@ -91,7 +122,7 @@ export default function DashSessions() {
const data = await response.json();
if (data.success) {
setDeleteModal({ isOpen: false, session: null });
queryClient.invalidateQueries({ queryKey: ["sessions"] });
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
alert.success("Relace byla ukončena");
} else {
alert.error(data.error || "Nepodařilo se ukončit relaci");
@@ -112,7 +143,7 @@ export default function DashSessions() {
const data = await response.json();
if (data.success) {
setDeleteAllModal(false);
queryClient.invalidateQueries({ queryKey: ["sessions"] });
setSessions((prev) => prev.filter((s) => s.is_current));
alert.success(data.message || "Ostatní relace byly ukončeny");
} else {
alert.error(data.error || "Nepodařilo se ukončit relace");
@@ -152,83 +183,97 @@ export default function DashSessions() {
)}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{sessions.length === 0 && (
<div
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
>
Žádné aktivní relace
</div>
)}
{sessions.length > 0 && (
<div className="dash-sessions-list">
{sessions.map((session) => (
{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
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>
))}
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
</div>
</div>
)}
</>
))}
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<div
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
>
Žádné aktivní relace
</div>
)}
{!sessionsLoading && sessions.length > 0 && (
<div className="sessions-list">
{sessions.map((session) => (
<div
key={session.id}
className={`session-item ${session.is_current ? "session-item-current" : ""}`}
>
<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>
</div>
))}
</div>
)}
</div>
</motion.div>

View File

@@ -1,74 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { jsonQuery } from "../../lib/apiAdapter";
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: number;
}
interface BatchPickerProps {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}
export default function BatchPicker({
itemId,
value,
onChange,
}: BatchPickerProps) {
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<div>
{response && response.reserved_quantity > 0 && (
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</div>
)}
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const batchId = Number(e.target.value);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
>
<option value="">-- auto FIFO --</option>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
ks | {Number(b.unit_price).toFixed(2)}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,228 +0,0 @@
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>
);
}

View File

@@ -1,31 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../../lib/queries/warehouse";
interface LocationSelectProps {
value: number | null;
onChange: (locationId: number | null) => void;
}
export default function LocationSelect({
value,
onChange,
}: LocationSelectProps) {
const { data: locations } = useQuery(warehouseLocationListOptions());
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez lokace --</option>
{locations?.map((loc: WarehouseLocation) => (
<option key={loc.id} value={loc.id}>
{loc.code} - {loc.name}
</option>
))}
</select>
);
}

View File

@@ -1,54 +0,0 @@
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>
);
}

View File

@@ -1,33 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../../lib/queries/warehouse";
interface SupplierSelectProps {
value: number | null;
onChange: (supplierId: number | null) => void;
}
export default function SupplierSelect({
value,
onChange,
}: SupplierSelectProps) {
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
const suppliers = data?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez dodavatele --</option>
{suppliers.map((s: WarehouseSupplier) => (
<option key={s.id} value={s.id}>
{s.name}
{s.ico ? ` (${s.ico})` : ""}
</option>
))}
</select>
);
}

View File

@@ -1,249 +0,0 @@
import { ReactNode, useId, useRef } from "react";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";
export interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
export type MovementRowRenderer<T> = (
item: T,
index: number,
updateItem: (field: string, value: unknown) => void,
removeItem: () => void,
) => ReactNode;
interface WarehouseMovementTableProps<T extends { key: string }> {
items: T[];
onChange: (items: T[]) => void;
mode: "receipt" | "issue" | "inventory";
defaultItem?: () => Omit<T, "key">;
renderRow?: MovementRowRenderer<T>;
renderHeader?: () => ReactNode;
}
function parseDecimal(raw: string): number {
// Strip leading minus if you want negative support; here we want only positive
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
});
export default function WarehouseMovementTable<T extends { key: string }>({
items,
onChange,
mode,
defaultItem,
renderRow,
renderHeader,
}: WarehouseMovementTableProps<T>) {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const addItem = () => {
const factory =
defaultItem ??
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
};
const removeItem = (index: number) => {
onChange(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: string, value: unknown) => {
const updated = [...items];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
// If a custom row renderer is supplied, the caller is fully in charge of
// the row layout (and typically the column headers too — see inventory mode).
if (renderRow) {
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
{renderHeader && (
<thead>
<tr>{renderHeader()}</tr>
</thead>
)}
<tbody>
{items.map((item, index) => (
<tr key={item.key}>
{renderRow(
item,
index,
(field, value) => updateItem(index, field, value),
() => removeItem(index),
)}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
const movementItems = items as unknown as MovementItem[];
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
<thead>
<tr>
<th className="admin-warehouse-col-item">Položka</th>
{mode === "issue" && (
<th className="admin-warehouse-col-batch">Šarže</th>
)}
<th className="admin-warehouse-col-qty">Množství</th>
<th className="admin-warehouse-col-price">Cena/ks</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-notes">Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{movementItems.map((item, index) => (
<tr key={item.key}>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
</td>
{mode === "issue" && (
<td className="admin-warehouse-col-batch">
<BatchPicker
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
</td>
)}
<td className="admin-warehouse-col-qty">
<input
type="number"
className="admin-form-input"
value={item.quantity || ""}
onChange={(e) =>
updateItem(
index,
"quantity",
parseDecimal(e.target.value),
)
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Množství, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-price">
<input
type="number"
className="admin-form-input"
value={item.unit_price || ""}
onChange={(e) =>
updateItem(
index,
"unit_price",
parseDecimal(e.target.value),
)
}
min="0"
step="0.01"
disabled={mode === "issue"}
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Cena za kus, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(id) => updateItem(index, "location_id", id)}
/>
</td>
<td className="admin-warehouse-col-notes">
<input
type="text"
className="admin-form-input"
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import {
useCallback,
useMemo,
useRef,
useEffect,
type ReactNode,
} from "react";
@@ -40,15 +39,6 @@ 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++}`;
@@ -57,11 +47,7 @@ export function AlertProvider({ children }: { children: ReactNode }) {
{ id, message, type: type as Alert["type"] },
]);
if (duration > 0) {
const timeoutId = setTimeout(() => {
timeoutsRef.current.delete(timeoutId);
removeAlert(id);
}, duration);
timeoutsRef.current.add(timeoutId);
setTimeout(() => removeAlert(id), duration);
}
return id;
},

View File

@@ -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 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 [user, setUser] = useState<User | null>(cachedUser);
const [loading, setLoading] = useState(!sessionFetched);
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 (
!tokenExpiresAtRef.current ||
Date.now() > tokenExpiresAtRef.current - 30000
)
return null;
return accessTokenRef.current;
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null;
return accessToken;
}, []);
const setAccessTokenFn = useCallback(
(token: string | null, expiresIn?: number) => {
const ttl = expiresIn ?? 900; // default 15 min matching backend config
accessTokenRef.current = token;
tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null;
accessToken = token;
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null;
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
@@ -126,8 +126,7 @@ 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 (silentRefreshInFlightRef.current)
return silentRefreshInFlightRef.current;
if (silentRefreshInFlight) return silentRefreshInFlight;
const promise = (async (): Promise<boolean> => {
try {
@@ -139,24 +138,23 @@ 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;
}
accessTokenRef.current = null;
tokenExpiresAtRef.current = null;
accessToken = null;
tokenExpiresAt = null;
setUser(null);
cachedUserRef.current = null;
if (hadValidSessionRef.current) setSessionExpired();
cachedUser = null;
setSessionExpired();
return false;
} catch {
// Network error — don't kick the user out, just return false
return false;
} finally {
silentRefreshInFlightRef.current = null;
silentRefreshInFlight = null;
}
})();
silentRefreshInFlightRef.current = promise;
silentRefreshInFlight = promise;
return promise;
}, [setAccessTokenFn]);
@@ -174,13 +172,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
headers,
});
if (response.status === 429 || response.status >= 500)
return !!cachedUserRef.current;
return !!cachedUser;
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));
cachedUserRef.current = mapUser(data.data.user);
hadValidSessionRef.current = true;
cachedUser = mapUser(data.data.user);
return true;
}
}
@@ -188,15 +185,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const refreshed = await silentRefresh();
if (refreshed) return true;
setUser(null);
cachedUserRef.current = null;
accessTokenRef.current = null;
tokenExpiresAtRef.current = null;
cachedUser = null;
accessToken = null;
tokenExpiresAt = null;
return false;
} catch {
return !!cachedUserRef.current;
return !!cachedUser;
} finally {
setLoading(false);
sessionFetchedRef.current = true;
sessionFetched = true;
}
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
@@ -234,9 +231,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
cachedUser = mapUser(data.data.user);
sessionFetched = true;
return { success: true };
}
setError(data.error);
@@ -268,16 +264,14 @@ 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));
cachedUserRef.current = mapUser(data.data.user);
sessionFetchedRef.current = true;
hadValidSessionRef.current = true;
cachedUser = mapUser(data.data.user);
sessionFetched = true;
return { success: true };
}
setError(data.error);
@@ -302,12 +296,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} catch {
/* ignore */
} finally {
accessTokenRef.current = null;
tokenExpiresAtRef.current = null;
accessToken = null;
tokenExpiresAt = null;
setUser(null);
cachedUserRef.current = null;
sessionFetchedRef.current = false;
hadValidSessionRef.current = false;
cachedUser = null;
sessionFetched = false;
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;

View File

@@ -1,3 +1,103 @@
/* ============================================================================
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
============================================================================ */
@@ -13,6 +113,26 @@
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;
@@ -392,9 +512,16 @@
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);
}
@@ -416,6 +543,9 @@
.dash-quick-actions {
grid-template-columns: 1fr 1fr;
}
.dash-kpi-grid {
grid-template-columns: 1fr;
}
.dash-profile-grid {
grid-template-columns: 1fr;
}
@@ -425,12 +555,12 @@
Sessions / Devices
============================================================================ */
.dash-sessions-list {
.sessions-list {
display: flex;
flex-direction: column;
}
.dash-session-item {
.session-item {
display: flex;
align-items: center;
gap: 1rem;
@@ -439,23 +569,23 @@
transition: var(--transition);
}
.dash-session-item:last-child {
.session-item:last-child {
border-bottom: none;
}
.dash-session-item:hover {
.session-item:hover {
background: var(--bg-tertiary);
}
.dash-session-item-current {
.session-item-current {
background: var(--row-current);
}
.dash-session-item-current:hover {
.session-item-current:hover {
background: var(--row-current-hover);
}
.dash-session-icon {
.session-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
@@ -467,17 +597,17 @@
flex-shrink: 0;
}
.dash-session-item-current .dash-session-icon {
.session-item-current .session-icon {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.dash-session-info {
.session-info {
flex: 1;
min-width: 0;
}
.dash-session-device {
.session-device {
font-weight: 500;
color: var(--text-primary);
display: flex;
@@ -486,7 +616,7 @@
gap: 0.25rem;
}
.dash-session-meta {
.session-meta {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
@@ -496,30 +626,30 @@
gap: 0.5rem;
}
.dash-session-meta-separator {
.session-meta-separator {
color: var(--border-color);
}
.dash-session-actions {
.session-actions {
flex-shrink: 0;
}
@media (max-width: 640px) {
.dash-session-item {
.session-item {
padding: 1rem;
gap: 0.75rem;
}
.dash-session-icon {
.session-icon {
width: 36px;
height: 36px;
}
.dash-session-device {
.session-device {
font-size: 0.875rem;
}
.dash-session-meta {
.session-meta {
font-size: 0.75rem;
}
}

View File

@@ -1,199 +0,0 @@
/* ============================================================================
React DatePicker Overrides
============================================================================ */
.react-datepicker-wrapper {
width: 100%;
}
.react-datepicker-popper {
z-index: 100 !important;
}
/* Prevent flash at top-left before popper calculates position */
#datepicker-portal .react-datepicker-popper {
opacity: 0;
animation: dp-fade-in 0.01s forwards 0.02s;
}
@keyframes dp-fade-in {
to {
opacity: 1;
}
}
.react-datepicker {
font-family: inherit !important;
background-color: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius-sm) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25) !important;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
}
.react-datepicker__triangle {
display: none !important;
}
/* Header */
.react-datepicker__header {
background-color: var(--bg-tertiary) !important;
border-bottom: 1px solid var(--border-color) !important;
padding-top: 0.75rem !important;
}
.react-datepicker__current-month,
.react-datepicker-time__header {
color: var(--text-primary) !important;
font-weight: 600 !important;
}
.react-datepicker__day-name {
color: var(--text-secondary) !important;
}
/* Days */
.react-datepicker__day {
color: var(--text-primary) !important;
border-radius: 6px !important;
transition:
background 0.15s,
color 0.15s !important;
}
.react-datepicker__day:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: var(--accent-color) !important;
color: #fff !important;
}
.react-datepicker__day--today {
font-weight: 700 !important;
}
.react-datepicker__day--outside-month {
color: var(--text-muted) !important;
opacity: 0.5;
}
.react-datepicker__day--disabled {
color: var(--text-muted) !important;
opacity: 0.3 !important;
}
/* Navigation arrows */
.react-datepicker__navigation {
top: 0.75rem !important;
}
.react-datepicker__navigation-icon::before {
border-color: var(--text-secondary) !important;
}
.react-datepicker__navigation:hover *::before {
border-color: var(--accent-color) !important;
}
/* Year dropdown */
.react-datepicker__year-dropdown,
.react-datepicker__month-dropdown,
.react-datepicker__year-read-view,
.react-datepicker__month-read-view {
color: var(--text-primary) !important;
}
/* Time picker */
.react-datepicker__time-container {
border-left: 1px solid var(--border-color) !important;
}
.react-datepicker__time-container .react-datepicker__time {
background-color: var(--bg-secondary) !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box {
width: 100% !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item {
color: var(--text-primary) !important;
transition: background 0.15s !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item--selected {
background-color: var(--accent-color) !important;
color: #fff !important;
font-weight: 600 !important;
}
/* Month picker */
.react-datepicker__monthPicker {
background-color: var(--bg-secondary) !important;
}
.react-datepicker-year-header {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-bottom: 1px solid var(--border-color) !important;
}
.react-datepicker__month-wrapper {
background-color: var(--bg-secondary) !important;
}
.react-datepicker__month-text {
color: var(--text-primary) !important;
padding: 0.5rem !important;
border-radius: 6px !important;
transition: background 0.15s !important;
background-color: transparent !important;
}
.react-datepicker__month-text:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__month-text--selected {
background-color: var(--accent-color) !important;
color: #fff !important;
}
.react-datepicker__month-text--today {
font-weight: 700 !important;
}
/* Input */
.react-datepicker__input-container input {
cursor: pointer;
}
.react-datepicker__close-icon::after {
background-color: var(--accent-color) !important;
}

View File

@@ -1,171 +0,0 @@
/* ============================================================================
File Manager
============================================================================ */
.fm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.fm-full-path {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-tertiary);
user-select: all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.fm-toolbar-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.fm-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
font-size: 12px;
min-height: 28px;
}
.fm-breadcrumb-segment {
display: inline-flex;
align-items: center;
}
.fm-breadcrumb-sep {
color: var(--text-tertiary);
margin: 0 4px;
user-select: none;
}
.fm-breadcrumb-btn {
background: none;
border: none;
padding: 2px 6px;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
transition: all 0.15s ease;
}
.fm-breadcrumb-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.fm-breadcrumb-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.fm-new-folder {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.fm-new-folder .admin-form-input {
max-width: 250px;
}
.fm-content {
position: relative;
border-radius: var(--border-radius-sm);
transition: border-color 0.2s ease;
}
.fm-content.fm-drag-over {
border: 2px dashed var(--accent-color);
background: var(--accent-light);
}
.fm-dropzone-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
border-radius: var(--border-radius-sm);
z-index: 5;
color: var(--accent-color);
font-size: 13px;
font-weight: 500;
pointer-events: none;
}
.fm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
color: var(--text-tertiary);
font-size: 13px;
}
.fm-folder-link {
background: none;
border: none;
padding: 0;
color: var(--accent-color);
font-weight: 500;
font-size: inherit;
font-family: inherit;
cursor: pointer;
}
.fm-folder-link:hover {
text-decoration: underline;
}
.fm-item-count {
font-size: 10px;
color: var(--text-tertiary);
font-weight: 400;
}
.fm-file-name {
color: var(--text-primary);
}
.fm-meta {
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
.fm-actions {
display: inline-flex;
gap: 2px;
justify-content: flex-end;
}
.fm-name-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.fm-symlink-badge {
display: inline-flex;
align-items: center;
color: var(--text-tertiary);
cursor: help;
}

View File

@@ -1,488 +0,0 @@
/* ============================================================================
Forms
============================================================================ */
.admin-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.admin-form-input {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
min-height: 36px;
}
.admin-form-input:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-input::placeholder {
color: var(--text-muted);
}
.admin-form-input[type="date"],
.admin-form-input[type="time"],
.admin-form-input[type="month"],
.admin-form-input[type="number"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
text-align: left;
height: 36px;
max-width: 100%;
}
.admin-form-input[type="number"]::-webkit-inner-spin-button,
.admin-form-input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.admin-form-input[type="date"]::-webkit-date-and-time-value,
.admin-form-input[type="time"]::-webkit-date-and-time-value,
.admin-form-input[type="month"]::-webkit-date-and-time-value {
text-align: left;
margin: 0;
}
.admin-form-input[type="date"]::-webkit-datetime-edit,
.admin-form-input[type="time"]::-webkit-datetime-edit,
.admin-form-input[type="month"]::-webkit-datetime-edit {
padding: 0;
}
.admin-form-input[type="date"]::-webkit-calendar-picker-indicator,
.admin-form-input[type="time"]::-webkit-calendar-picker-indicator,
.admin-form-input[type="month"]::-webkit-calendar-picker-indicator {
filter: var(--calendar-icon-filter, none);
cursor: pointer;
}
/* Select */
.admin-form-select {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-height: 36px;
box-sizing: border-box;
cursor: pointer;
appearance: none;
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 32px;
}
.admin-form-select:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Textarea */
.admin-form-textarea {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
resize: vertical;
box-sizing: border-box;
min-height: 80px;
}
.admin-form-textarea:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
/* Checkbox */
.admin-form-checkbox {
display: inline-flex;
align-items: flex-start;
gap: 0;
cursor: pointer;
user-select: none;
}
.admin-form-checkbox input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.admin-form-checkbox input + span::before {
content: "";
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
vertical-align: middle;
transition:
border-color var(--transition),
box-shadow var(--transition),
background var(--transition);
flex-shrink: 0;
}
.admin-form-checkbox input:checked + span::before {
background: var(--accent-color);
border-color: var(--accent-color);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
background-size: 12px;
background-position: center;
background-repeat: no-repeat;
}
.admin-form-checkbox input:focus + span::before {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-checkbox:hover
input:not(:checked):not(:disabled):not(:indeterminate)
+ span::before {
border-color: var(--border-color-hover);
background: var(--bg-secondary);
}
.admin-form-checkbox input:indeterminate + span::before {
background: var(--accent-color);
border-color: var(--accent-color);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='12' x2='18' y2='12'%3E%3C/line%3E%3C/svg%3E");
background-size: 12px;
background-position: center;
background-repeat: no-repeat;
}
.admin-form-checkbox input:disabled + span::before {
opacity: 0.5;
cursor: not-allowed;
}
.admin-form-checkbox:has(input:disabled) {
cursor: not-allowed;
opacity: 0.7;
}
.admin-form-checkbox span {
display: flex;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
/* Reorderable List */
.admin-reorder-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-reorder-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.admin-reorder-arrows {
display: flex;
gap: 2px;
}
.admin-reorder-label {
font-size: 13px;
color: var(--text-primary);
}
.admin-reorder-label.accent {
color: var(--accent-color);
}
.admin-reorder-arrows .admin-btn-icon {
width: 22px;
height: 22px;
color: var(--text-muted);
}
.admin-reorder-arrows .admin-btn-icon:hover:not(:disabled) {
background: var(--bg-primary);
color: var(--text-primary);
}
.admin-reorder-arrows .admin-btn-icon:disabled {
opacity: 0.25;
}
/* Form Rows (Grid Layouts) */
.admin-form-row {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
.admin-form-row-3 {
grid-template-columns: repeat(3, 1fr);
}
.admin-form-row-4 {
grid-template-columns: repeat(4, 1fr);
}
.admin-form-row-5 {
grid-template-columns: 1.2fr 1fr 1fr 1fr 1fr;
}
@media (max-width: 768px) {
.admin-form-row-4 {
grid-template-columns: repeat(2, 1fr);
}
.admin-form-row-5 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 640px) {
.admin-form-row,
.admin-form-row-3 {
grid-template-columns: 1fr;
}
.admin-form-row-5 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.admin-form-row-4,
.admin-form-row-5 {
grid-template-columns: 1fr;
}
}
/* Form Utilities */
.admin-form-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Required field indicator */
.admin-form-label.required::after {
content: " *";
color: var(--danger);
font-weight: 600;
}
/* Inline field errors */
.admin-form-group.has-error .admin-form-input,
.admin-form-group.has-error .admin-form-select,
.admin-form-group.has-error .admin-form-textarea {
border-color: var(--danger);
box-shadow: 0 0 0 3px var(--danger-light);
}
.admin-form-group.has-error .admin-form-label {
color: var(--danger);
}
.admin-form-error {
font-size: 0.75rem;
color: var(--danger);
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.admin-form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
/* Touch targets - min 44px on mobile */
@media (max-width: 768px) {
.admin-form-input,
.admin-form-select,
.admin-form-textarea {
min-height: 44px;
font-size: 16px; /* prevent auto-zoom on iOS */
}
.admin-form-checkbox {
min-height: 44px;
padding: 8px 0;
}
.admin-form-checkbox input + span::before {
width: 20px;
height: 20px;
}
.admin-form-label {
font-size: 13px;
}
}
/* ============================================================================
Customer Selector
============================================================================ */
.admin-customer-select {
position: relative;
}
.admin-customer-selected {
display: flex;
align-items: center;
gap: 0.5rem;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--input-bg);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.admin-customer-selected span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-customer-selected .admin-btn-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-right: -4px;
}
.admin-customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
}
.admin-customer-dropdown::-webkit-scrollbar {
width: 5px;
}
.admin-customer-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.admin-customer-dropdown::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.admin-customer-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.admin-customer-dropdown-item {
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.admin-customer-dropdown-item:hover {
background: var(--bg-secondary);
}
.admin-customer-dropdown-item div:first-child {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.admin-customer-dropdown-item div:last-child {
font-size: 11.5px;
color: var(--text-tertiary);
margin-top: 1px;
}
.admin-customer-dropdown-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,48 @@
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 };
}

View File

@@ -1,7 +1,5 @@
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,
@@ -11,17 +9,9 @@ import {
formatDate,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
formatTimeOrDatetimePrint,
calculateWorkMinutesPrint,
isWeekendDate,
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
calculateNightMinutes,
normalizeDateStr,
holidaysInMonth,
calculateFreeHolidayHours,
getCzechWeekday,
} from "../utils/attendanceHelpers";
import type {
ShiftFormData,
@@ -38,7 +28,7 @@ interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void };
}
export interface AttendanceRecord {
interface AttendanceRecord {
id: number;
user_id: number;
shift_date: string;
@@ -48,7 +38,7 @@ export interface AttendanceRecord {
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
notes?: string;
project_id?: number | null;
project_name?: string;
project_logs?: Array<{
@@ -82,6 +72,7 @@ interface UserTotal {
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
overtime: number;
missing: number;
@@ -89,16 +80,6 @@ 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 {
@@ -139,11 +120,6 @@ 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[],
@@ -151,7 +127,6 @@ 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);
@@ -168,6 +143,7 @@ function computeUserTotals(
working: false,
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
overtime: 0,
missing: 0,
@@ -175,23 +151,15 @@ 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,
notes: rec.notes ?? undefined,
});
t.minutes += calculateWorkMinutes(rec);
} else {
const leaveHours = Number(rec.leave_hours) || 8;
switch (leaveType) {
@@ -201,14 +169,12 @@ 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.
}
}
@@ -223,10 +189,7 @@ function computeUserTotals(
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
// 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".
// Count business days in month (Mon-Fri)
let rawBizDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
@@ -235,100 +198,23 @@ 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];
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;
// 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;
t.fund = fund;
t.business_days = rawBizDays;
t.business_days = bizDays;
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;
@@ -338,13 +224,12 @@ function computeUserTotals(
// Print helpers
// ---------------------------------------------------------------------------
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
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 buildProjectLogsHtml(record: Record<string, any>): string {
@@ -370,11 +255,29 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
h = 0;
m = 0;
}
return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)</div>`;
})
.join("");
}
return escapeHtml(record.project_name || "—");
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>`;
}
function buildUserSectionHtml(
@@ -382,215 +285,71 @@ function buildUserSectionHtml(
userData: Record<string, any>,
printData: Record<string, any>,
): string {
// 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 leaveHtml = printData.leave_balances[userId]
? buildLeaveSummaryHtml(userId, userData, printData)
: "";
// 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);
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)}`;
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>
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>
<td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
<td class="text-center">${hoursCell}</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 style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`);
}
<td>${record.notes || ""}</td>
</tr>`;
})
.join("");
// ----- 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>`;
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>`
: "";
return `<div class="user-section">
<div class="user-header">
<h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
<h3>${userData.name}</h3>
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
</div>
${leaveHtml}
<table>
<thead><tr>
<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 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>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody>${rows.join("")}</tbody>
<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>
</table>
${summaryHtml}
</div>`;
}
@@ -606,15 +365,9 @@ function buildPrintHtml(
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
<title>Docházka - ${pData.month_name}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
@@ -644,47 +397,19 @@ 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; }
.user-summary {
margin-top: 10px; padding: 10px 15px;
background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
.leave-summary {
margin-top: 10px; padding: 8px 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; }
@@ -703,11 +428,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">${escapeHtml(companyName)}</div>
<div class="company">${companyName}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${escapeHtml(pData.month_name)}</div>
<div class="period">${pData.month_name}</div>
${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div>
@@ -727,7 +452,6 @@ function buildPrintHtml(
// ---------------------------------------------------------------------------
export default function useAttendanceAdmin({ alert }: AlertContext) {
const queryClient = useQueryClient();
// ---- Core state ----
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
@@ -837,9 +561,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
useEffect(() => {
const loadUsers = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=attendance_users`,
);
const response = await apiFetch(`${API_BASE}/users?limit=1000`);
const result = await response.json();
if (result.success) {
const apiUsers: ApiUser[] = result.data;
@@ -929,15 +651,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
formData: ShiftFormData,
): boolean => {
const totalWork = calcFormWorkMinutes(formData);
const totalProject = calcProjectMinutesTotal(
logs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
const totalProject = calcProjectMinutesTotal(logs);
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60);
const wM = totalWork % 60;
@@ -1046,7 +760,6 @@ 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(
@@ -1118,7 +831,6 @@ 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(
@@ -1142,7 +854,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 unknown as Record<string, unknown>).user_name as string) ||
: ((record as Record<string, unknown>).user_name as string) ||
`User #${record.user_id}`;
const enriched = { ...record, user_name: userName };
setEditingRecord(enriched);
@@ -1253,7 +965,6 @@ 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(
@@ -1283,7 +994,6 @@ 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",
@@ -1325,7 +1035,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: ${escapeHtml(pData.selected_user_name)}</div>`
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>`
: "";
const bodyContent = buildPrintHtml(
pData,
@@ -1339,9 +1049,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
printWindow.document.open();
printWindow.document.write(bodyContent);
printWindow.document.close();
printWindow.addEventListener("load", () => printWindow.print(), {
once: true,
});
printWindow.onload = () => printWindow.print();
}
}
} catch {

View File

@@ -0,0 +1,126 @@
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,
};
}

View File

@@ -1,16 +1,14 @@
import { useEffect } from "react";
let activeLocks = 0;
export default function useModalLock(isOpen: boolean): void {
useEffect(() => {
if (isOpen) {
if (activeLocks === 0) document.body.style.overflow = "hidden";
activeLocks++;
return () => {
activeLocks = Math.max(0, activeLocks - 1);
if (activeLocks === 0) document.body.style.overflow = "";
};
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
}

View File

@@ -1,44 +0,0 @@
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 };

View File

@@ -1,24 +0,0 @@
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;
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useRef } from "react";
interface SortState {
sort: string;
@@ -13,10 +13,10 @@ export default function useTableSort(
sort: defaultSort,
order: defaultOrder,
});
const [userClicked, setUserClicked] = useState(false);
const userClicked = useRef(false);
const handleSort = useCallback((column: string) => {
setUserClicked(true);
userClicked.current = 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 ? state.sort : null;
const activeSort = userClicked.current ? state.sort : null;
return { sort: state.sort, order: state.order, handleSort, activeSort };
}

View File

@@ -1,582 +0,0 @@
/* ============================================================================
Layout
============================================================================ */
.admin-layout {
display: flex;
min-height: 100vh;
min-height: 100dvh;
background: var(--bg-primary);
}
@media (min-width: 1024px) {
.admin-layout {
align-items: flex-start;
}
}
/* ============================================================================
Sidebar
============================================================================ */
/* -- Theme variables for sidebar -- */
[data-theme="dark"] .admin-sidebar {
--sb-bg: #141414;
--sb-border: #2a2a2a;
--sb-text: #a0a0a0;
--sb-text-hover: #ddd;
--sb-hover-bg: #1f1f1f;
--sb-active-bg: #ffffff;
--sb-active-text: #141414;
--sb-label: #444;
--sb-muted: #555;
--sb-scrollbar: #333;
}
[data-theme="light"] .admin-sidebar {
--sb-bg: #ffffff;
--sb-border: #e8e6e1;
--sb-text: #7c7c84;
--sb-text-hover: #1a1a1a;
--sb-hover-bg: #f5f4f2;
--sb-active-bg: #141414;
--sb-active-text: #ffffff;
--sb-label: #a0a0a0;
--sb-muted: #a0a0a0;
--sb-scrollbar: #ddd;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
height: 100dvh;
z-index: 50;
background: var(--sb-bg);
border-right: 1px solid var(--sb-border);
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
transform: translateX(-100%);
visibility: hidden;
transition:
transform 0.3s ease,
visibility 0.3s ease;
overflow: hidden;
overscroll-behavior: none;
}
.admin-sidebar.open {
transform: translateX(0);
visibility: visible;
touch-action: none;
}
@media (min-width: 1024px) {
.admin-sidebar {
right: auto;
width: 220px;
height: 100%;
transform: none;
visibility: visible;
padding: 0;
}
}
[data-theme="light"] .admin-sidebar {
box-shadow:
1px 0 0 0 var(--sb-border),
4px 0 16px rgba(0, 0, 0, 0.04);
}
/* Sidebar Overlay (mobile) */
.admin-sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 49;
backdrop-filter: blur(2px);
}
.admin-sidebar-overlay.open {
display: block;
}
@media (min-width: 1024px) {
.admin-sidebar-overlay {
display: none !important;
}
}
/* Sidebar Header */
.admin-sidebar-header {
padding: 0 18px;
height: 73px;
border-bottom: 1px solid var(--sb-border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.admin-sidebar-logo {
height: 28px;
width: auto;
}
.admin-sidebar-close {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 6px;
}
.admin-sidebar-close:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
@media (min-width: 1024px) {
.admin-sidebar-close {
display: none;
}
}
/* Sidebar Navigation */
.admin-sidebar-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 4px 0;
}
.admin-sidebar-nav::-webkit-scrollbar {
width: 5px;
}
.admin-sidebar-nav::-webkit-scrollbar-track {
background: transparent;
}
.admin-sidebar-nav::-webkit-scrollbar-thumb {
background: var(--sb-scrollbar);
border-radius: 99px;
}
/* Nav Section */
.admin-nav-section {
padding: 14px 10px 6px;
}
.admin-nav-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--sb-label);
padding: 0 8px;
margin-bottom: 4px;
}
/* Nav Item */
.admin-nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: 7px;
color: var(--sb-text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 13px;
font-weight: 450;
margin-bottom: 1px;
text-decoration: none;
user-select: none;
}
.admin-nav-item:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-nav-item.active {
background: var(--sb-active-bg);
color: var(--sb-active-text);
font-weight: 600;
}
.admin-nav-item.active svg {
color: var(--accent-color);
}
.admin-nav-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 1023px) {
.admin-nav-item {
padding: 10px 12px;
font-size: 15px;
gap: 10px;
}
.admin-nav-item svg {
width: 18px;
height: 18px;
}
}
/* Sidebar Footer */
.admin-sidebar-footer {
margin-top: auto;
border-top: 1px solid var(--sb-border);
padding: 14px 10px;
flex-shrink: 0;
}
.admin-user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
margin-bottom: 4px;
}
.admin-user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.admin-user-details {
flex: 1;
min-width: 0;
}
.admin-user-name {
color: var(--sb-text-hover);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-user-role {
color: var(--sb-muted);
font-size: 11px;
}
.admin-logout-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 7px;
font-size: 12px;
font-family: inherit;
transition: all 0.15s ease;
}
.admin-logout-btn:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-logout-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 480px) {
.admin-sidebar-footer {
padding: 10px 8px;
}
.admin-user-chip {
padding: 6px;
}
.admin-logout-btn {
padding: 8px;
font-size: 13px;
}
}
/* ============================================================================
Main Content Area
============================================================================ */
.admin-main {
flex: 1;
min-width: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
@media (min-width: 1024px) {
.admin-main {
margin-left: 220px;
}
}
/* Header */
.admin-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
height: calc(73px + env(safe-area-inset-top, 0px));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
padding-top: env(safe-area-inset-top, 0px);
}
@media (min-width: 1024px) {
.admin-header {
left: 220px;
padding: 0 1.5rem;
}
}
.admin-menu-btn {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--border-radius-sm);
}
.admin-menu-btn:hover {
background: var(--bg-tertiary);
}
@media (min-width: 1024px) {
.admin-menu-btn {
display: none;
}
}
.admin-header-theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.admin-header-theme-btn:hover {
background: var(--bg-tertiary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-theme-icon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: all 0.3s ease;
opacity: 0;
transform: rotate(180deg) scale(0.5);
}
.admin-theme-icon.visible {
opacity: 1;
transform: rotate(0) scale(1);
}
/* Content */
.admin-content {
flex: 1;
padding: 1rem;
padding-top: calc(73px + 1rem + env(safe-area-inset-top, 0px));
}
@media (min-width: 1024px) {
.admin-content {
padding: 28px 32px;
padding-top: calc(73px + 28px);
}
}
/* ============================================================================
Page Headers
============================================================================ */
.admin-page-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (min-width: 640px) {
.admin-page-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.admin-page-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.25rem;
}
.admin-page-subtitle {
color: var(--text-secondary);
font-size: 13px;
}
.admin-page-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.admin-page-actions {
width: 100%;
}
.admin-page-actions .admin-btn {
flex: 1;
}
}
/* ============================================================================
Grid System
============================================================================ */
.admin-grid {
display: grid;
gap: 1rem;
}
.admin-grid > .admin-card {
margin-bottom: 0;
}
.admin-grid-3 {
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.admin-grid-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.admin-grid-3 {
grid-template-columns: repeat(3, 1fr);
}
}
.admin-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.admin-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
}
/* Page header on mobile */
@media (max-width: 480px) {
.admin-page-title {
font-size: 18px;
}
.admin-page-subtitle {
font-size: 12px;
}
.admin-content {
padding: 12px !important;
padding-top: calc(73px + 12px + env(safe-area-inset-top, 0px)) !important;
}
}
/* Grid - single column on small mobile */
@media (max-width: 480px) {
.admin-grid-4 {
grid-template-columns: 1fr;
}
}
/* ============================================================================
Settings Grid
============================================================================ */
.admin-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.admin-settings-grid > .admin-card {
margin-bottom: 0;
}
@media (max-width: 900px) {
.admin-settings-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,83 +0,0 @@
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 };
}

View File

@@ -1,185 +0,0 @@
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,
});

View File

@@ -1,28 +0,0 @@
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}` : ""}`);
},
});

View File

@@ -1,28 +0,0 @@
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,
});

View File

@@ -1,42 +0,0 @@
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");
},
});

View File

@@ -1,209 +0,0 @@
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,
});

View File

@@ -1,42 +0,0 @@
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",
),
});

View File

@@ -1,113 +0,0 @@
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);
},
});
}

View File

@@ -1,160 +0,0 @@
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",
),
});

View File

@@ -1,84 +0,0 @@
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,
});

View File

@@ -1,111 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
import apiFetch from "../../utils/api";
export interface ProjectNote {
id: number;
content: string;
user_name: string;
created_at: string;
}
export interface ProjectData {
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
project_notes?: ProjectNote[];
}
export interface Project {
id: number;
project_number: string;
name: string;
}
export const projectListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["projects", "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<Project>(
`/api/admin/projects${qs ? `?${qs}` : ""}`,
);
},
});
export const projectDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["projects", id],
queryFn: () => jsonQuery<ProjectData>(`/api/admin/projects/${id}`),
enabled: !!id,
});
export interface ProjectFilesData {
items: Array<{
name: string;
type: "file" | "folder";
size?: number;
size_formatted?: string;
modified?: string;
extension?: string;
item_count?: number;
is_symlink?: boolean;
link_target?: string;
}>;
breadcrumb: string[];
path: string;
full_path: string;
}
export const projectFilesOptions = (projectId: number, path: string) =>
queryOptions({
queryKey: ["projects", String(projectId), "files", path],
queryFn: async (): Promise<ProjectFilesData> => {
const params = new URLSearchParams({ project_id: String(projectId) });
if (path) params.set("path", path);
let res: Response;
try {
res = await apiFetch(`/api/admin/project-files?${params}`);
} catch {
throw new Error("Chyba připojení");
}
if (res.status === 401) throw new Error("Unauthorized");
if (res.status === 404) {
return { items: [], breadcrumb: [""], path: "", full_path: "" };
}
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || `Request failed (${res.status})`);
}
return {
items: data.data.items || [],
breadcrumb: data.data.breadcrumb || [""],
path: data.data.path || "",
full_path: data.data.full_path || "",
};
},
});

View File

@@ -1,87 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface CompanySettingsCustomField {
name: string;
value: string;
showLabel: boolean;
}
export interface CompanySettingsData {
// Company info
company_name?: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
ico?: string;
dic?: string;
has_logo?: boolean;
has_logo_dark?: boolean;
custom_fields?: CompanySettingsCustomField[];
supplier_field_order?: string[];
// System settings
require_2fa?: boolean;
default_currency?: string;
default_vat_rate?: number;
available_currencies?: string[];
available_vat_rates?: number[];
break_threshold_hours?: number;
break_duration_short?: number;
break_duration_long?: number;
clock_rounding_minutes?: number;
invoice_alert_email?: string;
leave_notify_email?: string;
smtp_from?: string;
smtp_from_name?: string;
max_login_attempts?: number;
lockout_minutes?: number;
max_requests_per_minute?: number;
// Numbering
quotation_prefix?: string;
order_type_code?: string;
invoice_type_code?: string;
offer_number_pattern?: string;
order_number_pattern?: string;
invoice_number_pattern?: string;
warehouse_receipt_prefix?: string;
warehouse_receipt_number_pattern?: string;
warehouse_issue_prefix?: string;
warehouse_issue_number_pattern?: string;
warehouse_inventory_prefix?: string;
warehouse_inventory_number_pattern?: string;
// Misc
app_version?: string;
}
export const companySettingsOptions = () =>
queryOptions({
queryKey: ["company-settings"],
queryFn: () =>
jsonQuery<CompanySettingsData>("/api/admin/company-settings"),
staleTime: 5 * 60_000,
});
export const systemInfoOptions = () =>
queryOptions({
queryKey: ["settings", "system-info"],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
"/api/admin/company-settings/system-info",
),
});
/** @deprecated Use systemInfoOptions instead — this query fetches system-info, not system settings. */
export const systemSettingsOptions = systemInfoOptions;
export const require2FAOptions = () =>
queryOptions({
queryKey: ["settings", "2fa"],
queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
});

View File

@@ -1,104 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface TripVehicle {
id: number;
spz: string;
name: string;
}
export interface TripUser {
id: number;
name: string;
}
export interface BackendTrip {
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
export const tripListOptions = (filters: {
month?: number;
year?: number;
vehicleId?: number;
userId?: number;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"list",
{
month: filters.month,
year: filters.year,
vehicleId: filters.vehicleId,
userId: filters.userId,
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.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
},
});
export const tripVehiclesOptions = () =>
queryOptions({
queryKey: ["trips", "vehicles"],
queryFn: () => jsonQuery<TripVehicle[]>("/api/admin/vehicles"),
staleTime: 2 * 60_000,
});
export const tripUsersOptions = () =>
queryOptions({
queryKey: ["trips", "users"],
queryFn: () => jsonQuery<TripUser[]>("/api/admin/trips/users"),
staleTime: 2 * 60_000,
});
export const tripHistoryOptions = (filters: {
month?: string;
vehicleId?: number;
userId?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"history",
{
month: filters.month,
vehicleId: filters.vehicleId,
userId: filters.userId,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", filters.month);
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
},
});

View File

@@ -1,37 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export interface User {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
export interface Role {
id: number;
name: string;
display_name: string;
}
export const userListOptions = (permission?: string) =>
queryOptions({
queryKey: ["users", { permission }],
queryFn: () => {
const url = permission
? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
: "/api/admin/users?limit=100";
return jsonQuery<User[]>(url);
},
});
export const roleListOptions = () =>
queryOptions({
queryKey: ["roles"],
queryFn: () => jsonQuery<Role[]>("/api/admin/roles"),
staleTime: 2 * 60_000,
});

View File

@@ -1,8 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const vehicleListOptions = () =>
queryOptions({
queryKey: ["vehicles"],
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
});

View File

@@ -1,430 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
// --- Types ---
export interface WarehouseItem {
id: number;
item_number: string | null;
name: string;
description: string | null;
category_id: number | null;
unit: string;
min_quantity: number | null;
is_active: boolean;
notes: string | null;
category?: { id: number; name: string } | null;
total_quantity?: number;
available_quantity?: number;
stock_value?: number;
below_minimum?: boolean;
}
export interface WarehouseReceipt {
id: number;
receipt_number: string | null;
supplier_id: number | null;
delivery_note_number: string | null;
delivery_note_date: string | null;
received_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
supplier?: { id: number; name: string } | null;
received_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
_count?: { items: number };
items?: WarehouseReceiptItem[];
attachments?: WarehouseReceiptAttachment[];
}
export interface WarehouseReceiptItem {
id: number;
receipt_id: number;
item_id: number;
quantity: number;
unit_price: number;
location_id: number | null;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseReceiptAttachment {
id: number;
receipt_id: number;
file_name: string;
file_mime: string;
file_size: number;
file_path: string;
created_at: string;
}
export interface WarehouseIssue {
id: number;
issue_number: string | null;
project_id: number;
issued_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
project?: { id: number; name: string; project_number: string } | null;
issued_by_user?: { id: number; first_name: string; last_name: string } | null;
_count?: { items: number };
items?: WarehouseIssueItem[];
}
export interface WarehouseIssueItem {
id: number;
issue_id: number;
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
item?: WarehouseItem;
batch?: {
id: number;
quantity: number;
unit_price: number;
received_at: string;
} | null;
location?: { id: number; code: string; name: string } | null;
reservation?: { id: number; quantity: number; remaining_qty: number } | null;
}
export interface WarehouseReservation {
id: number;
item_id: number;
project_id: number;
quantity: number;
remaining_qty: number;
reserved_by: number | null;
notes: string | null;
status: "ACTIVE" | "FULFILLED" | "CANCELLED";
created_at: string;
modified_at: string | null;
item?: WarehouseItem;
project?: { id: number; name: string } | null;
reserved_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
}
export interface WarehouseInventorySession {
id: number;
session_number: string | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED";
created_at: string;
modified_at: string | null;
items?: WarehouseInventoryItem[];
}
export interface WarehouseInventoryItem {
id: number;
session_id: number;
item_id: number;
location_id: number | null;
system_qty: number;
actual_qty: number;
difference: number;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseCategory {
id: number;
name: string;
description: string | null;
sort_order: number;
}
export interface WarehouseLocation {
id: number;
code: string;
name: string;
description: string | null;
is_active: boolean;
}
export interface WarehouseSupplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
contact_person: string | null;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
is_active: boolean;
}
export interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
// --- Query Options ---
export const warehouseItemListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
sort?: string;
order?: string;
category_id?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "items", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.category_id)
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return paginatedJsonQuery<WarehouseItem>(
`/api/admin/warehouse/items${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseItemDetailOptions = (
id: string | undefined,
enabled?: boolean,
) =>
queryOptions({
queryKey: ["warehouse", "items", id],
queryFn: () => jsonQuery<WarehouseItem>(`/api/admin/warehouse/items/${id}`),
enabled: enabled ?? !!id,
});
export const warehouseCategoryListOptions = () =>
queryOptions({
queryKey: ["warehouse", "categories"],
queryFn: () =>
jsonQuery<WarehouseCategory[]>("/api/admin/warehouse/categories"),
});
export const warehouseSupplierListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "suppliers", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
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<WarehouseSupplier>(
`/api/admin/warehouse/suppliers${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseLocationListOptions = (filters?: {
active?: "true" | "false" | "all";
}) =>
queryOptions({
queryKey: ["warehouse", "locations", filters ?? { active: "true" }],
queryFn: () => {
const active = filters?.active ?? "true";
return jsonQuery<WarehouseLocation[]>(
`/api/admin/warehouse/locations?active=${active}`,
);
},
});
export const warehouseReceiptListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
status?: string;
supplier_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "receipts", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
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.supplier_id)
params.set("supplier_id", String(filters.supplier_id));
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return paginatedJsonQuery<WarehouseReceipt>(
`/api/admin/warehouse/receipts${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseReceiptDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "receipts", id],
queryFn: () =>
jsonQuery<WarehouseReceipt>(`/api/admin/warehouse/receipts/${id}`),
enabled: !!id,
});
export const warehouseIssueListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
status?: string;
project_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "issues", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
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.project_id)
params.set("project_id", String(filters.project_id));
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return paginatedJsonQuery<WarehouseIssue>(
`/api/admin/warehouse/issues${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseIssueDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "issues", id],
queryFn: () =>
jsonQuery<WarehouseIssue>(`/api/admin/warehouse/issues/${id}`),
enabled: !!id,
});
export const warehouseReservationListOptions = (filters: {
page?: number;
perPage?: number;
item_id?: number;
project_id?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "reservations", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.item_id) params.set("item_id", String(filters.item_id));
if (filters.project_id)
params.set("project_id", String(filters.project_id));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<WarehouseReservation>(
`/api/admin/warehouse/reservations${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseInventoryListOptions = (filters: {
page?: number;
perPage?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "inventory", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
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);
const qs = params.toString();
return paginatedJsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory-sessions${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseInventoryDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["warehouse", "inventory", id],
queryFn: () =>
jsonQuery<WarehouseInventorySession>(
`/api/admin/warehouse/inventory-sessions/${id}`,
),
enabled: !!id,
});
export const warehouseStockStatusOptions = (filters?: {
category_id?: number;
}) =>
queryOptions({
queryKey: ["warehouse", "stock-status", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters?.category_id)
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return jsonQuery<WarehouseItem[]>(
`/api/admin/warehouse/reports/stock-status${qs ? `?${qs}` : ""}`,
);
},
});
export const warehouseBelowMinimumOptions = () =>
queryOptions({
queryKey: ["warehouse", "below-minimum"],
queryFn: () =>
jsonQuery<WarehouseItem[]>("/api/admin/warehouse/reports/below-minimum"),
});
export const warehouseMovementLogOptions = (filters?: {
limit?: number;
type?: string;
project_id?: number;
date_from?: string;
date_to?: string;
}) =>
queryOptions({
queryKey: ["warehouse", "movement-log", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters?.limit) params.set("limit", String(filters.limit));
if (filters?.type) params.set("type", filters.type);
if (filters?.project_id)
params.set("project_id", String(filters.project_id));
if (filters?.date_from) params.set("date_from", filters.date_from);
if (filters?.date_to) params.set("date_to", filters.date_to);
const qs = params.toString();
return jsonQuery<MovementLogRow[]>(
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
);
},
});

View File

@@ -1,13 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry: 1,
refetchOnReconnect: true,
},
},
});

View File

@@ -2,6 +2,63 @@
Offers Module
============================================ */
/* Editor section cards */
.offers-editor-section {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
/* Settings grid */
.offers-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.offers-settings-grid > .admin-card {
margin-bottom: 0;
}
@media (max-width: 900px) {
.offers-settings-grid {
grid-template-columns: 1fr;
}
}
/* Logo section */
.offers-logo-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.offers-logo-preview {
max-width: 200px;
max-height: 100px;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: #fff;
}
.offers-logo-preview img {
max-width: 100%;
max-height: 80px;
object-fit: contain;
}
/* Items table */
.offers-items-table {
overflow-x: auto;
@@ -46,6 +103,240 @@
min-height: 32px;
}
/* Totals summary */
.offers-totals-summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
}
.offers-totals-row {
display: flex;
gap: 2rem;
justify-content: flex-end;
min-width: 250px;
padding: 0.25rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.offers-totals-row span:last-child {
min-width: 100px;
text-align: right;
font-weight: 500;
color: var(--text-primary);
}
.offers-totals-total {
border-top: 2px solid var(--text-primary);
margin-top: 0.25rem;
padding-top: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.offers-totals-total span:last-child {
font-weight: 700;
}
/* Scope sections list wrapper */
.offers-scope-list {
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Scope section card */
.offers-scope-section {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
transition: border-color var(--transition);
background: var(--bg-primary);
}
.offers-scope-content {
overflow: hidden;
}
.offers-scope-section:hover {
border-color: color-mix(
in srgb,
var(--border-color) 70%,
var(--accent-color)
);
}
.offers-scope-section-header {
display: flex;
align-items: center;
padding: 0.625rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
border-radius: 0.5rem 0.5rem 0 0;
gap: 0.5rem;
}
.offers-scope-section-header .offers-scope-number {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
flex-shrink: 0;
min-width: 1.25rem;
}
.offers-scope-section-header .offers-scope-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.offers-scope-section-header .offers-scope-actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
flex-shrink: 0;
}
.offers-scope-section .admin-form {
padding: 1rem;
}
/* Customer selector */
.offers-customer-select {
position: relative;
}
.offers-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);
}
.offers-customer-selected span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.offers-customer-selected .admin-btn-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-right: -4px;
}
.offers-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;
}
.offers-customer-dropdown::-webkit-scrollbar {
width: 5px;
}
.offers-customer-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.offers-customer-dropdown::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.offers-customer-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.offers-customer-dropdown-item {
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.offers-customer-dropdown-item:hover {
background: var(--bg-secondary);
}
.offers-customer-dropdown-item div:first-child {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.offers-customer-dropdown-item div:last-child {
font-size: 11.5px;
color: var(--text-tertiary);
margin-top: 1px;
}
.offers-customer-dropdown-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.8125rem;
}
/* Template dropdown menu */
.offers-template-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
min-width: 200px;
max-height: 250px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 0.25rem;
}
.offers-template-menu-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition);
}
.offers-template-menu-item:hover {
background: var(--bg-secondary);
}
/* Language badges */
.offers-lang-badge {
display: inline-flex;
@@ -70,20 +361,81 @@
color: var(--danger);
}
/* Compact form row for 3+ columns */
.offers-form-row-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.offers-form-row-3 {
grid-template-columns: 1fr;
}
}
/* Tabs - zachovany pro zpetnou kompatibilitu, nove pouzivat admin-tabs/admin-tab */
.offers-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
margin-bottom: 1.5rem;
max-width: 100%;
overflow-x: auto;
}
.offers-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
background: transparent;
border: none;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition:
color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
letter-spacing: 0.01em;
white-space: nowrap;
}
.offers-tab:hover {
color: var(--text-primary);
}
.offers-tab.active {
color: var(--text-primary);
font-weight: 600;
background: var(--bg-secondary);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 0 0 1px var(--border-color);
}
/* RichEditor (Quill) */
.admin-rich-editor {
.rich-editor {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
}
.admin-rich-editor .quill {
.rich-editor .quill {
display: flex;
flex-direction: column;
}
/* Toolbar */
.admin-rich-editor .ql-toolbar.ql-snow {
.rich-editor .ql-toolbar.ql-snow {
background: var(--bg-secondary);
border: none;
border-bottom: 1px solid var(--border-color);
@@ -93,60 +445,60 @@
gap: 2px;
}
.admin-rich-editor .ql-toolbar .ql-formats {
.rich-editor .ql-toolbar .ql-formats {
margin-right: 8px;
}
/* Toolbar buttons */
.admin-rich-editor .ql-snow .ql-stroke {
.rich-editor .ql-snow .ql-stroke {
stroke: var(--text-secondary);
}
.admin-rich-editor .ql-snow .ql-fill {
.rich-editor .ql-snow .ql-fill {
fill: var(--text-secondary);
}
.admin-rich-editor .ql-snow .ql-picker-label {
.rich-editor .ql-snow .ql-picker-label {
color: var(--text-secondary);
border-color: var(--border-color);
}
.admin-rich-editor .ql-snow button:hover .ql-stroke,
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
.rich-editor .ql-snow button:hover .ql-stroke,
.rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
stroke: var(--text-primary);
}
.admin-rich-editor .ql-snow button:hover .ql-fill,
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
.rich-editor .ql-snow button:hover .ql-fill,
.rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
fill: var(--text-primary);
}
.admin-rich-editor .ql-snow button:hover,
.admin-rich-editor .ql-snow .ql-picker-label:hover {
.rich-editor .ql-snow button:hover,
.rich-editor .ql-snow .ql-picker-label:hover {
color: var(--text-primary);
}
/* Active state */
.admin-rich-editor .ql-snow button.ql-active {
.rich-editor .ql-snow button.ql-active {
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
border-radius: 4px;
}
.admin-rich-editor .ql-snow button.ql-active .ql-stroke {
.rich-editor .ql-snow button.ql-active .ql-stroke {
stroke: var(--accent-color);
}
.admin-rich-editor .ql-snow button.ql-active .ql-fill,
.admin-rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
.rich-editor .ql-snow button.ql-active .ql-fill,
.rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
fill: var(--accent-color);
}
.admin-rich-editor .ql-snow .ql-picker-item.ql-selected {
.rich-editor .ql-snow .ql-picker-item.ql-selected {
color: var(--accent-color);
}
.admin-rich-editor .ql-snow .ql-picker-label.ql-active {
.rich-editor .ql-snow .ql-picker-label.ql-active {
color: var(--accent-color);
}
.admin-rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
.rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
stroke: var(--accent-color);
}
/* Dropdowns (font, size, color, align) */
.admin-rich-editor .ql-snow .ql-picker-options {
.rich-editor .ql-snow .ql-picker-options {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
@@ -155,23 +507,23 @@
padding: 0.25rem;
}
.admin-rich-editor .ql-snow .ql-picker-item {
.rich-editor .ql-snow .ql-picker-item {
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.admin-rich-editor .ql-snow .ql-picker-item:hover {
.rich-editor .ql-snow .ql-picker-item:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Font picker */
.admin-rich-editor .ql-snow .ql-font .ql-picker-options {
.rich-editor .ql-snow .ql-font .ql-picker-options {
min-width: 11rem;
max-height: 200px;
overflow-y: auto;
}
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
.rich-editor .ql-snow .ql-size .ql-picker-options {
max-height: 200px;
overflow-y: auto;
}
@@ -351,39 +703,34 @@
}
/* Editor area */
.admin-rich-editor .ql-container.ql-snow {
.rich-editor .ql-container.ql-snow {
border: none;
border-radius: 0 0 0.5rem 0.5rem;
font-family: Tahoma, sans-serif;
font-size: 14px;
font-size: 0.875rem;
}
.admin-rich-editor .ql-editor {
.rich-editor .ql-editor {
min-height: var(--re-min-height, 120px);
padding: 0.75rem;
color: var(--text-primary);
line-height: 1.6;
font-family: Tahoma, sans-serif;
font-size: 14px;
font-size: 0.875rem;
background: var(--input-bg);
}
.admin-rich-editor .ql-editor.ql-blank::before {
.rich-editor .ql-editor.ql-blank::before {
color: var(--text-tertiary);
font-style: normal;
}
/* Lists inside editor */
.admin-rich-editor .ql-editor ul,
.admin-rich-editor .ql-editor ol {
.rich-editor .ql-editor ul,
.rich-editor .ql-editor ol {
padding-left: 1.5rem;
}
/* Color picker */
.admin-rich-editor
.ql-snow
.ql-color-picker
.ql-picker-options[aria-hidden="false"] {
.rich-editor .ql-snow .ql-color-picker .ql-picker-options[aria-hidden="false"] {
width: 176px;
padding: 0.375rem;
display: flex;
@@ -391,7 +738,7 @@
gap: 2px;
}
.admin-rich-editor .ql-snow .ql-color-picker .ql-picker-item {
.rich-editor .ql-snow .ql-color-picker .ql-picker-item {
width: 18px;
height: 18px;
border-radius: 2px;
@@ -401,7 +748,7 @@
}
/* Tooltip (link editor) */
.admin-rich-editor .ql-snow .ql-tooltip {
.rich-editor .ql-snow .ql-tooltip {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
@@ -409,7 +756,7 @@
color: var(--text-primary);
}
.admin-rich-editor .ql-snow .ql-tooltip input[type="text"] {
.rich-editor .ql-snow .ql-tooltip input[type="text"] {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
@@ -417,12 +764,12 @@
padding: 0.25rem 0.5rem;
}
.admin-rich-editor .ql-snow .ql-tooltip a {
.rich-editor .ql-snow .ql-tooltip a {
color: var(--accent-color);
}
/* Read-only rendered rich text (Quill HTML output) */
.admin-rich-text-view {
.rich-text-view {
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.875rem;
@@ -431,102 +778,55 @@
min-width: 0;
}
.admin-rich-text-view ul,
.admin-rich-text-view ol {
.rich-text-view ul,
.rich-text-view ol {
padding-left: 1.5rem;
margin: 0.25rem 0 0.75rem;
}
.admin-rich-text-view li {
.rich-text-view li {
margin-bottom: 0.15rem;
}
.admin-rich-text-view a {
.rich-text-view a {
color: var(--accent-color);
}
.admin-rich-text-view strong,
.admin-rich-text-view b {
.rich-text-view strong,
.rich-text-view b {
font-weight: 600;
color: var(--text-primary);
display: inline-block;
margin-top: 0.5rem;
}
.admin-rich-text-view br + b,
.admin-rich-text-view br + strong {
.rich-text-view br + b,
.rich-text-view br + strong {
margin-top: 0.75rem;
}
.admin-rich-text-view > br:first-child,
.admin-rich-text-view ul + br,
.admin-rich-text-view ol + br {
.rich-text-view > br:first-child,
.rich-text-view ul + br,
.rich-text-view ol + br {
display: none;
}
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) {
.offers-editor-section {
padding: 1rem;
}
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
.offers-totals-summary {
align-items: stretch;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
.offers-totals-row {
min-width: unset;
}
}
@@ -574,14 +874,6 @@
color: var(--text-muted) !important;
}
/* Completed offer */
.offers-completed-row {
background: var(
--row-completed,
color-mix(in srgb, var(--success) 8%, transparent)
);
}
/* Read-only form (invalidated offer detail) */
.offers-readonly input[readonly],
.offers-readonly select:disabled {
@@ -590,32 +882,13 @@
}
/* Offer draft indicator */
/* Filters */
.admin-filters {
.offers-draft-indicator {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.admin-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-filter-label {
font-size: 0.75rem;
font-weight: 600;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-filter-group .admin-tabs {
flex-wrap: wrap;
}
.admin-filter-group .admin-form-select {
min-width: 10rem;
margin-top: 0.2rem;
opacity: 0.8;
}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
@@ -15,9 +14,6 @@ import {
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { useApiMutation } from "../lib/queries/mutations";
import { czechPlural } from "../utils/formatters";
const API_BASE = "/api/admin";
@@ -65,6 +61,7 @@ interface MonthlyFund {
leave_hours: number;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
}
@@ -78,6 +75,12 @@ interface AttendanceData {
active_project_id: number | null;
}
function pluralizeDays(n: number) {
if (n === 1) return "den";
if (n >= 2 && n <= 4) return "dny";
return "dnů";
}
function getFundBarBackground(fund: MonthlyFund) {
if (fund.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
@@ -89,55 +92,23 @@ function getFundBarBackground(fund: MonthlyFund) {
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
queryFn: () => jsonQuery<AttendanceData>("/api/admin/attendance/status"),
});
const projectsQuery = useQuery({
queryKey: ["attendance", "projects"],
queryFn: () =>
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
{
url: () => `${API_BASE}/attendance/notes`,
method: () => "POST",
invalidate: ["attendance"],
},
);
const switchProjectMutation = useApiMutation<
{ project_id: number | null },
{ message?: string }
>({
url: () => `${API_BASE}/attendance/switch-project`,
method: () => "POST",
invalidate: ["attendance"],
});
const leaveRequestMutation = useApiMutation<
{ leave_type: string; date_from: string; date_to: string; notes: string },
{ message?: string }
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
invalidate: ["attendance", "leave-requests", "users"],
});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [data, setData] = useState<AttendanceData>({
ongoing_shift: null,
today_shifts: [],
date: "",
leave_balance: {
vacation_total: 160,
vacation_used: 0,
vacation_remaining: 160,
sick_used: 0,
},
monthly_fund: null,
project_logs: [],
active_project_id: null,
});
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
@@ -146,51 +117,70 @@ export default function Attendance() {
});
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [notes, setNotes] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
const [switchingProject, setSwitchingProject] = useState(false);
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{
isOpen: boolean;
show: boolean;
action: string | null;
}>({ isOpen: false, action: null });
}>({ show: false, action: null });
const geoAbortRef = useRef<AbortController | null>(null);
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const latestActionRef = useRef<string | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
};
}, []);
// Sync notes from query data when the shift changes
useEffect(() => {
if (statusQuery.data) {
setNotes(statusQuery.data.ongoing_shift?.notes || "");
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/status`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setData(result.data);
setNotes(result.data.ongoing_shift?.notes || "");
setProjectLogs(result.data.project_logs || []);
setActiveProjectId(result.data.active_project_id || null);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [statusQuery.data]);
}, [alert]);
useModalLock(isLeaveModalOpen);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=projects`,
);
const result = await response.json();
if (result.success) {
const items = Array.isArray(result.data) ? result.data : [];
setProjects(items);
}
} catch {
// silent - projects are supplementary
}
};
loadProjects();
}, []);
useModalLock(showLeaveModal);
if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => {
setSubmitting(true);
latestActionRef.current = action;
// Some browsers silently hang on getCurrentPosition (especially with
// enableHighAccuracy:true on desktops without GPS). Use a short safety
// timeout and proceed without GPS rather than leaving the user stuck.
const safetyTimeout = setTimeout(() => {
if (mountedRef.current) {
submitPunch(action, {});
}
}, 6000);
if (!navigator.geolocation) {
clearTimeout(safetyTimeout);
alert.warning("GPS není dostupná");
submitPunch(action, {});
return;
@@ -198,21 +188,14 @@ export default function Attendance() {
navigator.geolocation.getCurrentPosition(
(position) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
try {
const { latitude, longitude, accuracy } = position.coords;
submitPunch(action, { latitude, longitude, accuracy, address: "" });
} catch {
submitPunch(action, {});
}
const { latitude, longitude, accuracy } = position.coords;
submitPunch(action, { latitude, longitude, accuracy, address: "" });
// Fire-and-forget reverse geocoding to update the address later
if (geoAbortRef.current) geoAbortRef.current.abort();
const controller = new AbortController();
geoAbortRef.current = controller;
fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${position.coords.latitude}&lon=${position.coords.longitude}&zoom=18&addressdetails=1`,
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
{
headers: { "Accept-Language": "cs" },
signal: controller.signal,
@@ -220,15 +203,13 @@ export default function Attendance() {
)
.then((r) => r.json())
.then((geoData) => {
if (!mountedRef.current) return;
if (latestActionRef.current !== action) return;
if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
latitude,
longitude,
address: geoData.display_name,
punch_action: action,
}),
@@ -238,8 +219,6 @@ export default function Attendance() {
.catch(() => {});
},
(geoError) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
let errorMsg = "Nepodařilo se získat polohu";
if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = "Přístup k poloze byl zamítnut";
@@ -247,10 +226,9 @@ export default function Attendance() {
errorMsg = "Vypršel časový limit";
}
alert.error(errorMsg);
setSubmitting(false);
setGpsConfirm({ isOpen: true, action });
setGpsConfirm({ show: true, action });
},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
};
@@ -259,29 +237,51 @@ export default function Attendance() {
gpsData: Record<string, unknown> = {},
) => {
try {
const result = await punchMutation.mutateAsync({
punch_action: action,
...gpsData,
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
});
if (response.status === 401) return;
const result = await response.json();
setSubmitting(false);
punchTimeoutRef.current = setTimeout(() => {
alert.success(result?.message || "Uloženo");
}, 300);
} catch (e) {
if (result.success) {
await fetchData();
setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
alert.error(result.error);
}
} catch {
setSubmitting(false);
alert.error(e instanceof Error ? e.message : "Chyba připojení");
alert.error("Chyba připojení");
}
};
const handleBreak = async () => {
setSubmitting(true);
try {
const result = await punchMutation.mutateAsync({
punch_action: "break_start",
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: "break_start" }),
});
alert.success(result?.message || "Přestávka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await fetchData();
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSubmitting(false);
}
@@ -289,22 +289,45 @@ export default function Attendance() {
const handleSaveNotes = async () => {
try {
await notesMutation.mutateAsync({ notes });
alert.success("Poznámka byla uložena");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
const response = await apiFetch(`${API_BASE}/attendance/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
const handleSwitchProject = async (newProjectId: string | null) => {
setSwitchingProject(true);
try {
const result = await switchProjectMutation.mutateAsync({
project_id: newProjectId ? Number(newProjectId) : null,
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: newProjectId || null }),
});
alert.success(result?.message || "Projekt přepnut");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await fetchData();
alert.success(
result.data?.message || result.message || "Projekt přepnut",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSwitchingProject(false);
}
@@ -328,43 +351,142 @@ export default function Attendance() {
const handleRequestSubmit = async () => {
setRequestSubmitting(true);
try {
const result = await leaveRequestMutation.mutateAsync(leaveForm);
setIsLeaveModalOpen(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result?.message || "Žádost odeslána");
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
const response = await apiFetch(`${API_BASE}/leave-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(leaveForm),
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setShowLeaveModal(false);
await fetchData();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.data?.message || result.message || "Žádost odeslána",
);
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setRequestSubmitting(false);
}
};
if (statusQuery.isPending) {
if (loading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div style={{ display: "flex", gap: "1.5rem" }}>
<div className="admin-card" style={{ flex: 2 }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "120px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "180px" }}
/>
<div className="admin-skeleton-row">
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
</div>
</div>
</div>
);
}
if (!statusQuery.data) {
return null;
}
const {
ongoing_shift: ongoingShift,
today_shifts: todayShifts,
leave_balance: leaveBalance,
} = statusQuery.data;
const data = statusQuery.data;
const projects = projectsQuery.data ?? [];
const projectLogs = data.project_logs ?? [];
const activeProjectId = data.active_project_id ?? null;
} = data;
const isOngoingShift = ongoingShift && !ongoingShift.departure_time;
const completedToday = todayShifts.filter((s) => s.departure_time);
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
@@ -454,7 +576,10 @@ export default function Attendance() {
<div className="attendance-project-header">
<span className="attendance-shift-label">Projekt</span>
{activeProjectId ? (
<span className="admin-badge admin-badge-wrap text-sm">
<span
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.8125rem" }}
>
{projects.find(
(p) => String(p.id) === String(activeProjectId),
)
@@ -462,7 +587,12 @@ export default function Attendance() {
: `Projekt #${activeProjectId}`}
</span>
) : (
<span className="text-muted text-sm">Žádný</span>
<span
className="text-muted"
style={{ fontSize: "0.8125rem" }}
>
Žádný
</span>
)}
</div>
<select
@@ -471,7 +601,8 @@ export default function Attendance() {
handleSwitchProject(e.target.value || null)
}
disabled={switchingProject}
className="admin-form-select text-md"
className="admin-form-select"
style={{ fontSize: "0.875rem" }}
>
<option value=""> Bez projektu </option>
{projects.map((p) => (
@@ -483,16 +614,12 @@ export default function Attendance() {
{projectLogs.length > 0 && (
<div className="attendance-project-logs">
{projectLogs.map((log, i) => {
if (!log.started_at) return null;
const start = new Date(log.started_at);
const start = new Date(log.started_at!);
const end = log.ended_at
? new Date(log.ended_at)
: new Date();
const mins = Math.max(
0,
Math.floor(
(end.getTime() - start.getTime()) / 60000,
),
const mins = Math.floor(
(end.getTime() - start.getTime()) / 60000,
);
const h = Math.floor(mins / 60);
const mm = mins % 60;
@@ -527,7 +654,8 @@ export default function Attendance() {
<button
onClick={handleBreak}
disabled={submitting}
className="admin-btn admin-btn-secondary w-full"
className="admin-btn admin-btn-secondary"
style={{ width: "100%" }}
>
Pauza (30 min)
</button>
@@ -535,13 +663,15 @@ export default function Attendance() {
<button
onClick={() => handlePunch("departure")}
disabled={submitting}
className="admin-btn admin-btn-primary w-full"
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
{submitting ? "Zpracovávám..." : "Odchod"}
</button>
<button
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
onClick={() => setShowLeaveModal(true)}
className="admin-btn admin-btn-secondary"
style={{ width: "100%" }}
>
Žádost o nepřítomnost
</button>
@@ -573,14 +703,16 @@ export default function Attendance() {
<button
onClick={() => handlePunch("arrival")}
disabled={submitting}
className="admin-btn admin-btn-primary w-full"
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
{submitting ? "Zpracovávám..." : "Příchod"}
</button>
<button
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
onClick={() => setShowLeaveModal(true)}
className="admin-btn admin-btn-secondary"
style={{ width: "100%" }}
>
Žádost o nepřítomnost
</button>
@@ -623,13 +755,7 @@ export default function Attendance() {
{formatTime(shift.departure_time)}
</td>
<td className="admin-mono">
{formatMinutes(
calculateWorkMinutes({
...shift,
notes: shift.notes ?? undefined,
}),
true,
)}
{formatMinutes(calculateWorkMinutes(shift), true)}
</td>
{projects.length > 0 && (
<td>
@@ -642,12 +768,11 @@ export default function Attendance() {
}}
>
{shiftLogs.map((log, i) => {
if (!log.started_at) return null;
const mins = log.ended_at
? Math.floor(
(new Date(log.ended_at).getTime() -
new Date(
log.started_at,
log.started_at!,
).getTime()) /
60000,
)
@@ -699,7 +824,7 @@ export default function Attendance() {
{vacationDaysRemaining}
</span>
<span className="attendance-balance-unit">
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{pluralizeDays(vacationDaysRemaining)}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span>
</div>
@@ -752,10 +877,11 @@ export default function Attendance() {
</div>
<div style={{ marginTop: "0.75rem" }}>
<div
className="text-secondary text-sm"
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8125rem",
marginBottom: "0.5rem",
}}
>
@@ -779,8 +905,8 @@ export default function Attendance() {
</div>
{data.monthly_fund.leave_hours > 0 && (
<div
className="text-muted text-xs"
style={{ marginTop: "0.375rem" }}
className="text-muted"
style={{ fontSize: "0.75rem", marginTop: "0.375rem" }}
>
{"Pokryto: "}
{data.monthly_fund.covered}h (práce{" "}
@@ -789,6 +915,8 @@ export default function Attendance() {
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.holiday_hours > 0 &&
` + svátek ${data.monthly_fund.holiday_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
@@ -873,7 +1001,7 @@ export default function Attendance() {
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{hasPermission("attendance.manage") && (
{hasPermission("attendance.admin") && (
<Link to="/attendance/admin" className="attendance-quick-link">
<svg
width="20"
@@ -932,7 +1060,7 @@ export default function Attendance() {
{/* Leave Modal */}
<AnimatePresence>
{isLeaveModalOpen && (
{showLeaveModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -942,7 +1070,7 @@ export default function Attendance() {
>
<div
className="admin-modal-backdrop"
onClick={() => setIsLeaveModalOpen(false)}
onClick={() => setShowLeaveModal(false)}
/>
<motion.div
className="admin-modal"
@@ -1025,15 +1153,15 @@ export default function Attendance() {
leaveForm.date_to,
)}
</strong>{" "}
{czechPlural(
calculateBusinessDays(
{(() => {
const d = calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
),
"pracovní den",
"pracovní dny",
"pracovních dnů",
)}
);
if (d === 1) return "pracovní den";
if (d >= 2 && d <= 4) return "pracovní dny";
return "pracovních dnů";
})()}
</span>
<span className="text-muted">
{calculateBusinessDays(
@@ -1063,7 +1191,7 @@ export default function Attendance() {
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setIsLeaveModalOpen(false)}
onClick={() => setShowLeaveModal(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
@@ -1090,13 +1218,13 @@ export default function Attendance() {
</AnimatePresence>
<ConfirmModal
isOpen={gpsConfirm.isOpen}
isOpen={gpsConfirm.show}
onClose={() => {
setGpsConfirm({ isOpen: false, action: null });
setGpsConfirm({ show: false, action: null });
setSubmitting(false);
}}
onConfirm={() => {
setGpsConfirm({ isOpen: false, action: null });
setGpsConfirm({ show: false, action: null });
submitPunch(gpsConfirm.action!, {});
}}
title="GPS nedostupná"

View File

@@ -10,7 +10,7 @@ import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
import { formatMinutes } from "../utils/attendanceHelpers";
interface UserTotalData {
name: string;
@@ -18,34 +18,20 @@ interface UserTotalData {
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
fund: number | null;
worked_hours: number;
covered: number;
missing: number;
overtime: number;
// mzda-style summary fields (set by computeUserTotals in useAttendanceAdmin)
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
worked_holiday_hours?: number;
}
function getFundBarBackground(data: UserTotalData) {
// fondUsed mirrors the Fond "used" line in the IIFE below:
// real_worked + vacation + worked_holiday + free_holiday
// (delta is fondUsed - fund; 0 means exactly at the fund).
const realWorked = data.worked_hours_raw ?? data.worked_hours;
const fondUsed =
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0);
const fundVal = data.fund ?? 0;
const delta = fondUsed - fundVal;
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
if (data.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (data.covered >= (data.fund ?? 0))
return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
}
@@ -99,9 +85,9 @@ export default function AttendanceAdmin() {
useModalLock(showEditModal);
useModalLock(showCreateModal);
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (!hasPermission("attendance.admin")) return <Forbidden />;
// Show spinner only on initial load (no data yet), not on filter changes
// Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad =
loading &&
data.records.length === 0 &&
@@ -109,8 +95,83 @@ export default function AttendanceAdmin() {
if (isInitialLoad) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div className="admin-skeleton-row">
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ flex: 1, borderRadius: "8px" }}
/>
</div>
</div>
</div>
<div className="admin-grid admin-grid-3">
{[0, 1, 2].map((i) => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
<div className="admin-skeleton-line w-1/2" />
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "4px" }}
/>
</div>
</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
);
}
@@ -220,15 +281,8 @@ export default function AttendanceAdmin() {
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData;
return (
<div key={uid} className="admin-card" style={{ display: "flex" }}>
<div
className="admin-card-body"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span
@@ -244,11 +298,9 @@ export default function AttendanceAdmin() {
<div
style={{
marginTop: "0.5rem",
marginBottom: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
minHeight: "2rem",
gap: "0.25rem",
}}
>
{ut.vacation_hours > 0 && (
@@ -261,9 +313,9 @@ export default function AttendanceAdmin() {
Nem: {ut.sick_hours}h
</span>
)}
{ut.svatek !== undefined && ut.svatek > 0 && (
{ut.holiday_hours > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(ut.svatek)}h
Sv: {ut.holiday_hours}h
</span>
)}
{ut.unpaid_hours > 0 && (
@@ -272,93 +324,62 @@ export default function AttendanceAdmin() {
</span>
)}
</div>
{ut.fund !== null &&
(() => {
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
// (everything the user actually got paid for this month:
// raw worked time with sub-day remainders, plus
// vacation days, plus hours worked on holidays, plus
// the 8h per unworked holiday from the fund).
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
const fondUsed =
realWorked +
(ut.vacation_hours ?? 0) +
(ut.worked_holiday_hours ?? 0) +
(ut.svatek ?? 0);
const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal;
return (
{ut.fund !== null && (
<div className="mt-2">
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {ut.worked_hours}h / {ut.fund}h
</span>
{ut.overtime > 0 && (
<span className="text-warning fw-600">
+{ut.overtime}h
</span>
)}
{ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">
-{ut.missing}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
className="kpi-card-footer"
style={{
marginTop: "auto",
paddingTop: "0.75rem",
borderTop: "1px solid var(--border-color)",
height: "100%",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
>
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</span>
{delta > 0 && (
<span className="text-warning fw-600">
+{formatHoursDecimal(delta * 60)}h
</span>
)}
{delta <= 0 && delta < 0 && (
<span className="text-danger fw-600">
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
) : (
<span style={{ visibility: "hidden" }}></span>
)}
</div>
/>
</div>
</div>
)}
{data.leave_balances[uid] && (
<div
className="text-secondary"
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }}
>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h
/ {data.leave_balances[uid].vacation_total}h
</div>
)}
</div>
</div>
);
@@ -384,7 +405,7 @@ export default function AttendanceAdmin() {
{/* Modals */}
<BulkAttendanceModal
isOpen={showBulkModal}
show={showBulkModal}
onClose={() => setShowBulkModal(false)}
form={bulkForm}
setForm={setBulkForm}
@@ -397,7 +418,7 @@ export default function AttendanceAdmin() {
<ShiftFormModal
mode="create"
isOpen={showCreateModal}
show={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateSubmit}
form={createForm}
@@ -412,7 +433,7 @@ export default function AttendanceAdmin() {
<ShiftFormModal
mode="edit"
isOpen={showEditModal && !!editingRecord}
show={showEditModal && !!editingRecord}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
form={editForm}
@@ -422,14 +443,7 @@ export default function AttendanceAdmin() {
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={
editingRecord
? {
user_name: editingRecord.user_name ?? "",
shift_date: editingRecord.shift_date,
}
: null
}
editingRecord={editingRecord}
/>
<ConfirmModal

View File

@@ -1,29 +1,79 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField";
import { useQuery } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
type BalanceEntry,
type BalancesData,
type FundData,
type FundUserData,
type MonthFundData,
type ProjectData,
type UserShort,
} from "../lib/queries/attendance";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface BalanceEntry {
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
interface UserShort {
id: number | string;
name: string;
}
interface FundUserData {
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
interface MonthFundData {
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users?: Record<string, FundUserData>;
}
interface ProjectUser {
user_id: number;
user_name: string;
hours: number;
}
interface ProjectEntry {
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: ProjectUser[];
}
interface MonthProjectData {
month_name: string;
projects: ProjectEntry[];
}
interface BalancesData {
users: UserShort[];
balances: Record<string, BalanceEntry>;
}
interface FundData {
months: Record<string, MonthFundData>;
holidays: unknown[];
users: UserShort[];
balances: Record<string, unknown>;
}
interface ProjectData {
months: Record<string, MonthProjectData>;
}
const getVacationClass = (remaining: number): string => {
if (remaining <= 0) return "text-danger";
if (remaining < 20) return "text-warning";
@@ -84,16 +134,23 @@ const getProgressBackground = (
export default function AttendanceBalances() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [year, setYear] = useState(new Date().getFullYear());
const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundData, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectData, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
const [data, setData] = useState<BalancesData>({
users: [],
balances: {},
});
const [fundLoading, setFundLoading] = useState(true);
const [fundData, setFundData] = useState<FundData>({
months: {},
holidays: [],
users: [],
balances: {},
});
const [projectLoading, setProjectLoading] = useState(true);
const [projectData, setProjectData] = useState<ProjectData>({ months: {} });
const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{
@@ -112,37 +169,70 @@ export default function AttendanceBalances() {
userName: string;
}>({ show: false, userId: null, userName: "" });
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances&year=${year}`,
);
const result = await response.json();
if (result.success) {
setData(result.data);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[year, alert],
);
const fetchFundData = useCallback(async () => {
setFundLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=workfund&year=${year}`,
);
const result = await response.json();
if (result.success) {
setFundData(result.data);
}
} catch {
// silent - fund data is supplementary
} finally {
setFundLoading(false);
}
}, [year]);
const fetchProjectData = useCallback(async () => {
setProjectLoading(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=project_report&year=${year}`,
);
const result = await response.json();
if (result.success) {
setProjectData(result.data);
}
} catch {
// silent - project data is supplementary
} finally {
setProjectLoading(false);
}
}, [year]);
useEffect(() => {
fetchData();
fetchFundData();
fetchProjectData();
}, [fetchData, fetchFundData, fetchProjectData]);
useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />;
const editMutation = useApiMutation<
{
user_id: string;
year: number;
action_type: "edit";
vacation_total: number;
vacation_used: number;
sick_used: number;
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const resetMutation = useApiMutation<
{ user_id: string; year: number; action_type: "reset" },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
onSuccess: (data) => {
setResetConfirm({ show: false, userId: null, userName: "" });
alert.success(data?.message || "Operace dokončena");
},
});
const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name });
setEditForm({
@@ -156,29 +246,63 @@ export default function AttendanceBalances() {
const handleEditSubmit = async () => {
if (!editingUser) return;
try {
const result = await editMutation.mutateAsync({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
});
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
}),
},
);
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
const handleReset = async () => {
if (!resetConfirm.userId) return;
try {
await resetMutation.mutateAsync({
user_id: resetConfirm.userId,
year,
action_type: "reset",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: resetConfirm.userId,
year,
action_type: "reset",
}),
},
);
const result = await response.json();
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" });
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
@@ -190,7 +314,7 @@ export default function AttendanceBalances() {
}
const getYearFundTotals = (userId: string) => {
if (!fundData?.months || Object.keys(fundData.months).length === 0)
if (!fundData.months || Object.keys(fundData.months).length === 0)
return null;
let totalFund = 0;
let totalWorked = 0;
@@ -255,137 +379,132 @@ export default function AttendanceBalances() {
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{balancesPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
{loading && (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && Object.keys(data.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{!loading && Object.keys(data.balances).length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Nárok (h)</th>
<th>Čerpáno (h)</th>
<th>Zbývá (h)</th>
<th>Nemoc (h)</th>
<th>Fond roku</th>
<th>Pokryto</th>
<th>+/</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Object.entries(data.balances).map(([userId, balance]) => {
const yf = getYearFundTotals(userId);
return (
<tr key={userId}>
<td className="fw-500">{balance.name}</td>
<td className="admin-mono">{balance.vacation_total}</td>
<td className="admin-mono">
{balance.vacation_used.toFixed(1)}
</td>
<td className="admin-mono">
<span
className={getVacationClass(
balance.vacation_remaining,
)}
>
{balance.vacation_remaining.toFixed(1)}
</span>
</td>
<td className="admin-mono">
{balance.sick_used.toFixed(1)}
</td>
<td className="admin-mono">
{yf ? `${yf.fund}h` : "—"}
</td>
<td className="admin-mono">
{yf ? `${yf.covered}h` : "—"}
</td>
<td className="admin-mono">
{yf ? renderFundDiff(yf) : "—"}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(userId, balance)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setResetConfirm({
show: true,
userId,
userName: balance.name,
})
}
className="admin-btn-icon danger"
title="Resetovat"
aria-label="Resetovat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<>
{balancesData &&
Object.keys(balancesData.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{balancesData &&
Object.keys(balancesData.balances).length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Nárok (h)</th>
<th>Čerpáno (h)</th>
<th>Zbývá (h)</th>
<th>Nemoc (h)</th>
<th>Fond roku</th>
<th>Pokryto</th>
<th>+/</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Object.entries(balancesData.balances).map(
([userId, balance]) => {
const yf = getYearFundTotals(userId);
return (
<tr key={userId}>
<td className="fw-500">{balance.name}</td>
<td className="admin-mono">
{balance.vacation_total}
</td>
<td className="admin-mono">
{balance.vacation_used.toFixed(1)}
</td>
<td className="admin-mono">
<span
className={getVacationClass(
balance.vacation_remaining,
)}
>
{balance.vacation_remaining.toFixed(1)}
</span>
</td>
<td className="admin-mono">
{balance.sick_used.toFixed(1)}
</td>
<td className="admin-mono">
{yf ? `${yf.fund}h` : "—"}
</td>
<td className="admin-mono">
{yf ? `${yf.covered}h` : "—"}
</td>
<td className="admin-mono">
{yf ? renderFundDiff(yf) : "—"}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() =>
openEditModal(userId, balance)
}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setResetConfirm({
show: true,
userId,
userName: balance.name,
})
}
className="admin-btn-icon danger"
title="Resetovat"
aria-label="Resetovat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</motion.div>
{/* Monthly Fund Overview */}
{!fundPending &&
fundData?.months &&
{!fundLoading &&
fundData.months &&
Object.keys(fundData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -467,7 +586,7 @@ export default function AttendanceBalances() {
gap: "0.375rem",
}}
>
{fundData?.users &&
{fundData.users &&
fundData.users.map((user) => {
const us = monthData.users?.[String(user.id)];
if (!us) return null;
@@ -548,15 +667,23 @@ export default function AttendanceBalances() {
</motion.div>
)}
{fundPending && (
<div className="admin-loading">
<div className="admin-spinner" />
{fundLoading && (
<div className="mt-6">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)}
{/* Monthly Project Overview */}
{!projectPending &&
projectData?.months &&
{!projectLoading &&
projectData.months &&
Object.keys(projectData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -748,83 +875,123 @@ export default function AttendanceBalances() {
</motion.div>
)}
{projectPending && (
<div className="admin-loading">
<div className="admin-spinner" />
{projectLoading && (
<div className="mt-6">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)}
{/* Edit Modal */}
<FormModal
isOpen={showEditModal && !!editingUser}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit dovolenou"
loading={editMutation.isPending}
>
{editingUser && (
<>
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
<AnimatePresence>
{showEditModal && editingUser && (
<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={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{editingUser.name}
</p>
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit dovolenou</h2>
<p className="text-secondary" style={{ marginTop: "0.25rem" }}>
{editingUser.name}
</p>
</div>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</FormModal>
</AnimatePresence>
{/* Reset Confirmation */}
<ConfirmModal
@@ -837,7 +1004,6 @@ export default function AttendanceBalances() {
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
loading={resetMutation.isPending}
/>
</div>
);

View File

@@ -1,6 +1,4 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { userListOptions } from "../lib/queries/users";
import { useState, useEffect } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -9,9 +7,14 @@ import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number | string;
name: string;
}
interface CreateForm {
user_id: string;
shift_date: string;
@@ -32,37 +35,47 @@ export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { data: usersData, isPending: loading } = useQuery(userListOptions());
const users = (usersData ?? []).map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
}));
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const createMutation = useApiMutation<CreateForm, { message?: string }>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "users"],
const today = new Date().toISOString().split("T")[0];
const [form, setForm] = useState<CreateForm>({
user_id: "",
shift_date: today,
leave_type: "work",
leave_hours: 8,
arrival_date: today,
arrival_time: "",
break_start_date: today,
break_start_time: "",
break_end_date: today,
break_end_time: "",
departure_date: today,
departure_time: "",
notes: "",
});
const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0];
return {
user_id: "",
shift_date: today,
leave_type: "work",
leave_hours: 8,
arrival_date: today,
arrival_time: "",
break_start_date: today,
break_start_time: "",
break_end_date: today,
break_end_time: "",
departure_date: today,
departure_time: "",
notes: "",
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users`);
const result = await response.json();
if (result.success) {
setUsers(
Array.isArray(result.data) ? result.data : result.data?.items || [],
);
}
} catch {
alert.error("Nepodařilo se načíst uživatele");
} finally {
setLoading(false);
}
};
});
fetchUsers();
}, [alert]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -75,11 +88,22 @@ export default function AttendanceCreate() {
setSubmitting(true);
try {
const result = await createMutation.mutateAsync(form);
alert.success(result?.message || "Uloženo");
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
alert.success(result.message);
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSubmitting(false);
}
@@ -98,12 +122,34 @@ export default function AttendanceCreate() {
const isWorkType = form.leave_type === "work";
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div>
<div className="admin-card" style={{ maxWidth: "600px" }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i}>
<div
className="admin-skeleton-line w-1/4"
style={{ marginBottom: "0.5rem", height: "10px" }}
/>
<div className="admin-skeleton-line w-full h-10" />
</div>
))}
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
</div>
</div>
);
}
@@ -177,6 +223,7 @@ export default function AttendanceCreate() {
<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>
</FormField>

View File

@@ -1,15 +1,9 @@
import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import {
attendanceHistoryOptions,
type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance";
import {
formatDate,
formatDatetime,
@@ -20,12 +14,35 @@ import {
getLeaveTypeBadgeClass,
calculateWorkMinutesPrint,
formatTimeOrDatetimePrint,
calculateFreeHolidayHours,
holidaysInMonth,
normalizeDateStr,
formatHoursDecimal,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
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;
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?: ProjectLog[];
}
const MONTH_NAMES = [
"Leden",
@@ -52,73 +69,6 @@ const formatBreakRange = (record: AttendanceRecord): string => {
return "—";
};
function getEasterSunday(year: number): string {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function getCzechHolidays(year: number): string[] {
const y = String(year);
const holidays = [
`${y}-01-01`,
`${y}-05-01`,
`${y}-05-08`,
`${y}-07-05`,
`${y}-07-06`,
`${y}-09-28`,
`${y}-10-28`,
`${y}-11-17`,
`${y}-12-24`,
`${y}-12-25`,
`${y}-12-26`,
];
const easterSunday = getEasterSunday(year);
const easterDate = new Date(easterSunday);
const goodFriday = new Date(easterDate);
goodFriday.setDate(goodFriday.getDate() - 2);
const easterMonday = new Date(easterDate);
easterMonday.setDate(easterMonday.getDate() + 1);
const pad = (n: number) => String(n).padStart(2, "0");
holidays.push(
`${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`,
);
holidays.push(
`${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`,
);
holidays.sort();
return holidays;
}
function getBusinessDaysInMonth(year: number, month: number): number {
const holidays = getCzechHolidays(year);
let count = 0;
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dow = date.getDay();
if (dow !== 0 && dow !== 6) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
if (!holidays.includes(dateStr)) {
count++;
}
}
}
return count;
}
const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
@@ -135,11 +85,8 @@ const renderProjectCell = (record: AttendanceRecord) => {
} else {
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.max(
0,
Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
),
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
);
h = Math.floor(mins / 60);
m = mins % 60;
@@ -176,19 +123,48 @@ const renderProjectCell = (record: AttendanceRecord) => {
};
export default function AttendanceHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name || "";
const [loading, setLoading] = useState(true);
const [companyName, setCompanyName] = useState("");
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = data ?? [];
const [records, setRecords] = useState<AttendanceRecord[]>([]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [yearStr, monthStr] = month.split("-");
const response = await apiFetch(
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRecords(result.data);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [month, alert, user?.id]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanyName(d.data.company_name || "");
})
.catch(() => {});
}, []);
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");
@@ -198,6 +174,7 @@ export default function AttendanceHistory() {
let totalMinutes = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0;
for (const record of records) {
@@ -205,55 +182,41 @@ export default function AttendanceHistory() {
if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record);
} else {
const hours =
record.leave_hours != null ? Number(record.leave_hours) : 8;
const hours = Number(record.leave_hours) || 8;
if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "holiday") holidayHours += hours;
else if (leaveType === "unpaid") unpaidHours += hours;
// "holiday" is no longer a user-selectable leave_type — it is
// auto-computed from Czech public holidays by the print/KPI code.
// Old records may still exist; skip so they don't double-count.
}
}
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
const businessDays = getBusinessDaysInMonth(yr, mo);
// Fund counts Mon-Fri + holidays (holidays are paid via the fond).
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
const fund = (businessDays + holidayCount) * 8;
const holidayDays = records.filter(
(r) => (r.leave_type || "work") === "holiday",
).length;
let businessDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) businessDays++;
cur.setDate(cur.getDate() + 1);
}
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
businessDays = Math.max(0, businessDays - holidayDays);
const fund = businessDays * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT unpaid — unpaid is voluntary).
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 100) / 100;
// fondUsed = real_worked + vacation + worked_holiday + free_holiday.
// (the all-in number: everything the user got paid for this month).
const realWorked = totalMinutes / 60;
const freeHolidayHours = calculateFreeHolidayHours(
records as unknown as Parameters<typeof calculateFreeHolidayHours>[0],
holidayDates,
);
const workedHolidayHours = records
.filter(
(r) =>
(r.leave_type || "work") === "work" &&
holidayDates.includes(normalizeDateStr(r.shift_date)),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
const fondUsed =
realWorked + vacationHours + workedHolidayHours + freeHolidayHours;
const delta = fondUsed - fund;
const missing = Math.max(0, Math.round(-delta * 100) / 100);
const overtime = Math.max(0, Math.round(delta * 100) / 100);
const remaining = missing;
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
const monthlyFund = {
fund,
business_days: businessDays,
holiday_count: holidayCount,
worked: Math.round(fondUsed * 100) / 100,
worked,
covered,
remaining,
overtime,
@@ -264,12 +227,9 @@ export default function AttendanceHistory() {
totalMinutes,
vacationHours,
sickHours,
holidayHours,
unpaidHours,
monthlyFund,
fondUsed,
freeHolidayHours,
workedHolidayHours,
delta,
};
}, [records, month]);
@@ -353,6 +313,7 @@ export default function AttendanceHistory() {
}
.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; }
@media print {
@@ -439,161 +400,143 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
{loading && (
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
<div className="admin-skeleton-row" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line"
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
flexShrink: 0,
}}
/>
<div className="flex-1">
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "6px", borderRadius: "3px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px", marginTop: "0.5rem" }}
/>
</div>
</div>
</div>
) : (
<>
{computed.monthlyFund && (
)}
{!loading && computed.monthlyFund && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
}}
>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
{computed.monthlyFund.holiday_count > 0 &&
` + ${computed.monthlyFund.holiday_count} svátky`}
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.delta > 0
? "linear-gradient(135deg, var(--warning), #d97706)"
: computed.delta >= 0
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginTop: "0.5rem",
minHeight: "1.5rem",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
}}
>
{computed.totalMinutes > 0 && (
<span
className="attendance-leave-badge"
title="Odpracováno (reálně)"
>
Práce: {formatHoursDecimal(computed.totalMinutes)}h
</span>
)}
{computed.vacationHours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {computed.vacationHours}h
</span>
)}
{computed.sickHours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {computed.sickHours}h
</span>
)}
{computed.freeHolidayHours > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(computed.freeHolidayHours)}h
</span>
)}
{computed.unpaidHours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {computed.unpaidHours}h
</span>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
className={
computed.delta > 0
? "text-warning fw-600"
: computed.delta < 0
? "text-danger fw-600"
: "text-success fw-600"
}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</span>
</div>
</div>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.monthlyFund.covered >=
computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
)}
{!computed.monthlyFund && (
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
display: "flex",
justifyContent: "space-between",
fontSize: "0.75rem",
marginTop: "0.375rem",
}}
>
Fond měsíce není k dispozici
<span>
{"Pokryto: "}
{computed.monthlyFund.covered}h (práce{" "}
{computed.monthlyFund.worked}h
{computed.vacationHours > 0 &&
` + dovolená ${computed.vacationHours}h`}
{computed.sickHours > 0 &&
` + nemoc ${computed.sickHours}h`}
{computed.holidayHours > 0 &&
` + svátek ${computed.holidayHours}h`}
{computed.unpaidHours > 0 &&
` + neplacené ${computed.unpaidHours}h`}
)
</span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
</div>
)}
</>
</div>
</div>
)}
{!loading && !computed.monthlyFund && (
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici
</div>
)}
</div>
</motion.div>
@@ -606,89 +549,90 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
{loading && (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
) : (
<>
{records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
)}
{!loading && records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{!loading && records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
@@ -737,7 +681,9 @@ export default function AttendanceHistory() {
</span>
</div>
{(computed.vacationHours > 0 || computed.sickHours > 0) && (
{(computed.vacationHours > 0 ||
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && (
<>
@@ -753,6 +699,13 @@ export default function AttendanceHistory() {
</span>{" "}
</>
)}
{computed.holidayHours > 0 && (
<>
<span className="leave-badge badge-holiday">
Svátek: {computed.holidayHours}h
</span>{" "}
</>
)}
</div>
)}

Some files were not shown because too many files have changed in this diff Show More