initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(find D:cortexboha-appresources -type f \\\\\\(-name *.jsx -o -name *.js -o -name *.tsx -o -name *.ts \\\\\\))",
"Bash(find D:cortexboha-appsrc -type f -name *.jsx -o -type f -name *.tsx)",
"Bash(npx tsc:*)",
"Bash(curl -s http://127.0.0.1:3000/api/health)",
"Bash(find /d/cortex/boha-app -type f -name *.php)",
"Bash(find D:/cortex/boha-app/app/Http/Controllers/Admin -type f -name *.php)"
]
}
}

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Database
DATABASE_URL=mysql://user:password@localhost:3306/app
# Server
PORT=3001
HOST=127.0.0.1
APP_ENV=local
# Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=generate-with-openssl-rand-hex-32
ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
# File storage
NAS_PATH=Z:/02_PROJEKTY
MAX_UPLOAD_SIZE=52428800
# Email
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
# CORS (production only, comma-separated)
CORS_ORIGINS=

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.log
dist-client/
*.css.map

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
# Production Readiness Audit — boha-app-ts
**Date:** 2026-03-23
**Status:** Approved
**Scope:** Code hardening, security, validation, architecture refactor, testing, build preparation
---
## Context
boha-app-ts is a TypeScript/Node.js migration of the PHP boha-app. Stack: Fastify backend, React frontend, Prisma ORM (MySQL), JWT + TOTP auth. The app will be deployed on an Ubuntu server alongside the existing PHP app, reusing the same MySQL database, nginx, and SSL setup.
This spec covers making the codebase production-ready. Deployment configuration (nginx, PM2, Ubuntu) is out of scope — this focuses purely on code quality, security, and build preparation.
## Implementation Order
Sections must be executed in this order due to dependencies:
1. **Security Hardening** — no dependencies, foundational
2. **Input Validation & TypeScript** — no dependencies on (1), can overlap
3. **Service Layer Refactor** — must complete before testing
4. **Testing** — depends on services existing (tests target service layer)
5. **Production Build Preparation** — last, especially migrations and graceful shutdown
---
## 1. Security Hardening
### 1.1 Secrets Management
- Verify `.env` is in `.gitignore` and was never committed to git history
- Create `.env.example` with placeholder values (no real secrets)
- Document that production requires new JWT_SECRET and TOTP_ENCRYPTION_KEY
- TOTP re-encryption script — see section 5.4 for implementation details
### 1.2 Rate Limiting
- Global: 100 requests/min per IP (unchanged — sufficient for internal admin app)
- Login endpoint (`POST /api/admin/login`): 20 requests/min per IP
- All other endpoints: inherit global limit
- Account lockout (5 failed attempts = 15 min lock) remains the primary brute-force defense
- Rate limiting protects infrastructure, lockout protects accounts
### 1.3 Security Headers
Add to existing security middleware:
- `Content-Security-Policy` — production only (dev mode needs relaxed policy for Vite HMR/eval)
- Production: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'`
- Dev: no CSP (Vite injects scripts dynamically)
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
- Existing headers unchanged: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, HSTS (prod)
### 1.4 Request Body Limits
- Global JSON body limit: 1MB via Fastify `bodyLimit`
- Auth endpoints (login, refresh, TOTP): 10KB
- Multipart (file uploads): 10MB (unchanged)
### 1.5 Timing-Safe Auth
- When user not found during login, still run `bcrypt.compare()` against a dummy hash
- Prevents timing-based username enumeration
---
## 2. Input Validation & TypeScript Strictness
### 2.1 Zod Validation
- Install `zod` as dependency
- Create validation schemas for all request bodies in `src/schemas/`
- Schemas organized by domain: `auth.schema.ts`, `users.schema.ts`, `offers.schema.ts`, etc.
- Replace all `request.body as Record<string, unknown>` with Zod parsing
- Validation errors return 400 with field-level messages in Czech
### 2.2 TypeScript Strictness
- Verify `strict: true` is already enabled in `tsconfig.server.json`
- Eliminate remaining `as Record<string, unknown>` casts — replaced by Zod-inferred types
- Remove any `@ts-ignore` or `any` usage
- This step is effectively part of the Zod migration (2.1)
### 2.3 Error Response Consistency
- All user-facing error messages in Czech
- Every route uses `error()` / `success()` helpers
- Proper HTTP status codes on all responses
---
## 3. Service Layer & Code Architecture
### 3.1 Service Extraction
Move business logic from route handlers into `src/services/`:
| Service | Responsibility |
|---------|---------------|
| `offers.service.ts` | CRUD, numbering, duplication |
| `orders.service.ts` | CRUD, numbering, project creation |
| `invoices.service.ts` | CRUD, stats, PDF data preparation |
| `projects.service.ts` | CRUD, notes, numbering |
| `users.service.ts` | CRUD, role assignment, password reset |
| `attendance.service.ts` | Clock in/out, shift management |
| `numbering.service.ts` | Shared number generation (orders + projects + offers) |
Existing services (`auth.ts`, `audit.ts`) remain unchanged — already well structured.
### 3.2 Route Handler Pattern
After refactor, routes follow this pattern:
```
parse & validate input (Zod) → call service → return response
```
No business logic in route files.
### 3.3 Number Generation Consolidation
- Move `generateSharedNumber()` from `orders.ts` into `numbering.service.ts`
- Move offer MAX-based numbering into same service
- Used by orders, projects, offers, and their next-number endpoints
- The `number_sequences` table stays in the database — only the code location changes from `src/utils/sequence.ts` to `src/services/numbering.service.ts`
---
## 4. Testing
### 4.1 Stack
- **Vitest** — test runner (compatible with existing Vite setup)
- **Supertest** — HTTP integration testing
- Real test database (no mocks)
### 4.2 Test Database Setup
- Separate `DATABASE_URL` via `.env.test` pointing to a dedicated test database
- Tests use transaction rollback for cleanup (each test runs in a transaction that rolls back)
- Seed script for baseline test data (admin user, roles, permissions)
### 4.3 Test Coverage
| Area | Tests |
|------|-------|
| Auth flow | Login, TOTP verify, backup codes, token refresh, rotation, logout, lockout |
| Permissions | Admin bypass, role-based access, forbidden responses |
| Number generation | Offer, order, project shared sequence correctness |
| CRUD | Create/read/update/delete for offers, orders, invoices |
| Edge cases | Expired tokens, invalid TOTP, duplicate usernames, password validation |
### 4.4 Not Testing
- Frontend React components
- Prisma query internals
- Simple list/get endpoints with no business logic
### 4.5 Structure
```
src/__tests__/
auth.test.ts
permissions.test.ts
offers.test.ts
orders.test.ts
invoices.test.ts
numbering.test.ts
```
---
## 5. Production Build Preparation
### 5.1 Graceful Shutdown
- Handle SIGTERM/SIGINT in server.ts
- Close Fastify server (drain in-flight requests)
- Disconnect Prisma client
- Log shutdown events
### 5.2 Prisma Migration Strategy
- Create baseline migration from existing schema using `prisma migrate diff`
- All future schema changes go through versioned migration files
- Never use `db push` in production
### 5.3 Environment Template
- Create `.env.example` documenting all required variables with placeholder values
- Mark which values must be regenerated for production (secrets)
- Mark which values are deployment-specific (HOST, PORT, CORS_ORIGINS, NAS_PATH)
### 5.4 TOTP Re-encryption Script
- Standalone script: `scripts/rotate-totp-key.ts`
- Reads old key and new key from arguments
- Decrypts all `users.totp_secret` with old key, re-encrypts with new key
- Transaction-safe (all or nothing)
- Dry-run mode for verification
### 5.5 Static File Serving
- Production serves `dist-client/` via `@fastify/static` (already a dependency)
- Dev mode uses Vite middleware (already implemented)
### 5.6 Cleanup
- Verify no unused dependencies in package.json
- Ensure `dist/` and `dist-client/` are in `.gitignore`
---
## Out of Scope
- Nginx configuration
- PM2 / process management setup
- Ubuntu server provisioning
- Frontend component refactoring
- Database data migration from PHP app

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="cs" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#12121a" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<title>BOHA | Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3753
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "boha-app-ts",
"version": "1.0.0",
"description": "",
"main": "dist/server.js",
"scripts": {
"dev:server": "tsx watch src/server.ts",
"dev:client": "vite",
"dev": "tsx watch src/server.ts",
"build:server": "tsc -p tsconfig.server.json",
"build:client": "vite build",
"build": "npm run build:server && npm run build:client",
"start": "node dist/server.js",
"preview": "vite preview",
"db:generate": "prisma generate",
"db:pull": "prisma db pull",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"framer-motion": "^12.38.0",
"hi-base32": "^0.5.1",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^8.0.2",
"otpauth": "^9.5.0",
"prisma": "^6.19.2",
"react": "^18.3.1",
"react-datepicker": "^9.1.0",
"react-dom": "^18.3.1",
"react-quill-new": "^3.8.3",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/mysql": "^2.15.27",
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^8.0.0"
}
}

612
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,612 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model attendance {
id Int @id @default(autoincrement())
user_id Int
shift_date DateTime @db.Date
arrival_time DateTime? @db.DateTime(0)
arrival_lat Decimal? @db.Decimal(10, 8)
arrival_lng Decimal? @db.Decimal(11, 8)
arrival_accuracy Decimal? @db.Decimal(10, 2)
arrival_address String? @db.VarChar(500)
break_start DateTime? @db.DateTime(0)
break_end DateTime? @db.DateTime(0)
departure_time DateTime? @db.DateTime(0)
departure_lat Decimal? @db.Decimal(10, 8)
departure_lng Decimal? @db.Decimal(11, 8)
departure_accuracy Decimal? @db.Decimal(10, 2)
departure_address String? @db.VarChar(500)
notes String? @db.Text
project_id Int?
leave_type attendance_leave_type? @default(work)
leave_hours Decimal? @db.Decimal(4, 2)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@index([project_id], map: "idx_project_id")
}
model attendance_project_logs {
id Int @id @default(autoincrement())
attendance_id Int
project_id Int
started_at DateTime? @db.DateTime(0)
ended_at DateTime? @db.DateTime(0)
hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
@@index([project_id], map: "idx_project_id")
@@index([started_at], map: "idx_started_at")
}
model audit_logs {
id Int @id @default(autoincrement())
user_id Int?
username String? @db.VarChar(100)
user_ip String? @db.VarChar(45)
action String @db.VarChar(100)
entity_type String? @db.VarChar(50)
entity_id Int?
description String? @db.Text
old_values String? @db.LongText
new_values String? @db.LongText
user_agent String? @db.Text
session_id String? @db.VarChar(128)
created_at DateTime? @default(now()) @db.Timestamp(0)
@@index([created_at], map: "idx_audit_log_created")
@@index([action], map: "idx_audit_logs_action")
@@index([created_at], map: "idx_audit_logs_created")
@@index([entity_type, entity_id, created_at], map: "idx_audit_logs_entity")
@@index([user_id, created_at], map: "idx_audit_logs_user_created")
@@fulltext([description], map: "idx_audit_search")
}
model bank_accounts {
id Int @id @default(autoincrement())
account_name String? @db.VarChar(255)
bank_name String? @db.VarChar(255)
account_number String? @db.VarChar(50)
iban String? @db.VarChar(50)
bic String? @db.VarChar(20)
currency String? @default("CZK") @db.VarChar(10)
is_default Boolean? @default(false)
position Int? @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
}
model company_settings {
id Int @id @default(autoincrement())
company_name String? @db.VarChar(255)
street String? @db.VarChar(255)
city String? @db.VarChar(255)
postal_code String? @db.VarChar(20)
country String? @db.VarChar(100)
company_id String? @db.VarChar(50)
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data 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? @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)
}
model customers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
street String? @db.VarChar(255)
city String? @db.VarChar(255)
postal_code String? @db.VarChar(20)
country String? @db.VarChar(100)
company_id String? @db.VarChar(50)
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
created_at DateTime? @default(now()) @db.DateTime(0)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
sync_version Int? @default(0)
invoices invoices[]
orders orders[]
projects projects[]
quotations quotations[]
}
model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
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)
position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@index([invoice_id], map: "invoice_id")
}
model invoices {
id Int @id @default(autoincrement())
invoice_number String? @db.VarChar(50)
order_id Int?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
payment_method String? @db.VarChar(50)
constant_symbol String? @db.VarChar(20)
bank_name String? @db.VarChar(255)
bank_swift String? @db.VarChar(20)
bank_iban String? @db.VarChar(50)
bank_account String? @db.VarChar(50)
issue_date DateTime? @db.Date
due_date DateTime? @db.Date
tax_date DateTime? @db.Date
paid_date DateTime? @db.Date
issued_by String? @db.VarChar(255)
notes String? @db.Text
internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[]
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_2")
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([order_id], map: "order_id")
}
model item_templates {
id Int @id @default(autoincrement())
name String? @db.VarChar(255)
description String? @db.Text
default_price Decimal? @default(0.00) @db.Decimal(12, 2)
category String? @db.VarChar(100)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
}
model leave_balances {
id Int @id @default(autoincrement())
user_id Int
year Int
vacation_total Decimal? @default(160.00) @db.Decimal(5, 2)
vacation_used Decimal? @default(0.00) @db.Decimal(5, 2)
sick_used Decimal? @default(0.00) @db.Decimal(5, 2)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "leave_balances_ibfk_1")
@@unique([user_id, year], map: "idx_leave_balances_user_year")
}
model leave_requests {
id Int @id @default(autoincrement())
user_id Int
leave_type leave_requests_leave_type
date_from DateTime @db.Date
date_to DateTime @db.Date
total_hours Decimal @db.Decimal(5, 2)
total_days Int
notes String? @db.Text
status leave_requests_status? @default(pending)
reviewer_id Int?
reviewer_note String? @db.Text
reviewed_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
users_leave_requests_user_idTousers users @relation("leave_requests_user_idTousers", fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "leave_requests_ibfk_1")
users_leave_requests_reviewer_idTousers users? @relation("leave_requests_reviewer_idTousers", fields: [reviewer_id], references: [id], onUpdate: NoAction, map: "leave_requests_ibfk_2")
@@index([user_id, status], map: "idx_leave_requests_user_status")
@@index([status], map: "idx_status")
@@index([reviewer_id], map: "reviewer_id")
}
model number_sequences {
id Int @id @default(autoincrement())
type String? @db.VarChar(50)
year Int?
last_number Int? @default(0)
}
model order_items {
id Int @id @default(autoincrement())
order_id Int
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
is_included_in_total Boolean? @default(true)
position Int? @default(0)
modified_at DateTime? @db.DateTime(0)
orders orders @relation(fields: [order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "order_items_ibfk_1")
@@index([order_id], map: "order_id")
}
model order_sections {
id Int @id @default(autoincrement())
order_id Int
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
position Int? @default(0)
modified_at DateTime? @db.DateTime(0)
orders orders @relation(fields: [order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "order_sections_ibfk_1")
@@index([order_id], map: "order_id")
}
model orders {
id Int @id @default(autoincrement())
order_number String? @db.VarChar(50)
customer_order_number String? @db.VarChar(100)
attachment_data Bytes?
attachment_name String? @db.VarChar(255)
quotation_id Int?
customer_id Int?
status String? @default("prijata") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10)
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)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoices invoices[]
order_items order_items[]
order_sections order_sections[]
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_2")
projects projects[]
@@index([customer_id], map: "customer_id")
@@index([quotation_id], map: "quotation_id")
}
model permissions {
id Int @id @default(autoincrement())
name String @unique(map: "name") @db.VarChar(50)
display_name String @db.VarChar(100)
description String? @db.Text
module String @db.VarChar(50)
created_at DateTime? @default(now()) @db.Timestamp(0)
role_permissions role_permissions[]
}
model project_notes {
id Int @id @default(autoincrement())
project_id Int
user_id Int?
user_name String? @db.VarChar(100)
content String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1")
@@index([project_id], map: "project_id")
}
model projects {
id Int @id @default(autoincrement())
project_number String? @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
quotation_id Int?
order_id Int?
status String? @default("aktivni") @db.VarChar(30)
start_date DateTime? @db.Date
end_date DateTime? @db.Date
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project_notes project_notes[]
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")
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_3")
@@index([customer_id], map: "customer_id")
@@index([responsible_user_id], map: "fk_projects_responsible_user")
@@index([order_id], map: "order_id")
@@index([quotation_id], map: "quotation_id")
}
model quotation_items {
id Int @id @default(autoincrement())
quotation_id Int
position Int? @default(0)
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
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")
}
model quotations {
id Int @id @default(autoincrement())
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
currency String? @default("CZK") @db.VarChar(10)
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
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
sync_version Int? @default(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "quotations_ibfk_1")
scope_sections scope_sections[]
@@index([customer_id], map: "customer_id")
@@index([quotation_number], map: "idx_quotations_number")
}
model received_invoices {
id Int @id @default(autoincrement()) @db.UnsignedInt
month Int @db.UnsignedTinyInt
year Int @db.UnsignedSmallInt
supplier_name String @db.VarChar(255)
invoice_number String? @db.VarChar(100)
description String? @db.VarChar(500)
amount Decimal @default(0.00) @db.Decimal(12, 2)
currency String @default("CZK") @db.VarChar(3)
vat_rate Decimal @default(21.00) @db.Decimal(5, 2)
vat_amount Decimal @default(0.00) @db.Decimal(12, 2)
issue_date DateTime? @db.Date
due_date DateTime? @db.Date
paid_date DateTime? @db.Date
status received_invoices_status @default(unpaid)
file_data Bytes? @db.MediumBlob
file_name String? @db.VarChar(255)
file_mime String? @db.VarChar(100)
file_size Int? @db.UnsignedInt
notes String? @db.Text
uploaded_by Int? @db.UnsignedInt
created_at DateTime @default(now()) @db.DateTime(0)
modified_at DateTime @default(now()) @db.DateTime(0)
@@index([year, month], map: "idx_month_year")
@@index([status], map: "idx_status")
@@index([supplier_name], map: "idx_supplier")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model refresh_tokens {
id Int @id @default(autoincrement()) @db.UnsignedInt
user_id Int @db.UnsignedInt
token_hash String @unique(map: "token_hash") @db.VarChar(64)
expires_at DateTime @db.DateTime(0)
replaced_at DateTime? @db.DateTime(0)
replaced_by_hash String? @db.VarChar(64)
remember_me Boolean @default(false)
ip_address String? @db.VarChar(45)
user_agent String? @db.VarChar(255)
created_at DateTime? @default(now()) @db.DateTime(0)
@@index([expires_at], map: "idx_refresh_tokens_expires_at")
@@index([user_id, expires_at], map: "idx_refresh_tokens_user_exp")
@@index([user_id], map: "idx_refresh_tokens_user_id")
}
model role_permissions {
role_id Int
permission_id Int
roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "role_permissions_ibfk_1")
permissions permissions @relation(fields: [permission_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "role_permissions_ibfk_2")
@@id([role_id, permission_id])
@@index([permission_id], map: "permission_id")
}
model roles {
id Int @id @default(autoincrement())
name String @unique(map: "name") @db.VarChar(50)
display_name String @db.VarChar(100)
description String? @db.Text
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
role_permissions role_permissions[]
users users[]
}
model scope_sections {
id Int @id @default(autoincrement())
quotation_id Int
position Int? @default(0)
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")
}
model scope_template_sections {
id Int @id @default(autoincrement())
scope_template_id Int
position Int? @default(0)
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
scope_templates scope_templates @relation(fields: [scope_template_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_template_sections_ibfk_1")
@@index([scope_template_id], map: "scope_template_id")
}
model scope_templates {
id Int @id @default(autoincrement())
name String? @db.VarChar(255)
title String? @db.VarChar(500)
description String? @db.Text
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
scope_template_sections scope_template_sections[]
}
model totp_login_tokens {
id Int @id @default(autoincrement())
user_id Int
token_hash String @db.VarChar(64)
expires_at DateTime @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@index([expires_at], map: "idx_expires")
@@index([token_hash], map: "idx_totp_login_tokens_hash")
@@index([user_id], map: "idx_user_id")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model trips {
id Int @id @default(autoincrement())
vehicle_id Int
user_id Int
trip_date DateTime @db.Date
start_km Int
end_km Int
distance Int?
route_from String @db.VarChar(100)
route_to String @db.VarChar(100)
is_business Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "trips_user_fk")
vehicles vehicles @relation(fields: [vehicle_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "trips_vehicle_fk")
@@index([trip_date], map: "trip_date")
@@index([user_id], map: "user_id")
@@index([vehicle_id], map: "vehicle_id")
}
model users {
id Int @id @default(autoincrement())
username String @unique(map: "username") @db.VarChar(50)
email String @unique(map: "email") @db.VarChar(255)
password_hash String @db.VarChar(255)
first_name String @db.VarChar(50)
last_name String @db.VarChar(50)
role_id Int?
is_active Boolean? @default(true)
last_login DateTime? @db.Timestamp(0)
failed_login_attempts Int? @default(0)
locked_until DateTime? @db.Timestamp(0)
password_changed_at DateTime? @default(now()) @db.Timestamp(0)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
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[]
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active")
@@index([role_id], map: "idx_users_role_id")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model vehicles {
id Int @id @default(autoincrement())
spz String @unique(map: "spz") @db.VarChar(20)
name String @db.VarChar(100)
brand String? @db.VarChar(50)
model String? @db.VarChar(50)
initial_km Int @default(0)
actual_km Int @default(0)
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
trips trips[]
}
enum leave_requests_leave_type {
vacation
sick
unpaid
}
enum leave_requests_status {
pending
approved
rejected
cancelled
}
enum received_invoices_status {
unpaid
paid
}
enum attendance_leave_type {
work
vacation
sick
holiday
unpaid
}

0
public/.gitkeep Normal file
View File

BIN
public/images/logo-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

27
src/App.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import AdminApp from './admin/AdminApp'
function AdminLoader() {
return (
<div style={{
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-primary)',
}}>
<div className="admin-spinner" />
</div>
)
}
export default function App() {
return (
<Suspense fallback={<AdminLoader />}>
<Routes>
<Route path="/*" element={<AdminApp />} />
</Routes>
</Suspense>
)
}

96
src/admin/AdminApp.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { AlertProvider } from './context/AlertContext'
import ErrorBoundary from './components/ErrorBoundary'
import AdminLayout from './components/AdminLayout'
import AlertContainer from './components/AlertContainer'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import './admin.css'
import './login.css'
import './dashboard.css'
import './attendance.css'
import './settings.css'
import './offers.css'
import './invoices.css'
const Users = lazy(() => import('./pages/Users'))
const Attendance = lazy(() => import('./pages/Attendance'))
const AttendanceHistory = lazy(() => import('./pages/AttendanceHistory'))
const AttendanceAdmin = lazy(() => import('./pages/AttendanceAdmin'))
const AttendanceBalances = lazy(() => import('./pages/AttendanceBalances'))
const AttendanceCreate = lazy(() => import('./pages/AttendanceCreate'))
const LeaveRequests = lazy(() => import('./pages/LeaveRequests'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
const AttendanceLocation = lazy(() => import('./pages/AttendanceLocation'))
const Trips = lazy(() => import('./pages/Trips'))
const TripsHistory = lazy(() => import('./pages/TripsHistory'))
const TripsAdmin = lazy(() => import('./pages/TripsAdmin'))
const Vehicles = lazy(() => import('./pages/Vehicles'))
const Offers = lazy(() => import('./pages/Offers'))
const OfferDetail = lazy(() => import('./pages/OfferDetail'))
const OffersCustomers = lazy(() => import('./pages/OffersCustomers'))
const OffersTemplates = lazy(() => import('./pages/OffersTemplates'))
const CompanySettings = lazy(() => import('./pages/CompanySettings'))
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 InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
const Settings = lazy(() => import('./pages/Settings'))
const AuditLog = lazy(() => import('./pages/AuditLog'))
const NotFound = lazy(() => import('./pages/NotFound'))
export default function AdminApp() {
return (
<AuthProvider>
<AlertProvider>
<AlertContainer />
<ErrorBoundary>
<Suspense fallback={<div className="admin-loading"><div className="admin-spinner" /></div>}>
<Routes>
<Route path="login" element={<Login />} />
<Route element={<AdminLayout />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
<Route path="attendance/history" element={<AttendanceHistory />} />
<Route path="attendance/admin" element={<AttendanceAdmin />} />
<Route path="attendance/balances" element={<AttendanceBalances />} />
<Route path="attendance/requests" element={<LeaveRequests />} />
<Route path="attendance/approval" element={<LeaveApproval />} />
<Route path="attendance/create" element={<AttendanceCreate />} />
<Route path="attendance/location/:id" element={<AttendanceLocation />} />
<Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} />
<Route path="vehicles" element={<Vehicles />} />
<Route path="offers" element={<Offers />} />
<Route path="offers/new" element={<OfferDetail />} />
<Route path="offers/:id" element={<OfferDetail />} />
<Route path="offers/customers" element={<OffersCustomers />} />
<Route path="offers/templates" element={<OffersTemplates />} />
<Route path="company/settings" element={<CompanySettings />} />
<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={<InvoiceCreate />} />
<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>
)
}

2860
src/admin/admin.css Normal file

File diff suppressed because it is too large Load Diff

434
src/admin/attendance.css Normal file
View File

@@ -0,0 +1,434 @@
/* ============================================================================
Attendance Module
============================================================================ */
/* Layout */
.attendance-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (min-width: 1024px) {
.attendance-layout {
flex-direction: row;
align-items: flex-start;
}
}
.attendance-main {
flex: 1;
min-width: 0;
}
.attendance-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 1024px) {
.attendance-sidebar {
width: 320px;
flex-shrink: 0;
}
}
/* Clock Card */
.attendance-clock-card {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 2rem;
}
.attendance-clock-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.attendance-clock-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 500;
color: var(--text-secondary);
}
.attendance-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.attendance-status-dot.active {
background: var(--success);
box-shadow: 0 0 8px color-mix(in srgb, var(--success) 50%, transparent);
animation: pulse 2s ease-in-out infinite;
}
.attendance-clock-time {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
}
/* Shift Info */
.attendance-shift-info {
margin-bottom: 2rem;
}
.attendance-shift-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 640px) {
.attendance-shift-row {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
.attendance-shift-item {
text-align: center;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.attendance-shift-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.attendance-shift-value {
display: block;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-secondary);
}
.attendance-shift-value.success {
color: var(--success);
}
/* Clock Actions */
.attendance-clock-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Notes */
.attendance-notes {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.attendance-notes-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
/* Project Section */
.attendance-project-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.attendance-project-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.attendance-project-header .attendance-shift-label {
margin-bottom: 0;
}
.attendance-project-section .admin-form-select {
margin-bottom: 0;
}
.attendance-project-logs {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.attendance-project-log-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
}
.attendance-project-log-name {
font-weight: 500;
color: var(--text-primary);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attendance-project-log-time {
color: var(--text-muted);
white-space: nowrap;
font-size: 0.75rem;
}
.attendance-project-log-duration {
color: var(--text-secondary);
font-weight: 600;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Balance Card */
.attendance-balance-card {
background: var(--gradient);
border-radius: var(--border-radius);
padding: 1.5rem;
color: #fff;
}
.attendance-balance-title {
font-size: 0.875rem;
font-weight: 500;
opacity: 0.9;
margin-bottom: 0.75rem;
color: inherit;
}
.attendance-balance-value {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 1rem;
}
.attendance-balance-number {
font-size: 3rem;
font-weight: 700;
line-height: 1;
color: inherit;
}
.attendance-balance-unit {
font-size: 1rem;
opacity: 0.9;
}
.attendance-balance-detail {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
opacity: 0.8;
margin-bottom: 0.75rem;
}
.attendance-balance-bar {
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
overflow: hidden;
}
.attendance-balance-progress {
height: 100%;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
transition: width 0.3s ease;
}
/* Quick Links */
.attendance-quick-links {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 1rem;
}
.attendance-quick-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.attendance-quick-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
margin: -0.25rem -0.5rem;
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition);
}
.attendance-quick-link:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.attendance-quick-link span {
flex: 1;
}
.attendance-quick-link svg:last-child {
opacity: 0.5;
}
/* Leave Type Badges */
.attendance-leave-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--border-radius-sm);
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.25rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.attendance-leave-badge.badge-vacation {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.attendance-leave-badge.badge-sick {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
.attendance-leave-badge.badge-holiday {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.attendance-leave-badge.badge-unpaid {
background: var(--muted-light);
color: var(--muted);
}
/* Working Status Badge */
.attendance-working-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
}
.attendance-working-badge.working {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.attendance-working-badge.finished {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* GPS Link */
.attendance-gps-link {
text-decoration: none;
font-size: 1rem;
}
.attendance-gps-link:hover {
transform: scale(1.1);
}
/* Location Page */
.attendance-location-map {
height: 400px;
border-radius: var(--border-radius-sm);
margin-bottom: 1.5rem;
background: var(--bg-secondary);
position: relative;
z-index: 0; /* stacking context - Leaflet z-indexy zustanou uvnitr */
}
.attendance-location-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.attendance-location-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 1rem;
}
.attendance-location-card.empty {
opacity: 0.6;
}
.attendance-location-title {
font-size: 1rem;
margin-bottom: 0.5rem;
color: var(--accent-color);
font-weight: 600;
}
.attendance-location-time {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.attendance-location-address {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.attendance-location-coords {
font-size: 0.8rem;
color: var(--text-muted);
font-family: monospace;
}

View File

@@ -0,0 +1,185 @@
import { forwardRef, useMemo } from 'react'
import DatePicker, { registerLocale } from 'react-datepicker'
import { cs } from 'date-fns/locale'
import { parse, format } from 'date-fns'
import 'react-datepicker/dist/react-datepicker.css'
registerLocale('cs', cs)
// Ensure portal root exists
if (typeof document !== 'undefined' && !document.getElementById('datepicker-portal')) {
const el = document.createElement('div')
el.id = 'datepicker-portal'
document.body.appendChild(el)
}
const isTouchDevice = () =>
typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
interface CustomInputProps {
value?: string
onClick?: () => void
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
required?: boolean
readOnly?: boolean
disabled?: boolean
}
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
({ value, onClick, onChange, placeholder, required, readOnly, disabled }, ref) => (
<input
className="admin-form-input"
onClick={onClick}
onChange={onChange}
value={value}
placeholder={placeholder}
ref={ref}
required={required}
readOnly={readOnly}
disabled={disabled}
autoComplete="off"
/>
)
)
interface NativeInputProps {
mode: string
value: string
onChange: (value: string) => void
required?: boolean
minDate?: string
maxDate?: string
disabled?: boolean
}
const modeToInputType: Record<string, string> = { month: 'month', time: 'time' }
function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabled }: NativeInputProps) {
const type = modeToInputType[mode] || 'date'
return (
<input
type={type}
lang="cs"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="admin-form-input"
required={required}
disabled={disabled}
min={minDate || undefined}
max={maxDate || undefined}
/>
)
}
interface AdminDatePickerProps {
mode?: 'date' | 'month' | 'datetime' | 'time'
value: string
onChange: (value: string) => void
minDate?: string
maxDate?: string
disabled?: boolean
placeholder?: string
required?: boolean
}
export default function AdminDatePicker({
mode = 'date',
value,
onChange,
required,
minDate,
maxDate,
disabled,
placeholder,
}: AdminDatePickerProps) {
const useNative = useMemo(() => isTouchDevice(), [])
if (useNative) {
return (
<NativeInput
mode={mode}
value={value}
onChange={onChange}
required={required}
minDate={minDate}
maxDate={maxDate}
disabled={disabled}
/>
)
}
const toDate = (val: string | null | undefined): Date | null => {
if (!val) return null
try {
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
if (mode === 'time') {
const [h, m] = val.split(':')
const d = new Date()
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0)
return d
}
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
} catch { return null }
return null
}
const handleChange = (date: Date | null) => {
if (!date) { onChange(''); return }
if (mode === 'date') onChange(format(date, 'yyyy-MM-dd'))
else if (mode === 'time') onChange(format(date, 'HH:mm'))
else if (mode === 'month') onChange(format(date, 'yyyy-MM'))
}
const parseMinMax = (val: string | undefined): Date | undefined => {
if (!val) return undefined
try {
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
} catch { return undefined }
return undefined
}
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: 'cs',
customInput: <CustomInput required={required} placeholder={placeholder} disabled={disabled} />,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: 'bottom-start' as const,
portalId: 'datepicker-portal',
disabled,
}
if (mode === 'time') {
return (
<DatePicker
{...commonProps}
showTimeSelect
showTimeSelectOnly
timeIntervals={5}
timeCaption="Čas"
dateFormat="HH:mm"
timeFormat="HH:mm"
/>
)
}
if (mode === 'month') {
return (
<DatePicker
{...commonProps}
showMonthYearPicker
dateFormat="MM/yyyy"
/>
)
}
return (
<DatePicker
{...commonProps}
dateFormat="dd.MM.yyyy"
/>
)
}

View File

@@ -0,0 +1,107 @@
import { useState, useCallback } from 'react'
import { Outlet, Navigate, useLocation } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../../context/ThemeContext'
import { setLogoutAlert } from '../utils/api'
import useModalLock from '../hooks/useModalLock'
import Sidebar from './Sidebar'
import ShortcutsHelp from './ShortcutsHelp'
export default function AdminLayout() {
const { isAuthenticated, loading, user, logout } = useAuth()
const { theme, toggleTheme } = useTheme()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [loggingOut, setLoggingOut] = useState(false)
const location = useLocation()
// Session is managed by AuthProvider (initial check + proactive refresh via setTimeout).
// Do not call checkSession on route changes — concurrent refresh calls with token rotation
// would invalidate each other and kick the user out.
const handleLogout = useCallback(() => {
setLoggingOut(true)
setSidebarOpen(false)
setLogoutAlert()
setTimeout(() => logout(), 400)
}, [logout])
useModalLock(sidebarOpen)
if (loading) {
return (
<div className="admin-layout">
<div className="admin-loading" style={{ width: '100%' }}>
<div className="admin-spinner" />
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
// If 2FA is required but user hasn't enabled it, redirect to dashboard (where setup lives)
const needs2FASetup = user?.require2FA && !user?.totpEnabled
if (needs2FASetup && location.pathname !== '/') {
return <Navigate to="/" replace />
}
return (
<motion.div
className="admin-layout"
initial={{ opacity: 0, scale: 0.98 }}
animate={loggingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
}
transition={{ duration: loggingOut ? 0.4 : 0.25, ease: [0.4, 0, 0.2, 1] }}
>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} onLogout={handleLogout} />
<div className="admin-main">
<header className="admin-header">
<button
onClick={() => setSidebarOpen(true)}
className="admin-menu-btn"
aria-label="Otevřít menu"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<div className="flex-1" />
<button
onClick={toggleTheme}
className="admin-header-theme-btn"
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
aria-label={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
>
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</span>
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
</button>
</header>
<main className="admin-content">
<Outlet />
</main>
</div>
<ShortcutsHelp />
</motion.div>
)
}

View File

@@ -0,0 +1,67 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAlertState } from '../context/AlertContext'
const icons: Record<string, React.ReactNode> = {
success: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
),
error: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
),
warning: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
),
info: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
),
}
export default function AlertContainer() {
const { alerts, removeAlert } = useAlertState()
return (
<div className="admin-alert-container" role="status" aria-live="polite">
<AnimatePresence>
{alerts.map(alert => (
<motion.div
key={alert.id}
className={`admin-toast admin-toast-${alert.type}`}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<span className="admin-toast-icon">{icons[alert.type]}</span>
<span className="admin-toast-message">{alert.message}</span>
<button
className="admin-toast-close"
onClick={() => removeAlert(alert.id)}
aria-label="Zavřít"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,181 @@
import { Link } from 'react-router-dom'
import {
formatDate, formatDatetime, formatTime,
calculateWorkMinutes, formatMinutes,
getLeaveTypeName, getLeaveTypeBadgeClass
} from '../utils/attendanceHelpers'
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 {
records: AttendanceRecord[]
onEdit: (record: AttendanceRecord) => void
onDelete: (record: AttendanceRecord) => void
}
function formatBreak(record: AttendanceRecord): string {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
}
return '\u2014'
}
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{record.project_logs.map((log, i) => {
let h: number, m: number, isActive = false
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0
m = parseInt(String(log.minutes)) || 0
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' \u25B8' : ''})
</span>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '\u2014'
}
export default function AttendanceShiftTable({ records, onEdit, onDelete }: AttendanceShiftTableProps) {
if (records.length === 0) {
return (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)
}
return (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Zam\u011Bstnanec</th>
<th>Typ</th>
<th>P\u0159\u00EDchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Pozn\u00E1mka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record)
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>{record.user_name}</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono">
{isLeave ? '\u2014' : formatBreak(record)}
</td>
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '\u2014'}</td>
<td>
{renderProjectCell(record)}
</td>
<td>
{hasLocation ? (
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
{'\uD83D\uDCCD'}
</Link>
) : '\u2014'}
</td>
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
{record.notes || ''}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => onEdit(record)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => onDelete(record)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,192 @@
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from './AdminDatePicker'
import useModalLock from '../hooks/useModalLock'
interface BulkAttendanceForm {
month: string
user_ids: string[]
arrival_time: string
departure_time: string
break_start_time: string
break_end_time: string
}
interface BulkAttendanceUser {
id: number | string
name: string
}
interface BulkAttendanceModalProps {
show: boolean
onClose: () => void
form: BulkAttendanceForm
setForm: (form: BulkAttendanceForm) => void
users: BulkAttendanceUser[]
onSubmit: () => void
submitting: boolean
toggleUser: (userId: number | string) => void
toggleAllUsers: () => void
}
export default function BulkAttendanceModal({
show,
onClose,
form,
setForm,
users,
onSubmit,
submitting,
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(show)
return (
<AnimatePresence>
{show && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => !submitting && onClose()} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Existující záznamy se přeskočí.
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
value={form.month}
onChange={(val) => setForm({ ...form, month: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Zaměstnanci
<button
type="button"
onClick={toggleAllUsers}
style={{
marginLeft: '0.75rem',
background: 'none',
border: 'none',
color: 'var(--accent-color)',
cursor: 'pointer',
fontSize: '0.8125rem',
fontWeight: 500,
padding: 0,
}}
>
{form.user_ids.length === users.length ? 'Odznačit vše' : 'Vybrat vše'}
</button>
</label>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
maxHeight: '200px',
overflowY: 'auto',
padding: '0.75rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--border-radius-sm)',
border: '1px solid var(--border-color)',
}}
>
{users.map((user) => (
<label key={user.id} className="admin-form-checkbox">
<input
type="checkbox"
checked={form.user_ids.includes(String(user.id))}
onChange={() => toggleUser(user.id)}
/>
<span>{user.name}</span>
</label>
))}
</div>
<small className="admin-form-hint">
Vybráno: {form.user_ids.length} z {users.length}
</small>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Příchod</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) => setForm({ ...form, arrival_time: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => setForm({ ...form, departure_time: val })}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Začátek pauzy</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) => setForm({ ...form, break_start_time: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) => setForm({ ...form, break_end_time: val })}
/>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0}
>
{submitting ? 'Vytvářím záznamy...' : 'Vyplnit měsíc'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,52 @@
import type { ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface ConfirmModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: ReactNode
confirmText?: string
cancelText?: string
type?: 'danger' | 'warning' | 'default' | 'info'
confirmVariant?: 'danger' | 'primary'
loading?: boolean
}
export default function ConfirmModal({ isOpen, onClose, onConfirm, title, message, confirmText = 'Potvrdit', cancelText = 'Zrušit', type = 'default', confirmVariant, loading }: ConfirmModalProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-confirm-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<h2 className="admin-confirm-title">{title}</h2>
<p className="admin-confirm-message">{message}</p>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={onClose} className="admin-btn admin-btn-secondary" disabled={loading}>{cancelText}</button>
<button type="button" onClick={onConfirm} className={`admin-btn ${(confirmVariant === 'danger' || type === 'danger') ? 'admin-btn-danger' : 'admin-btn-primary'}`} disabled={loading}>
{loading ? 'Zpracování...' : confirmText}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,29 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'
interface Props { children: ReactNode }
interface State { hasError: boolean; error: Error | null }
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info)
}
render() {
if (this.state.hasError) {
return (
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
<h2>Něco se pokazilo</h2>
<p>{this.state.error?.message}</p>
<button className="admin-btn admin-btn-primary" onClick={() => window.location.reload()}>Obnovit stránku</button>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,11 @@
import { Link } from 'react-router-dom'
export default function Forbidden() {
return (
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
<h2>403</h2>
<p>Nemáte oprávnění pro přístup k této stránce.</p>
<Link to="/" className="admin-btn admin-btn-primary">Zpět na Dashboard</Link>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import type { CSSProperties, ReactNode } from 'react'
interface FormFieldProps {
label: ReactNode
children: ReactNode
error?: string
required?: boolean
style?: React.CSSProperties
}
export default function FormField({ label, children, error, required, style }: FormFieldProps) {
return (
<div className="admin-form-group" style={style}>
<label className="admin-form-label">
{label}
{required && <span className="admin-form-required"> *</span>}
</label>
{children}
{error && <span className="admin-form-error">{error}</span>}
</div>
)
}

View File

@@ -0,0 +1,62 @@
interface PaginationProps {
pagination: {
total: number
page: number
per_page: number
total_pages: number
} | null
onPageChange: (page: number) => void
onPerPageChange?: (perPage: number) => void
}
export default function Pagination({ pagination, onPageChange, onPerPageChange }: PaginationProps) {
if (!pagination || pagination.total_pages <= 1) return null
const { page, total_pages } = pagination
const getPages = () => {
const pages: (number | string)[] = []
const delta = 2
for (let i = 1; i <= total_pages; i++) {
if (i === 1 || i === total_pages || (i >= page - delta && i <= page + delta)) {
pages.push(i)
} else if (pages[pages.length - 1] !== '...') {
pages.push('...')
}
}
return pages
}
return (
<div className="admin-pagination">
<div className="admin-pagination-pages">
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} className="admin-pagination-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 18l-6-6 6-6" /></svg>
</button>
{getPages().map((p, i) =>
typeof p === 'string' ? (
<span key={`dots-${i}`} className="admin-pagination-dots">...</span>
) : (
<button key={p} onClick={() => onPageChange(p)} className={`admin-pagination-btn ${p === page ? 'active' : ''}`}>
{p}
</button>
)
)}
<button disabled={page >= total_pages} onClick={() => onPageChange(page + 1)} className="admin-pagination-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6" /></svg>
</button>
</div>
{onPerPageChange && (
<select
value={pagination.per_page}
onChange={e => onPerPageChange(Number(e.target.value))}
className="admin-form-select admin-pagination-select"
>
{[10, 25, 50, 100].map(n => (
<option key={n} value={n}>{n} / stránka</option>
))}
</select>
)}
</div>
)
}

View File

@@ -0,0 +1,105 @@
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', '#333333', '#555555', '#777777', '#999999', '#bbbbbb', '#dddddd', '#ffffff',
'#de3a3a', '#e57373', '#c62828',
'#1565c0', '#42a5f5', '#0d47a1',
'#2e7d32', '#66bb6a', '#1b5e20',
'#f57f17', '#ffca28', '#e65100',
'#6a1b9a', '#ab47bc', '#4a148c',
'#00695c', '#26a69a', '#004d40',
'#37474f', '#78909c', '#263238',
]
const TOOLBAR = [
[{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }],
['bold', 'italic', 'underline', 'strike'],
[{ color: COLORS }, { background: COLORS }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ align: [] }],
['link'],
['clean']
]
const FORMATS = [
'font', 'size',
'bold', 'italic', 'underline', 'strike',
'color', 'background',
'list', 'indent', 'align',
'link'
]
interface RichEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
minHeight?: string
}
export default function RichEditor({
value,
onChange,
placeholder = 'Obsah...',
minHeight = '120px'
}: RichEditorProps) {
const quillRef = useRef<ReactQuill>(null)
const lastValueRef = useRef(value)
const modules = useMemo(() => ({
toolbar: TOOLBAR,
clipboard: {
matchVisual: false,
},
}), [])
const handleChange = useCallback((content: string, _delta: any, source: string) => {
if (source !== 'user') return
if (content === lastValueRef.current) return
lastValueRef.current = content
onChange(content)
}, [onChange])
return (
<div className="rich-editor" style={{ '--re-min-height': minHeight } as React.CSSProperties}>
<ReactQuill
ref={quillRef}
theme="snow"
value={value || ''}
onChange={handleChange}
modules={modules}
formats={FORMATS}
placeholder={placeholder}
/>
</div>
)
}

View File

@@ -0,0 +1,521 @@
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from './AdminDatePicker'
import useModalLock from '../hooks/useModalLock'
import { calcFormWorkMinutes, calcProjectMinutesTotal, formatDate } from '../utils/attendanceHelpers'
let _logKeyCounter = 0
// ---------- Shared types ----------
export interface ShiftFormData {
user_id: string
shift_date: string
leave_type: string
leave_hours: number
arrival_date: string
arrival_time: string
break_start_date: string
break_start_time: string
break_end_date: string
break_end_time: string
departure_date: string
departure_time: string
notes: string
}
export interface ProjectLog {
_key?: string
id?: number
project_id: string | number
hours: string | number
minutes: string | number
}
export interface Project {
id: number | string
project_number: string
name: string
}
export interface User {
id: number | string
name: string
}
export interface EditingRecord {
user_name: string
shift_date: string
}
// ---------- Sub-component props ----------
interface ProjectTimeStatusProps {
form: ShiftFormData
projectLogs: ProjectLog[]
}
interface ProjectLogRowProps {
log: ProjectLog
index: number
projectList: Project[]
onUpdate: (index: number, field: string, value: string) => void
onRemove: (index: number) => void
}
export interface ShiftFormModalProps {
mode: 'create' | 'edit'
show: boolean
onClose: () => void
onSubmit: () => void
form: ShiftFormData
setForm: (form: ShiftFormData) => void
projectLogs: ProjectLog[]
setProjectLogs: (logs: ProjectLog[]) => void
projectList: Project[]
users: User[]
onShiftDateChange: (value: string) => void
editingRecord: EditingRecord | null
}
// ---------- ProjectTimeStatus ----------
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form)
const totalProject = calcProjectMinutesTotal(projectLogs)
const remaining = totalWork - totalProject
const hasLogs = projectLogs.some((l) => l.project_id)
if (!hasLogs || totalWork <= 0) return null
const isMatch = remaining === 0
return (
<div
style={{
padding: '0.5rem 0.75rem',
marginBottom: '0.5rem',
borderRadius: '6px',
fontSize: '0.8rem',
background: isMatch
? 'var(--success-bg, rgba(34,197,94,0.1))'
: 'var(--danger-bg, rgba(239,68,68,0.1))',
color: isMatch
? 'var(--success-color, #16a34a)'
: 'var(--danger-color, #dc2626)',
border: `1px solid ${
isMatch
? 'var(--success-border, rgba(34,197,94,0.3))'
: 'var(--danger-border, rgba(239,68,68,0.3))'
}`,
}}
>
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{' '}
{Math.abs(remaining) % 60}m {remaining < 0 ? '(překročeno)' : ''}
</div>
)
}
// ---------- ProjectLogRow ----------
function ProjectLogRow({
log,
index,
projectList,
onUpdate,
onRemove,
}: ProjectLogRowProps) {
return (
<div className="flex-row gap-2 mb-2">
<select
value={log.project_id}
onChange={(e) => onUpdate(index, 'project_id', e.target.value)}
className="admin-form-select"
style={{ flex: 3, marginBottom: 0 }}
>
<option value=""> Projekt </option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
<input
type="number"
min="0"
max="24"
value={log.hours}
onChange={(e) => onUpdate(index, 'hours', e.target.value)}
className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
placeholder="h"
/>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
h
</span>
<input
type="number"
min="0"
max="59"
value={log.minutes}
onChange={(e) => onUpdate(index, 'minutes', e.target.value)}
className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
placeholder="m"
/>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
m
</span>
<button
type="button"
onClick={() => onRemove(index)}
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ padding: '0.375rem', flexShrink: 0 }}
title="Odebrat"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)
}
// ---------- ShiftFormModal ----------
export default function ShiftFormModal({
mode,
show,
onClose,
onSubmit,
form,
setForm,
projectLogs,
setProjectLogs,
projectList,
users,
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(show)
const isCreate = mode === 'create'
const isWorkType = form.leave_type === 'work'
const updateField = (field: keyof ShiftFormData, value: string | number) => {
setForm({ ...form, [field]: value })
}
const updateProjectLog = (index: number, field: string, value: string) => {
const updated = [...projectLogs]
updated[index] = { ...updated[index], [field]: value }
setProjectLogs(updated)
}
const removeProjectLog = (index: number) => {
setProjectLogs(projectLogs.filter((_, j) => j !== index))
}
const addProjectLog = () => {
setProjectLogs([
...projectLogs,
{ _key: `log-${++_logKeyCounter}`, project_id: '', hours: '', minutes: '' },
])
}
return (
<AnimatePresence>
{show && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{isCreate ? 'Přidat záznam docházky' : 'Upravit docházku'}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: 'var(--text-secondary)',
marginTop: '0.25rem',
}}
>
{editingRecord.user_name} {' '}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<div className="admin-form">
{isCreate ? (
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label required">
Zaměstnanec
</label>
<select
value={form.user_id}
onChange={(e) =>
updateField('user_id', e.target.value)
}
className="admin-form-select"
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">
Datum směny
</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => onShiftDateChange(val)}
/>
</div>
</div>
) : (
<div className="admin-form-group">
<label className="admin-form-label">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => updateField('shift_date', val)}
/>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Typ záznamu</label>
<select
value={form.leave_type}
onChange={(e) =>
updateField('leave_type', e.target.value)
}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>
{!isWorkType && (
<div className="admin-form-group">
<label className="admin-form-label">Počet hodin</label>
<input
type="number"
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField('leave_hours', parseFloat(e.target.value))
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">
8 hodin = celý den
</small>
)}
</div>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val) =>
updateField('arrival_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - čas
</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) =>
updateField('arrival_time', val)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val) =>
updateField('break_start_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - čas
</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) =>
updateField('break_start_time', val)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val) =>
updateField('break_end_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - čas
</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) =>
updateField('break_end_time', val)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val) =>
updateField('departure_date', val)
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - čas
</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) =>
updateField('departure_time', val)
}
/>
</div>
</div>
</>
)}
{isWorkType && projectList.length > 0 && (
<div className="admin-form-group">
<label className="admin-form-label">Projekty</label>
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
key={log._key || i}
log={log}
index={i}
projectList={projectList}
onUpdate={updateProjectLog}
onRemove={removeProjectLog}
/>
))}
<button
type="button"
onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat projekt
</button>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => updateField('notes', e.target.value)}
className="admin-form-textarea"
rows={3}
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,3 @@
export default function ShortcutsHelp() {
return null
}

View File

@@ -0,0 +1,419 @@
import { type ReactNode } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../../context/ThemeContext'
interface MenuItem {
path: string
label: string
end?: boolean
permission?: string | string[]
matchPrefix?: string
matchAlso?: string[]
matchExclude?: string[]
icon: ReactNode
}
interface MenuSection {
label: string
items: MenuItem[]
}
const menuSections: MenuSection[] = [
{
label: 'Přehled',
items: [
{
path: '/',
label: 'Přehled',
end: true,
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="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
)
}
]
},
{
label: 'Docházka',
items: [
{
path: '/attendance',
label: 'Záznam',
permission: 'attendance.record',
end: true,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15 15" />
</svg>
)
},
{
path: '/attendance/history',
label: 'Moje historie',
permission: 'attendance.history',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
)
},
{
path: '/attendance/requests',
label: 'Žádosti',
permission: 'attendance.record',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
)
},
{
path: '/attendance/approval',
label: 'Schvalování',
permission: 'attendance.approve',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
)
},
{
path: '/attendance/admin',
label: 'Správa',
permission: 'attendance.admin',
matchPrefix: '/attendance/admin',
matchAlso: ['/attendance/create', '/attendance/location'],
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: '/attendance/balances',
label: 'Správa bilancí',
permission: 'attendance.balances',
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>
)
}
]
},
{
label: 'Kniha jízd',
items: [
{
path: '/trips',
label: 'Záznam',
permission: 'trips.record',
end: true,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="5" cy="18" r="3" /><circle cx="19" cy="18" r="3" />
<path d="M5 18V12L8 5h8l3 7v6" /><path d="M10 18h4" />
</svg>
)
},
{
path: '/trips/history',
label: 'Moje historie',
permission: 'trips.history',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
)
},
{
path: '/trips/admin',
label: 'Správa',
permission: 'trips.admin',
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: '/vehicles',
label: 'Vozidla',
permission: 'trips.vehicles',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="1" y="3" width="15" height="13" rx="2" />
<path d="M16 8h4l3 3v5h-7V8z" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
)
}
]
},
{
label: 'Administrativa',
items: [
{
path: '/offers',
label: 'Nabídky',
permission: 'offers.view',
matchPrefix: '/offers',
matchExclude: ['/offers/customers', '/offers/templates'],
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)
},
{
path: '/orders',
label: 'Objednávky',
permission: 'orders.view',
matchPrefix: '/orders',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
)
},
{
path: '/invoices',
label: 'Faktury',
permission: 'invoices.view',
matchPrefix: '/invoices',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
)
},
{
path: '/projects',
label: 'Projekty',
permission: 'projects.view',
matchPrefix: '/projects',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
},
{
path: '/offers/customers',
label: 'Zákazníci',
permission: 'offers.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>
)
},
{
path: '/company/settings',
label: 'Firma',
permission: 'offers.settings',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
)
}
]
},
{
label: 'Systém',
items: [
{
path: '/users',
label: 'Uživatelé',
permission: 'users.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>
)
},
{
path: '/settings',
label: 'Nastavení',
permission: ['settings.roles', 'settings.security'],
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
},
{
path: '/audit-log',
label: 'Audit log',
permission: 'settings.audit',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
}
]
}
]
interface SidebarProps {
isOpen: boolean
onClose: () => void
onLogout: () => void
}
export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
const { user, hasPermission } = useAuth()
const { theme } = useTheme()
const location = useLocation()
const isItemActive = (item: MenuItem) => {
if (item.matchPrefix) {
let active = location.pathname.startsWith(item.matchPrefix)
if (active && item.matchExclude) {
active = !item.matchExclude.some(ex => location.pathname.startsWith(ex))
}
return active
}
if (item.end) {
return location.pathname === item.path
}
return location.pathname.startsWith(item.path)
}
const hasItemPermission = (item: MenuItem) => {
if (!item.permission) {
return true
}
if (Array.isArray(item.permission)) {
return item.permission.some(p => hasPermission(p))
}
return hasPermission(item.permission)
}
const visibleSections = menuSections
.map(section => ({
...section,
items: section.items.filter(hasItemPermission)
}))
.filter(section => section.items.length > 0)
return (
<>
<div
className={`admin-sidebar-overlay${isOpen ? ' open' : ''}`}
onClick={onClose}
/>
<aside className={`admin-sidebar${isOpen ? ' open' : ''}`}>
<div className="admin-sidebar-header">
<img
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
alt="Logo"
className="admin-sidebar-logo"
/>
<button onClick={onClose} className="admin-sidebar-close" aria-label="Zavřít menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<nav className="admin-sidebar-nav">
{visibleSections.map((section) => (
<div key={section.label} className="admin-nav-section">
<div className="admin-nav-label">{section.label}</div>
{section.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.end}
onClick={onClose}
className={() => {
let active = isItemActive(item)
if (!active && item.matchAlso) {
active = item.matchAlso.some(p => location.pathname.startsWith(p))
}
return `admin-nav-item${active ? ' active' : ''}`
}}
>
{item.icon}
{item.label}
</NavLink>
))}
</div>
))}
</nav>
<div className="admin-sidebar-footer">
<div className="admin-user-chip">
<div className="admin-user-avatar">
{user?.fullName?.charAt(0) || user?.username?.charAt(0) || 'U'}
</div>
<div className="admin-user-details">
<div className="admin-user-name">
{user?.fullName || user?.username}
</div>
<div className="admin-user-role">
{user?.roleDisplay}
</div>
</div>
</div>
<button onClick={onLogout} className="admin-logout-btn" aria-label="Odhlásit se">
<svg 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>
Odhlásit se
</button>
</div>
</aside>
</>
)
}

View File

@@ -0,0 +1,20 @@
interface SortIconProps {
column: string
sort: string
order: string
}
export default function SortIcon({ column, sort, order }: SortIconProps) {
if (sort !== column) {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.3, marginLeft: 4 }}>
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
</svg>
)
}
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 4 }}>
{order === 'asc' ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
</svg>
)
}

View File

@@ -0,0 +1,80 @@
import { Link } from 'react-router-dom'
import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers'
interface Activity {
id: number | string
action: string
description: string
username?: string
entity_type: string
created_at: string
}
interface DashActivityFeedProps {
activities: Activity[] | null
}
function getActivityIcon(action: string) {
switch (action) {
case 'create':
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
case 'update':
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
case 'delete':
return (
<svg width="15" height="15" 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>
)
case 'login':
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
)
default:
return (
<svg width="15" height="15" 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 DashActivityFeed({ activities }: DashActivityFeedProps) {
if (!activities) {
return null
}
return (
<div className="admin-card dash-activity-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Audit log</h2>
<Link to="/audit-log" className="admin-btn admin-btn-primary admin-btn-sm">Detail &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{activities.map((act) => (
<div key={act.id} className="dash-activity-row">
<div className={`dash-activity-icon ${getActivityIconClass(act.action)}`}>
{getActivityIcon(act.action)}
</div>
<div className="dash-activity-main">
<div className="dash-activity-text">{act.description}</div>
<div className="dash-activity-sub">{act.username || 'Systém'} · {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}</div>
</div>
<div className="dash-activity-time admin-mono">{formatActivityTime(act.created_at)}</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { Link } from 'react-router-dom'
import { LEAVE_TYPE_LABELS, STATUS_DOT_CLASS, STATUS_LABELS } from '../../utils/dashboardHelpers'
interface AttendanceUser {
user_id: number | string
name: string
initials?: string
status: string
leave_type?: string
arrived_at?: string
}
interface AttendanceData {
users: AttendanceUser[]
}
interface DashAttendanceTodayProps {
attendance: AttendanceData | null
}
export default function DashAttendanceToday({ attendance }: DashAttendanceTodayProps) {
if (!attendance) {
return null
}
return (
<div className="admin-card dash-attendance-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Docházka dnes</h2>
<Link to="/attendance/admin" className="admin-btn admin-btn-primary admin-btn-sm">Detail &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{attendance.users.map((u, i) => (
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
<div className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}>
{u.initials || '?'}
</div>
<div className="dash-presence-name">{u.name}</div>
<div className="dash-presence-end">
<span className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}>
{u.status === 'leave' ? (LEAVE_TYPE_LABELS[u.leave_type || ''] || 'Nepřítomen') : STATUS_LABELS[u.status]}
</span>
{u.arrived_at && <span className="admin-mono dash-presence-time">{u.arrived_at}</span>}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { motion } from 'framer-motion'
import { formatCurrency } from '../../utils/formatters'
interface KpiCard {
label: string
value: string
sub?: string
color: string
footer: string | null
}
interface RevenueItem {
amount: number
currency: string
}
interface InvoicesData {
revenue_this_month: RevenueItem[]
revenue_czk?: number | null
unpaid_count: number
}
interface DashData {
attendance?: {
present_today: number
total_active: number
on_leave: number
}
offers?: {
open_count: number
created_this_month: number
}
invoices?: InvoicesData
leave_pending?: {
count: number
}
}
interface DashKpiCardsProps {
dashData: DashData | null
}
function buildKpiCards(dashData: DashData | null): KpiCard[] {
const cards: KpiCard[] = []
if (dashData?.attendance) {
cards.push({
label: 'Přítomní dnes',
value: `${dashData.attendance.present_today}`,
sub: `/ ${dashData.attendance.total_active}`,
color: 'success',
footer: dashData.attendance.on_leave > 0 ? `${dashData.attendance.on_leave} nepřítomných` : null,
})
}
if (dashData?.offers) {
cards.push({
label: 'Otevřené nabídky',
value: `${dashData.offers.open_count}`,
color: 'info',
footer: dashData.offers.created_this_month > 0 ? `${dashData.offers.created_this_month} tento měsíc` : null,
})
}
if (dashData?.invoices) {
cards.push(buildInvoiceKpi(dashData.invoices))
}
if (dashData?.leave_pending) {
cards.push({
label: 'Žádosti o volno',
value: `${dashData.leave_pending.count}`,
color: 'danger',
footer: dashData.leave_pending.count > 0 ? 'čeká na schválení' : null,
})
}
return cards
}
function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
const rev = invoices.revenue_this_month || []
const hasForeign = rev.some(r => r.currency !== 'CZK')
const hasCzkTotal = hasForeign && invoices.revenue_czk !== null && invoices.revenue_czk !== undefined
const fallbackText = rev.length > 0
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
: '0 Kč'
const revenueText = hasCzkTotal
? formatCurrency(invoices.revenue_czk!, 'CZK')
: fallbackText
const detailText = hasForeign && rev.length > 0
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
: null
const unpaidText = invoices.unpaid_count > 0
? `${invoices.unpaid_count} neuhrazených`
: null
const footerParts = [detailText, unpaidText].filter(Boolean)
return {
label: 'Tržby (měsíc)',
value: revenueText,
color: 'warning',
footer: footerParts.length > 0 ? footerParts.join(' · ') : null,
}
}
const KPI_CLASS_MAP: Record<number, string> = { 4: 'dash-kpi-4', 3: 'dash-kpi-3', 2: 'dash-kpi-2', 1: 'dash-kpi-1' }
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
const kpiCards = buildKpiCards(dashData)
if (kpiCards.length === 0) {
return null
}
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || 'dash-kpi-4'
return (
<motion.div
className={`dash-kpi-grid ${kpiClass}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
{kpiCards.map((kpi) => (
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
<div className="admin-stat-label">{kpi.label}</div>
<div className="admin-stat-value admin-mono">
{kpi.value}
{kpi.sub && <small className="text-muted" style={{ fontSize: '0.75em', fontWeight: 500, marginLeft: '0.25rem' }}>{kpi.sub}</small>}
</div>
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
</div>
))}
</motion.div>
)
}

View File

@@ -0,0 +1,344 @@
import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../../context/AuthContext'
import { useAlert } from '../../context/AlertContext'
import useModalLock from '../../hooks/useModalLock'
import apiFetch from '../../utils/api'
const API_BASE = '/api/admin'
interface DashProfileProps {
totpEnabled: boolean
totpLoading: boolean
totpSubmitting: boolean
onStart2FASetup: () => void
onConfirm2FA: () => void
onDisable2FA: () => void
totpSecret: string | null
totpQrUri: string | null
totpCode: string
setTotpCode: (code: string) => void
backupCodes: string[] | null
setBackupCodes: (codes: string[] | null) => void
show2FASetup: boolean
setShow2FASetup: (show: boolean) => void
show2FADisable: boolean
setShow2FADisable: (show: boolean) => void
disableCode: string
setDisableCode: (code: string) => void
}
interface ProfileFormData {
username: string
email: string
new_password: string
current_password: string
first_name: string
last_name: string
}
export default function DashProfile({
totpEnabled, totpLoading, totpSubmitting,
onStart2FASetup, onConfirm2FA, onDisable2FA,
totpSecret, totpQrUri, totpCode, setTotpCode,
backupCodes, setBackupCodes,
show2FASetup, setShow2FASetup,
show2FADisable, setShow2FADisable,
disableCode, setDisableCode,
}: DashProfileProps) {
const { user, updateUser } = useAuth()
const alert = useAlert()
const totpSetupRef = useRef<HTMLInputElement>(null)
const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState<ProfileFormData>({
username: '', email: '', new_password: '', current_password: '', first_name: '', last_name: ''
})
useModalLock(showModal)
const openEditModal = () => {
const nameParts = (user?.fullName || '').split(' ')
setFormData({
username: user?.username || '',
email: user?.email || '',
new_password: '',
current_password: '',
first_name: nameParts[0] || '',
last_name: nameParts.slice(1).join(' ') || ''
})
setShowModal(true)
}
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
const dataToSave = { ...formData }
try {
const response = await apiFetch(`${API_BASE}/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
const data = await response.json()
if (data.success) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
setShowModal(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success('Profil byl upraven')
} else {
alert.error(data.error || 'Nepodařilo se uložit profil')
}
} catch {
alert.error('Chyba připojení')
}
}
function getTotpStatusText(): string {
if (totpLoading) {
return 'Načítání...'
}
return totpEnabled ? 'Aktivní' : 'Neaktivní'
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Váš účet</h2>
<button onClick={openEditModal} className="admin-btn admin-btn-secondary admin-btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Upravit
</button>
</div>
<div className="admin-card-body">
<div className="dash-profile-grid">
<div className="dash-profile-item">
<span className="dash-profile-label">Uživatel</span>
<span className="dash-profile-value">{user?.username}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">E-mail</span>
<span className="dash-profile-value">{user?.email}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Jméno</span>
<span className="dash-profile-value">{user?.fullName}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Role</span>
<span className="dash-profile-value">{user?.roleDisplay || String(user?.role || '')}</span>
</div>
</div>
{/* 2FA Section */}
<div style={{ borderTop: '1px solid var(--border-color)', marginTop: '1rem', paddingTop: '1rem' }}>
<div className="flex-between">
<div className="flex-row-gap">
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: totpEnabled ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
color: totpEnabled ? 'var(--success)' : 'var(--text-secondary)'
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>Dvoufaktorové ověření (2FA)</div>
<div className={totpEnabled ? 'text-success' : 'text-secondary'} style={{ fontSize: '0.75rem' }}>
{getTotpStatusText()}
</div>
</div>
</div>
{!totpLoading && (
totpEnabled ? (
<button onClick={() => { setDisableCode(''); setShow2FADisable(true) }} className="admin-btn admin-btn-primary admin-btn-sm">
Deaktivovat
</button>
) : (
<button onClick={onStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary admin-btn-sm">
{totpSubmitting ? 'Generuji...' : 'Aktivovat'}
</button>
)
)}
</div>
</div>
</div>
</motion.div>
{/* Edit Profile Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(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 }}>
<div className="admin-modal-header"><h2 className="admin-modal-title">Upravit profil</h2></div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Jméno</label>
<input type="text" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input type="text" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">E-mail</label>
<input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
<input type="password" value={formData.new_password} onChange={(e) => setFormData({ ...formData, new_password: e.target.value })} className="admin-form-input" />
</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">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary">Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">Uložit změny</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 2FA Setup Modal */}
<AnimatePresence>
{show2FASetup && (
<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={() => { if (!backupCodes) { setShow2FASetup(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 }}>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}</h2>
</div>
<div className="admin-modal-body">
{backupCodes ? (
<div>
<div className="admin-role-locked-notice mb-4">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte.
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
{backupCodes.map((code) => (
<div key={code} style={{ padding: '0.25rem 0.5rem', textAlign: 'center', color: 'var(--text-primary)' }}>{code}</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<button onClick={() => { navigator.clipboard?.writeText(backupCodes.join('\n')); alert.success('Kódy zkopírovány') }} className="admin-btn admin-btn-secondary admin-btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Kopírovat kódy
</button>
</div>
</div>
) : (
<div>
<p className="text-secondary" style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
alt="TOTP QR Code"
style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }}
/>
</div>
)}
{totpSecret && (
<div className="mb-4">
<label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Nebo zadejte klíč ručně:</label>
<div style={{ padding: '0.5rem 0.75rem', background: 'var(--bg-secondary)', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem', wordBreak: 'break-all', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<span>{totpSecret}</span>
<button onClick={() => { navigator.clipboard?.writeText(totpSecret); alert.success('Klíč zkopírován') }} className="admin-btn-icon" title="Kopírovat" aria-label="Kopírovat" style={{ flexShrink: 0 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód z aplikace</label>
<input ref={totpSetupRef} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} />
</div>
</div>
)}
</div>
<div className="admin-modal-footer">
{backupCodes ? (
<button onClick={() => { setShow2FASetup(false); setBackupCodes(null) }} className="admin-btn admin-btn-primary">
Rozumím, uložil jsem si kódy
</button>
) : (
<>
<button onClick={() => setShow2FASetup(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onConfirm2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || totpCode.length !== 6}>
{totpSubmitting ? 'Ověřuji...' : 'Aktivovat 2FA'}
</button>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 2FA Disable Modal */}
<AnimatePresence>
{show2FADisable && (
<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={() => setShow2FADisable(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 }}>
<div className="admin-modal-header"><h2 className="admin-modal-title">Deaktivovat 2FA</h2></div>
<div className="admin-modal-body">
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace.
</p>
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód</label>
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={disableCode} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus />
</div>
</div>
<div className="admin-modal-footer">
<button onClick={() => setShow2FADisable(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onDisable2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || disableCode.length !== 6}>
{totpSubmitting ? 'Deaktivuji...' : 'Deaktivovat 2FA'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,378 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../../context/AuthContext'
import { useAlert } from '../../context/AlertContext'
import { formatKm } from '../../utils/formatters'
import AdminDatePicker from '../AdminDatePicker'
import apiFetch from '../../utils/api'
import useModalLock from '../../hooks/useModalLock'
const API_BASE = '/api/admin'
interface Vehicle {
id: number | string
spz: string
name: string
}
interface TripForm {
vehicle_id: string
trip_date: string
start_km: string
end_km: string
route_from: string
route_to: string
is_business: number
notes: string
}
interface TripErrors {
vehicle_id?: string
trip_date?: string
start_km?: string
end_km?: string
route_from?: string
route_to?: string
}
interface DashQuickActionsProps {
dashData: {
my_shift?: {
has_ongoing: boolean
}
} | null
punching: boolean
onPunch: () => void
}
export default function DashQuickActions({ dashData, punching, onPunch }: DashQuickActionsProps) {
const { hasPermission } = useAuth()
const alert = useAlert()
const [showTripModal, setShowTripModal] = useState(false)
const [tripSubmitting, setTripSubmitting] = useState(false)
const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([])
const [tripForm, setTripForm] = useState<TripForm>({
vehicle_id: '', trip_date: '', start_km: '', end_km: '',
route_from: '', route_to: '', is_business: 1, notes: ''
})
const [tripErrors, setTripErrors] = useState<TripErrors>({})
useModalLock(showTripModal)
const openTripModal = async () => {
setTripForm({
vehicle_id: '', trip_date: new Date().toISOString().split('T')[0],
start_km: '', end_km: '', route_from: '', route_to: '',
is_business: 1, notes: ''
})
setTripErrors({})
setShowTripModal(true)
try {
const response = await apiFetch(`${API_BASE}/vehicles`)
const result = await response.json()
if (result.success) {
setTripVehicles(Array.isArray(result.data) ? result.data : result.data?.vehicles || [])
}
} catch {
// vozidla se nenacetla
}
}
const handleTripVehicleChange = async (vehicleId: string) => {
setTripForm(prev => ({ ...prev, vehicle_id: vehicleId }))
if (!vehicleId) {
return
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
const result = await response.json()
if (result.success) {
setTripForm(prev => ({ ...prev, start_km: result.data.last_km }))
}
} catch {
// last_km se nenacetlo
}
}
const handleTripSubmit = async () => {
const errs: TripErrors = {}
if (!tripForm.vehicle_id) {
errs.vehicle_id = 'Vyberte vozidlo'
}
if (!tripForm.trip_date) {
errs.trip_date = 'Zadejte datum'
}
if (!tripForm.start_km) {
errs.start_km = 'Zadejte počáteční km'
}
if (!tripForm.end_km) {
errs.end_km = 'Zadejte konečný km'
}
if (tripForm.start_km && tripForm.end_km && parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)) {
errs.end_km = 'Musí být větší než počáteční'
}
if (!tripForm.route_from) {
errs.route_from = 'Zadejte místo odjezdu'
}
if (!tripForm.route_to) {
errs.route_to = 'Zadejte místo příjezdu'
}
setTripErrors(errs)
if (Object.keys(errs).length > 0) {
return
}
setTripSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/trips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tripForm)
})
const result = await response.json()
if (result.success) {
setShowTripModal(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setTripSubmitting(false)
}
}
const tripDistance = (): number => {
const s = parseInt(tripForm.start_km) || 0
const e = parseInt(tripForm.end_km) || 0
return e > s ? e - s : 0
}
const hasOngoingShift = dashData?.my_shift?.has_ongoing
const punchLabel = hasOngoingShift ? 'Zaznamenat odchod' : 'Zaznamenat příchod'
const quickActions: Array<{
label: string
color: string
icon: React.ReactNode
onClick?: () => void
path?: string
disabled?: boolean
}> = []
if (hasPermission('attendance.record')) {
quickActions.push({
label: punching ? 'Odesílám...' : punchLabel,
color: hasOngoingShift ? 'danger' : 'success',
icon: hasOngoingShift
? <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>
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 12l2 2 4-4" /><circle cx="12" cy="12" r="10" /></svg>,
onClick: onPunch,
disabled: punching,
})
}
if (hasPermission('offers.create')) {
quickActions.push({ label: 'Nová nabídka', path: '/offers/new', color: 'info', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg> })
}
if (hasPermission('trips.record')) {
quickActions.push({
label: 'Přidat jízdu',
color: 'warning',
icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="1" y="3" width="15" height="13" rx="2" /><circle cx="8.5" cy="16" r="2.5" /><circle cx="18.5" cy="16" r="2.5" /><path d="M16 8h4l3 5v3h-7" /></svg>,
onClick: openTripModal,
})
}
if (hasPermission('invoices.create')) {
quickActions.push({ label: 'Vystavit fakturu', path: '/invoices/new', color: 'danger', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></svg> })
}
return (
<>
<motion.div
className="dash-quick-actions"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
{quickActions.map((action) => action.onClick ? (
<button
key={action.label}
onClick={action.onClick}
disabled={action.disabled}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
>
{action.icon}
<span>{action.label}</span>
</button>
) : (
<Link key={action.label} to={action.path!} className={`dash-quick-btn dash-quick-btn-${action.color}`}>
{action.icon}
<span>{action.label}</span>
</Link>
))}
</motion.div>
<AnimatePresence>
{showTripModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowTripModal(false)} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className={`admin-form-group${tripErrors.vehicle_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Vozidlo</label>
<select
value={tripForm.vehicle_id}
onChange={(e) => {
handleTripVehicleChange(e.target.value)
setTripErrors(prev => ({ ...prev, vehicle_id: undefined }))
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{tripVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.spz} - {v.name}</option>
))}
</select>
{tripErrors.vehicle_id && <span className="admin-form-error">{tripErrors.vehicle_id}</span>}
</div>
<div className={`admin-form-group${tripErrors.trip_date ? ' has-error' : ''}`}>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={tripForm.trip_date}
onChange={(val: string) => {
setTripForm(prev => ({ ...prev, trip_date: val }))
setTripErrors(prev => ({ ...prev, trip_date: undefined }))
}}
/>
{tripErrors.trip_date && <span className="admin-form-error">{tripErrors.trip_date}</span>}
</div>
</div>
<div className="admin-form-row admin-form-row-3">
<div className={`admin-form-group${tripErrors.start_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={tripForm.start_km}
onChange={(e) => {
setTripForm(prev => ({ ...prev, start_km: e.target.value }))
setTripErrors(prev => ({ ...prev, start_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{tripErrors.start_km && <span className="admin-form-error">{tripErrors.start_km}</span>}
</div>
<div className={`admin-form-group${tripErrors.end_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Konečný stav km</label>
<input
type="number"
inputMode="numeric"
value={tripForm.end_km}
onChange={(e) => {
setTripForm(prev => ({ ...prev, end_km: e.target.value }))
setTripErrors(prev => ({ ...prev, end_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{tripErrors.end_km && <span className="admin-form-error">{tripErrors.end_km}</span>}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label>
<input type="text" value={`${formatKm(tripDistance())} km`} className="admin-form-input" readOnly disabled />
</div>
</div>
<div className="admin-form-row">
<div className={`admin-form-group${tripErrors.route_from ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
value={tripForm.route_from}
onChange={(e) => {
setTripForm(prev => ({ ...prev, route_from: e.target.value }))
setTripErrors(prev => ({ ...prev, route_from: undefined }))
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
{tripErrors.route_from && <span className="admin-form-error">{tripErrors.route_from}</span>}
</div>
<div className={`admin-form-group${tripErrors.route_to ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo příjezdu</label>
<input
type="text"
value={tripForm.route_to}
onChange={(e) => {
setTripForm(prev => ({ ...prev, route_to: e.target.value }))
setTripErrors(prev => ({ ...prev, route_to: undefined }))
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
{tripErrors.route_to && <span className="admin-form-error">{tripErrors.route_to}</span>}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label>
<select
value={tripForm.is_business}
onChange={(e) => setTripForm(prev => ({ ...prev, is_business: parseInt(e.target.value) }))}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Poznámky</label>
<textarea
value={tripForm.notes}
onChange={(e) => setTripForm(prev => ({ ...prev, notes: e.target.value }))}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowTripModal(false)} className="admin-btn admin-btn-secondary" disabled={tripSubmitting}>
Zrušit
</button>
<button type="button" onClick={handleTripSubmit} className="admin-btn admin-btn-primary" disabled={tripSubmitting}>
{tripSubmitting ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,218 @@
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'
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
}
function getDeviceIcon(iconType?: string) {
switch (iconType) {
case 'smartphone':
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
case 'tablet':
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
default:
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
}
export default function DashSessions() {
const alert = useAlert()
const [sessions, setSessions] = useState<Session[]>([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({ isOpen: false, session: null })
const [deleteAllModal, setDeleteAllModal] = useState(false)
const [deleting, setDeleting] = useState(false)
useModalLock(deleteAllModal)
const fetchSessions = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/sessions`)
const data = await response.json()
if (data.success) {
setSessions(Array.isArray(data.data) ? data.data : data.data?.sessions || [])
}
} catch {
// session fetch failed silently
} finally {
setSessionsLoading(false)
}
}, [])
useEffect(() => {
fetchSessions()
}, [fetchSessions])
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return
}
const sessionId = deleteModal.session.id
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
setDeleteModal({ isOpen: false, session: null })
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')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const handleDeleteAllSessions = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/sessions?action=all`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
setDeleteAllModal(false)
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')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
<h2 className="admin-card-title">Přihlášená zařízení</h2>
{sessions.filter(s => !s.is_current).length > 0 && (
<button onClick={() => setDeleteAllModal(true)} className="admin-btn admin-btn-secondary admin-btn-sm">
Odhlásit ostatní
</button>
)}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && (
<div className="admin-skeleton" style={{ padding: '1rem', gap: '1rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
</div>
</div>
))}
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<div className="text-secondary" style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.875rem' }}>
Žá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>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, session: null })}
onConfirm={handleDeleteSession}
title="Ukončit relaci"
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
confirmText="Ukončit"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<ConfirmModal
isOpen={deleteAllModal}
onClose={() => setDeleteAllModal(false)}
onConfirm={handleDeleteAllSessions}
title="Odhlásit ostatní zařízení"
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
confirmText="Odhlásit vše"
cancelText="Zrušit"
type="warning"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,71 @@
import { createContext, useContext, useState, useCallback, useMemo, useRef, type ReactNode } from 'react'
interface Alert {
id: string
message: string
type: 'success' | 'error' | 'warning' | 'info'
}
interface AlertMethods {
addAlert: (message: string, type?: string, duration?: number) => string
removeAlert: (id: string) => void
success: (message: string, duration?: number) => string
error: (message: string, duration?: number) => string
warning: (message: string, duration?: number) => string
info: (message: string, duration?: number) => string
}
interface AlertStateValue {
alerts: Alert[]
removeAlert: (id: string) => void
}
const AlertContext = createContext<AlertMethods | null>(null)
const AlertStateContext = createContext<AlertStateValue | null>(null)
export function AlertProvider({ children }: { children: ReactNode }) {
const [alerts, setAlerts] = useState<Alert[]>([])
const removeAlert = useCallback((id: string) => {
setAlerts(prev => prev.filter(alert => alert.id !== id))
}, [])
const counterRef = useRef(0)
const addAlert = useCallback((message: string, type = 'success', duration = 4000) => {
const id = `${Date.now()}-${counterRef.current++}`
setAlerts(prev => [...prev, { id, message, type: type as Alert['type'] }])
if (duration > 0) {
setTimeout(() => removeAlert(id), duration)
}
return id
}, [removeAlert])
const methods = useMemo<AlertMethods>(() => ({
addAlert,
removeAlert,
success: (message, duration) => addAlert(message, 'success', duration),
error: (message, duration) => addAlert(message, 'error', duration),
warning: (message, duration) => addAlert(message, 'warning', duration),
info: (message, duration) => addAlert(message, 'info', duration),
}), [addAlert, removeAlert])
return (
<AlertContext.Provider value={methods}>
<AlertStateContext.Provider value={{ alerts, removeAlert }}>
{children}
</AlertStateContext.Provider>
</AlertContext.Provider>
)
}
export function useAlert(): AlertMethods {
const context = useContext(AlertContext)
if (!context) throw new Error('useAlert must be used within an AlertProvider')
return context
}
export function useAlertState(): AlertStateValue {
const context = useContext(AlertStateContext)
if (!context) throw new Error('useAlertState must be used within an AlertProvider')
return context
}

View File

@@ -0,0 +1,306 @@
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
import { setSessionExpired, setTokenGetter, setRefreshFn } from '../utils/api'
const API_BASE = '/api/admin'
interface User {
id: number
username: string
email: string
fullName: string
roleDisplay: string
isAdmin: boolean
totpEnabled: boolean
require2FA: boolean
permissions: string[]
[key: string]: unknown
}
interface AuthState {
user: User | null
loading: boolean
error: string | null
isAuthenticated: boolean
isAdmin: boolean
permissions: string[]
hasPermission: (permission: string) => boolean
}
interface AuthActions {
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; requires2FA?: boolean; loginToken?: string; error?: string; remember?: boolean }>
verify2FA: (loginToken: string, code: string, remember?: boolean, isBackup?: boolean) => Promise<{ success: boolean; error?: string }>
logout: () => Promise<void>
checkSession: () => Promise<boolean>
getAccessToken: () => string | null
apiRequest: (endpoint: string, options?: RequestInit) => Promise<Response>
silentRefresh: () => Promise<boolean>
updateUser: (updates: Partial<User>) => void
}
const AuthStateContext = createContext<AuthState | null>(null)
const AuthActionsContext = createContext<AuthActions | null>(null)
function mapUser(u: Record<string, unknown> | null): User | null {
if (!u) return null
const id = (u.userId ?? u.id) as number
const firstName = (u.firstName ?? u.first_name ?? '') as string
const lastName = (u.lastName ?? u.last_name ?? '') as string
const roleName = (u.roleName ?? u.role_name ?? '') as string
return {
...u,
id,
fullName: (u.fullName ?? u.full_name ?? `${firstName} ${lastName}`.trim()) as string,
roleDisplay: (u.roleDisplay ?? u.role_display ?? roleName) as string,
isAdmin: (u.isAdmin ?? u.is_admin ?? roleName === 'admin') as boolean,
totpEnabled: (u.totpEnabled ?? u.totp_enabled ?? false) as boolean,
require2FA: (u.require2FA ?? u.require_2fa ?? false) as boolean,
permissions: (u.permissions ?? []) as string[],
} as User
}
let accessToken: string | null = null
let tokenExpiresAt: number | null = null
let cachedUser: User | null = null
let sessionFetched = false
let silentRefreshInFlight: Promise<boolean> | null = null
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(cachedUser)
const [loading, setLoading] = useState(!sessionFetched)
const [error, setError] = useState<string | null>(null)
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { cachedUser = user }, [user])
const getAccessTokenFn = useCallback((): string | null => {
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null
return accessToken
}, [])
const setAccessTokenFn = useCallback((token: string | null, expiresIn?: number) => {
const ttl = expiresIn ?? 900 // default 15 min matching backend config
accessToken = token
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
refreshTimeoutRef.current = null
}
if (token && ttl > 60) {
refreshTimeoutRef.current = setTimeout(() => silentRefresh(), (ttl - 60) * 1000)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
if (silentRefreshInFlight) return silentRefreshInFlight
const promise = (async (): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/refresh`, { method: 'POST', credentials: 'include' })
const data = await response.json()
if (data.success && data.data?.access_token) {
setAccessTokenFn(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
return true
}
accessToken = null
tokenExpiresAt = null
setUser(null)
cachedUser = null
setSessionExpired()
return false
} catch {
// Network error — don't kick the user out, just return false
return false
} finally {
silentRefreshInFlight = null
}
})()
silentRefreshInFlight = promise
return promise
}, [setAccessTokenFn])
const checkSession = useCallback(async (): Promise<boolean> => {
try {
const token = getAccessTokenFn()
if (token) {
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }
const response = await fetch(`${API_BASE}/session`, { method: 'GET', credentials: 'include', headers })
if (response.status === 429 || response.status >= 500) 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))
cachedUser = mapUser(data.data.user)
return true
}
}
// No token or session invalid — try silent refresh via cookie
const refreshed = await silentRefresh()
if (refreshed) return true
setUser(null)
cachedUser = null
accessToken = null
tokenExpiresAt = null
return false
} catch {
return !!cachedUser
} finally {
setLoading(false)
sessionFetched = true
}
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh])
useEffect(() => {
setTokenGetter(getAccessTokenFn)
setRefreshFn(silentRefresh)
}, [getAccessTokenFn, silentRefresh])
useEffect(() => {
checkSession()
return () => { if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current) }
}, [checkSession])
const login = useCallback(async (username: string, password: string, remember = false) => {
setError(null)
try {
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password, remember_me: remember }),
})
const data = await response.json()
if (data.success) {
if (data.data?.totp_required) {
return { success: false, requires2FA: true, loginToken: data.data.login_token, remember }
}
setAccessTokenFn(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
sessionFetched = true
return { success: true }
}
setError(data.error)
return { success: false, error: data.error }
} catch {
const errorMsg = 'Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}, [setAccessTokenFn])
const verify2FA = useCallback(async (loginToken: string, code: string, remember = false, isBackup = false) => {
setError(null)
try {
const response = await fetch(`${API_BASE}/login/totp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ login_token: loginToken, totp_code: code }),
})
const data = await response.json()
if (data.success) {
setAccessTokenFn(data.data.access_token, data.data.expires_in)
setUser(mapUser(data.data.user))
cachedUser = mapUser(data.data.user)
sessionFetched = true
return { success: true }
}
setError(data.error)
return { success: false, error: data.error }
} catch {
const errorMsg = 'Chyba pripojeni.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}, [setAccessTokenFn])
const logout = useCallback(async () => {
try {
const token = getAccessTokenFn()
await fetch(`${API_BASE}/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
credentials: 'include',
})
} catch { /* ignore */ } finally {
accessToken = null
tokenExpiresAt = null
setUser(null)
cachedUser = null
sessionFetched = false
if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null }
}
}, [getAccessTokenFn])
const apiRequest = useCallback(async (endpoint: string, options: RequestInit = {}) => {
let token = getAccessTokenFn()
if (!token && user) {
const refreshed = await silentRefresh()
if (refreshed) token = getAccessTokenFn()
}
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(options.headers as Record<string, string>) }
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: 'include' })
if (response.status === 401 && user) {
const refreshed = await silentRefresh()
if (refreshed) {
token = getAccessTokenFn()
if (token) headers['Authorization'] = `Bearer ${token}`
return fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: 'include' })
}
}
return response
}, [getAccessTokenFn, silentRefresh, user])
const updateUser = useCallback((updates: Partial<User>) => {
setUser(prev => prev ? { ...prev, ...updates } : null)
}, [])
const hasPermission = useCallback((permission: string): boolean => {
if (!user) return false
if (user.isAdmin) return true
return (user.permissions || []).includes(permission)
}, [user])
const permissions = useMemo(() => user?.permissions || [], [user])
const stateValue = useMemo<AuthState>(() => ({
user, loading, error, isAuthenticated: !!user, isAdmin: user?.isAdmin || false, permissions, hasPermission,
}), [user, loading, error, permissions, hasPermission])
const actionsValue = useMemo<AuthActions>(() => ({
login, verify2FA, logout, checkSession, getAccessToken: getAccessTokenFn, apiRequest, silentRefresh, updateUser,
}), [login, verify2FA, logout, checkSession, getAccessTokenFn, apiRequest, silentRefresh, updateUser])
return (
<AuthActionsContext.Provider value={actionsValue}>
<AuthStateContext.Provider value={stateValue}>
{children}
</AuthStateContext.Provider>
</AuthActionsContext.Provider>
)
}
export function useAuth(): AuthState & AuthActions {
const state = useContext(AuthStateContext)
const actions = useContext(AuthActionsContext)
if (!state || !actions) throw new Error('useAuth must be used within an AuthProvider')
return { ...state, ...actions }
}
export function useAuthState(): AuthState {
const context = useContext(AuthStateContext)
if (!context) throw new Error('useAuthState must be used within an AuthProvider')
return context
}
export function useAuthActions(): AuthActions {
const context = useContext(AuthActionsContext)
if (!context) throw new Error('useAuthActions must be used within an AuthProvider')
return context
}
export default AuthStateContext

544
src/admin/dashboard.css Normal file
View File

@@ -0,0 +1,544 @@
/* ============================================================================
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
============================================================================ */
/* Dashboard layout */
.dash {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.dash .admin-page-header {
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;
grid-template-columns: repeat(4, 1fr);
gap: 0.625rem;
}
.dash-quick-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: none;
border-radius: var(--border-radius-sm);
font-size: 0.8125rem;
font-weight: 600;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.dash-quick-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none !important;
}
.dash-quick-btn-success { background: var(--success-soft); color: var(--success); }
.dash-quick-btn-info { background: var(--info-soft); color: var(--info); }
.dash-quick-btn-warning { background: var(--warning-soft); color: var(--warning); }
.dash-quick-btn-danger { background: var(--danger-soft); color: var(--danger); }
.dash-quick-btn:hover {
transform: translateY(-1px);
filter: brightness(0.95);
}
[data-theme="light"] .dash-quick-btn-success { background: var(--success); color: #fff; }
[data-theme="light"] .dash-quick-btn-info { background: var(--info); color: #fff; }
[data-theme="light"] .dash-quick-btn-warning { background: var(--warning); color: #fff; }
[data-theme="light"] .dash-quick-btn-danger { background: var(--danger); color: #fff; }
/* Main content 3-col grid */
.dash-main-grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr;
gap: 1rem;
}
/* Card link */
.dash-card-link {
font-size: 0.75rem;
color: var(--accent-color);
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s;
}
.dash-card-link:hover {
opacity: 0.8;
}
/* Activity rows */
.dash-activity-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
transition: background 0.1s;
}
.dash-activity-row:last-child {
border-bottom: none;
}
.dash-activity-row:hover {
background: var(--bg-tertiary);
}
.dash-activity-icon {
width: 34px;
height: 34px;
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dash-activity-icon.success { background: var(--success-soft); color: var(--success); }
.dash-activity-icon.info { background: var(--info-soft); color: var(--info); }
.dash-activity-icon.warning { background: var(--warning-soft); color: var(--warning); }
.dash-activity-icon.danger { background: var(--danger-soft); color: var(--danger); }
.dash-activity-icon.accent { background: var(--accent-soft); color: var(--accent-color); }
.dash-activity-icon.muted { background: var(--bg-tertiary); color: var(--text-secondary); }
.dash-activity-main {
flex: 1;
min-width: 0;
}
.dash-activity-text {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-activity-sub {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
.dash-activity-time {
font-size: 0.6875rem;
color: var(--text-muted);
flex-shrink: 0;
}
/* Presence rows */
.dash-presence-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
.dash-presence-row:last-child {
border-bottom: none;
}
.dash-presence-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 700;
flex-shrink: 0;
text-transform: uppercase;
}
.dash-presence-avatar.dash-status-in { background: var(--success-soft); color: var(--success); }
.dash-presence-avatar.dash-status-away { background: var(--warning-soft); color: var(--warning); }
.dash-presence-avatar.dash-status-out { background: var(--bg-tertiary); color: var(--text-muted); }
.dash-presence-avatar.dash-status-leave { background: var(--info-soft); color: var(--info); }
.dash-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dash-status-dot.dash-status-in { background: var(--success); }
.dash-status-dot.dash-status-away { background: var(--warning); }
.dash-status-dot.dash-status-out { background: var(--text-muted); }
.dash-status-dot.dash-status-leave { background: var(--info); }
.dash-presence-label.dash-status-in { color: var(--success); }
.dash-presence-label.dash-status-away { color: var(--warning); }
.dash-presence-label.dash-status-out { color: var(--text-muted); }
.dash-presence-label.dash-status-leave { color: var(--info); }
.dash-presence-name {
flex: 1;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.dash-presence-end {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.dash-presence-label {
font-size: 0.75rem;
font-weight: 500;
}
.dash-presence-time {
font-size: 0.6875rem;
color: var(--text-muted);
}
/* Right column */
.dash-right-col {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Project rows */
.dash-project-row {
display: block;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
text-decoration: none;
transition: background 0.1s;
}
.dash-project-row:last-child {
border-bottom: none;
}
.dash-project-row:hover {
background: var(--bg-tertiary);
}
.dash-project-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.dash-project-customer {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
/* Stat mini rows */
.dash-stat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.8125rem;
color: var(--text-secondary);
}
.dash-stat-row:last-child {
border-bottom: none;
}
/* Empty row */
.dash-empty-row {
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
}
/* Bottom stacked layout (profile + sessions) */
.dash-bottom {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.dash .admin-card {
margin-bottom: 0;
}
/* Profile grid inside account card */
.dash-profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
}
.dash-profile-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.dash-profile-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
}
.dash-profile-value {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 1024px) {
.dash-main-grid {
grid-template-columns: 1fr 1fr;
}
.dash-right-col {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr 1fr;
}
.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); }
.dash-main-grid {
grid-template-columns: 1fr;
}
.dash-right-col {
grid-template-columns: 1fr;
}
.dash-bottom {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.dash-quick-actions { grid-template-columns: 1fr 1fr; }
.dash-kpi-grid { grid-template-columns: 1fr; }
.dash-profile-grid { grid-template-columns: 1fr; }
}
/* ============================================================================
Sessions / Devices
============================================================================ */
.sessions-list {
display: flex;
flex-direction: column;
}
.session-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: var(--transition);
}
.session-item:last-child {
border-bottom: none;
}
.session-item:hover {
background: var(--bg-tertiary);
}
.session-item-current {
background: var(--row-current);
}
.session-item-current:hover {
background: var(--row-current-hover);
}
.session-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.session-item-current .session-icon {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.session-info {
flex: 1;
min-width: 0;
}
.session-device {
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
}
.session-meta {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.session-meta-separator {
color: var(--border-color);
}
.session-actions {
flex-shrink: 0;
}
@media (max-width: 640px) {
.session-item {
padding: 1rem;
gap: 0.75rem;
}
.session-icon {
width: 36px;
height: 36px;
}
.session-device {
font-size: 0.875rem;
}
.session-meta {
font-size: 0.75rem;
}
}

View File

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

@@ -0,0 +1,766 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import apiFetch from '../utils/api'
import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
calculateWorkMinutes,
getDatePart,
getTimePart,
} from '../utils/attendanceHelpers'
import type { ShiftFormData, ProjectLog, Project, User } from '../components/ShiftFormModal'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void }
}
interface AttendanceRecord {
id: number
user_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_id?: number | null
project_name?: string
project_logs?: Array<{
id?: number
project_id: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
}>
user_name?: string
users?: {
id: number
first_name: string
last_name: string
username: string
}
}
interface ApiUser {
id: number
first_name: string
last_name: string
username: string
}
interface UserTotal {
name: string
minutes: number
working: boolean
vacation_hours: number
sick_hours: number
holiday_hours: number
unpaid_hours: number
overtime: number
missing: number
fund: number | null
business_days: number
worked_hours: number
covered: number
}
interface LeaveBalance {
vacation_total: number
vacation_remaining: number
}
interface BulkForm {
month: string
user_ids: string[]
arrival_time: string
departure_time: string
break_start_time: string
break_end_time: string
}
interface AttendanceData {
records: AttendanceRecord[]
users: User[]
user_totals: Record<string, UserTotal>
leave_balances: Record<string, LeaveBalance>
}
interface DeleteConfirmState {
show: boolean
record: AttendanceRecord | null
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const API_BASE = '/api/admin'
const combineDatetime = (date: string, time: string): string | null =>
date && time ? `${date}T${time}:00` : null
/**
* Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned.
*/
function computeUserTotals(
records: AttendanceRecord[],
userMap: Map<number, string>,
month: string,
): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {}
for (const rec of records) {
const uid = String(rec.user_id)
if (!totals[uid]) {
const name =
userMap.get(rec.user_id) ??
(rec.users
? `${rec.users.first_name} ${rec.users.last_name}`.trim() || rec.users.username
: `User #${rec.user_id}`)
totals[uid] = {
name,
minutes: 0,
working: false,
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
overtime: 0,
missing: 0,
fund: null,
business_days: 0,
worked_hours: 0,
covered: 0,
}
}
const t = totals[uid]
const leaveType = rec.leave_type || 'work'
if (leaveType === 'work') {
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
t.minutes += calculateWorkMinutes(rec)
} else {
const leaveHours = Number(rec.leave_hours) || 8
switch (leaveType) {
case 'vacation': t.vacation_hours += leaveHours; break
case 'sick': t.sick_hours += leaveHours; break
case 'holiday': t.holiday_hours += leaveHours; break
case 'unpaid': t.unpaid_hours += leaveHours; break
}
}
// Track if user is currently working (has arrival but no departure)
if (rec.arrival_time && !rec.departure_time) {
t.working = true
}
}
// Add fund data per user (matching PHP addFundDataToUserTotals)
const [yearStr, monthStr] = month.split('-')
const yr = parseInt(yearStr, 10)
const mo = parseInt(monthStr, 10) - 1
// Count business days in month (Mon-Fri)
let rawBizDays = 0
const cur = new Date(yr, mo, 1)
while (cur.getMonth() === mo) {
const dow = cur.getDay()
if (dow !== 0 && dow !== 6) rawBizDays++
cur.setDate(cur.getDate() + 1)
}
for (const uid of Object.keys(totals)) {
const t = totals[uid]
// Subtract holiday days from business days for this user
const holidayDays = Math.round(t.holiday_hours / 8)
const bizDays = Math.max(0, rawBizDays - holidayDays)
const fund = bizDays * 8
const workedHours = Math.round((t.minutes / 60) * 10) / 10
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
const leaveHours = t.vacation_hours + t.sick_hours
const covered = Math.round((workedHours + leaveHours) * 10) / 10
t.fund = fund
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)
}
return totals
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export default function useAttendanceAdmin({ alert }: AlertContext) {
// ---- Core state ----
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [filterUserId, setFilterUserId] = useState('')
const [data, setData] = useState<AttendanceData>({
records: [],
users: [],
user_totals: {},
leave_balances: {},
})
// ---- Bulk modal ----
const [showBulkModal, setShowBulkModal] = useState(false)
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkForm, setBulkForm] = useState<BulkForm>({
month: '',
user_ids: [],
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30',
})
// ---- Create modal ----
const [showCreateModal, setShowCreateModal] = useState(false)
const today = new Date().toISOString().split('T')[0]
const [createForm, setCreateForm] = useState<ShiftFormData>({
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: '',
})
// ---- Edit modal ----
const [showEditModal, setShowEditModal] = useState(false)
const [editingRecord, setEditingRecord] = useState<AttendanceRecord | null>(null)
const [editForm, setEditForm] = useState<ShiftFormData>({
user_id: '',
shift_date: '',
leave_type: 'work',
leave_hours: 8,
arrival_date: '',
arrival_time: '',
break_start_date: '',
break_start_time: '',
break_end_date: '',
break_end_time: '',
departure_date: '',
departure_time: '',
notes: '',
})
// ---- Delete ----
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>({
show: false,
record: null,
})
// ---- Projects ----
const [projectList, setProjectList] = useState<Project[]>([])
const [createProjectLogs, setCreateProjectLogs] = useState<ProjectLog[]>([])
const [editProjectLogs, setEditProjectLogs] = useState<ProjectLog[]>([])
// ---- Print ref (kept for API compat) ----
const printRef = useRef<HTMLDivElement | null>(null)
// ---- Ref to hold full user list for user_totals computation ----
const usersRef = useRef<Map<number, string>>(new Map())
// =========================================================================
// Load projects once
// =========================================================================
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance?action=projects`)
const result = await response.json()
if (result.success) setProjectList(result.data?.projects ?? result.data ?? [])
} catch {
/* silent */
}
}
loadProjects()
}, [])
// =========================================================================
// Load users once
// =========================================================================
useEffect(() => {
const loadUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users?limit=1000`)
const result = await response.json()
if (result.success) {
const apiUsers: ApiUser[] = result.data
const mapped: User[] = apiUsers.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
}))
const nameMap = new Map<number, string>()
for (const u of mapped) nameMap.set(u.id as number, u.name)
usersRef.current = nameMap
setData((prev) => ({ ...prev, users: mapped }))
}
} catch {
/* silent */
}
}
loadUsers()
}, [])
// =========================================================================
// Fetch attendance records + leave balances
// =========================================================================
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const [yearStr, monthStr] = month.split('-')
// Build records URL
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`
// Fetch records and balances in parallel
const [recordsResponse, balancesResponse] = await Promise.all([
apiFetch(recordsUrl),
apiFetch(`${API_BASE}/attendance?action=balances&year=${yearStr}`),
])
if (recordsResponse.status === 401) return
const recordsResult = await recordsResponse.json()
const balancesResult = await balancesResponse.json()
const records: AttendanceRecord[] = recordsResult.success
? (Array.isArray(recordsResult.data) ? recordsResult.data : [])
: []
// balancesResult.data is { users: [...], balances: { uid: {...} } }
const balancesObj = balancesResult.success ? balancesResult.data : {}
const leaveBalances: Record<string, LeaveBalance> = balancesObj?.balances ?? balancesObj ?? {}
// Compute user_totals client-side
const userTotals = computeUserTotals(records, usersRef.current, month)
setData((prev) => ({
...prev,
records,
user_totals: userTotals,
leave_balances: leaveBalances,
}))
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[month, filterUserId],
)
// Initial load with skeleton, filter changes without skeleton
const initialLoadDone = useRef(false)
useEffect(() => {
if (!initialLoadDone.current) {
initialLoadDone.current = true
fetchData(true)
} else {
fetchData(false)
}
}, [fetchData])
// =========================================================================
// Validation helper
// =========================================================================
const validateProjectLogs = (logs: ProjectLog[], formData: ShiftFormData): boolean => {
const totalWork = calcFormWorkMinutes(formData)
const totalProject = calcProjectMinutesTotal(logs)
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60)
const wM = totalWork % 60
const pH = Math.floor(totalProject / 60)
const pM = totalProject % 60
alert.error(
`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`,
)
return false
}
return true
}
// =========================================================================
// Create modal
// =========================================================================
const openCreateModal = () => {
const todayDate = new Date().toISOString().split('T')[0]
setCreateForm({
user_id: '',
shift_date: todayDate,
leave_type: 'work',
leave_hours: 8,
arrival_date: todayDate,
arrival_time: '',
break_start_date: todayDate,
break_start_time: '',
break_end_date: todayDate,
break_end_time: '',
departure_date: todayDate,
departure_time: '',
notes: '',
})
setCreateProjectLogs([])
setShowCreateModal(true)
}
const handleCreateShiftDateChange = (newDate: string) => {
setCreateForm((prev) => ({
...prev,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate,
}))
}
const handleCreateSubmit = async () => {
if (!createForm.user_id || !createForm.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
}
const filteredCreateLogs = createProjectLogs.filter((l) => l.project_id)
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
if (!validateProjectLogs(filteredCreateLogs, createForm)) return
}
try {
const isLeave = createForm.leave_type !== 'work'
const payload: Record<string, unknown> = {
user_id: Number(createForm.user_id),
shift_date: createForm.shift_date,
leave_type: createForm.leave_type,
notes: createForm.notes || null,
}
if (isLeave) {
payload.leave_hours = createForm.leave_hours || 8
payload.arrival_time = null
payload.departure_time = null
payload.break_start = null
payload.break_end = null
} else {
payload.arrival_time = combineDatetime(createForm.arrival_date, createForm.arrival_time)
payload.departure_time = combineDatetime(createForm.departure_date, createForm.departure_time)
payload.break_start = combineDatetime(createForm.break_start_date, createForm.break_start_time)
payload.break_end = combineDatetime(createForm.break_end_date, createForm.break_end_time)
}
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
payload.project_logs = filteredCreateLogs
}
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (result.success) {
setShowCreateModal(false)
await fetchData(false)
await new Promise((resolve) => setTimeout(resolve, 300))
alert.success(result.message || result.data?.message || 'Záznam vytvořen')
} else {
alert.error(result.error || 'Nepodařilo se vytvořit záznam')
}
} catch {
alert.error('Chyba připojení')
}
}
// =========================================================================
// Bulk modal
// =========================================================================
const openBulkModal = () => {
setBulkForm({
month,
user_ids: data.users.map((u) => String(u.id)),
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30',
})
setShowBulkModal(true)
}
const toggleBulkUser = (userId: number | string) => {
const uid = String(userId)
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.includes(uid)
? prev.user_ids.filter((u) => u !== uid)
: [...prev.user_ids, uid],
}))
}
const toggleAllBulkUsers = () => {
const allIds = data.users.map((u) => String(u.id))
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
}))
}
const handleBulkSubmit = async () => {
if (!bulkForm.month) {
alert.error('Vyberte měsíc')
return
}
if (bulkForm.user_ids.length === 0) {
alert.error('Vyberte alespoň jednoho zaměstnance')
return
}
setBulkSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance?action=bulk_attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bulkForm),
})
const result = await response.json()
if (result.success) {
setShowBulkModal(false)
await fetchData(false)
await new Promise((resolve) => setTimeout(resolve, 300))
alert.success(result.message || result.data?.message || 'Záznamy vytvořeny')
} else {
alert.error(result.error || 'Nepodařilo se vytvořit záznamy')
}
} catch {
alert.error('Chyba připojení')
} finally {
setBulkSubmitting(false)
}
}
// =========================================================================
// Edit modal
// =========================================================================
const openEditModal = (record: AttendanceRecord) => {
// Enrich record with user_name for the modal subtitle
const userName = record.users
? `${record.users.first_name} ${record.users.last_name}`.trim() || record.users.username
: (record as Record<string, unknown>).user_name as string || `User #${record.user_id}`
const enriched = { ...record, user_name: userName }
setEditingRecord(enriched)
const shiftDate = getDatePart(record.shift_date) || record.shift_date
setEditForm({
user_id: String(record.user_id),
shift_date: shiftDate,
leave_type: record.leave_type || 'work',
leave_hours: Number(record.leave_hours) || 8,
arrival_date: getDatePart(record.arrival_time) || shiftDate,
arrival_time: getTimePart(record.arrival_time),
break_start_date: getDatePart(record.break_start) || shiftDate,
break_start_time: getTimePart(record.break_start),
break_end_date: getDatePart(record.break_end) || shiftDate,
break_end_time: getTimePart(record.break_end),
departure_date: getDatePart(record.departure_time) || shiftDate,
departure_time: getTimePart(record.departure_time),
notes: record.notes || '',
})
const logs: ProjectLog[] = (record.project_logs || []).map((l) => {
if (l.hours !== null && l.hours !== undefined) {
return {
project_id: String(l.project_id),
hours: String(l.hours),
minutes: String(l.minutes || 0),
}
}
if (l.started_at && l.ended_at) {
const mins = Math.max(
0,
Math.floor(
(new Date(l.ended_at).getTime() - new Date(l.started_at).getTime()) / 60000,
),
)
return {
project_id: String(l.project_id),
hours: String(Math.floor(mins / 60)),
minutes: String(mins % 60),
}
}
return { project_id: String(l.project_id), hours: '', minutes: '' }
})
setEditProjectLogs(logs)
setShowEditModal(true)
}
const handleEditSubmit = async () => {
if (!editingRecord) return
const isWork = (editForm.leave_type || 'work') === 'work'
const filteredEditLogs = isWork ? editProjectLogs.filter((l) => l.project_id) : []
if (filteredEditLogs.length > 0) {
if (!validateProjectLogs(filteredEditLogs, editForm)) return
}
try {
const isLeave = editForm.leave_type !== 'work'
const payload: Record<string, unknown> = {
leave_type: editForm.leave_type,
notes: editForm.notes || null,
}
if (isLeave) {
payload.leave_hours = editForm.leave_hours || 8
payload.arrival_time = null
payload.departure_time = null
payload.break_start = null
payload.break_end = null
} else {
payload.arrival_time = combineDatetime(editForm.arrival_date, editForm.arrival_time)
payload.departure_time = combineDatetime(editForm.departure_date, editForm.departure_time)
payload.break_start = combineDatetime(editForm.break_start_date, editForm.break_start_time)
payload.break_end = combineDatetime(editForm.break_end_date, editForm.break_end_time)
}
if (filteredEditLogs.length > 0) {
payload.project_logs = filteredEditLogs
}
const response = await apiFetch(`${API_BASE}/attendance/${editingRecord.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise((resolve) => setTimeout(resolve, 300))
alert.success(result.message || result.data?.message || 'Záznam aktualizován')
} else {
alert.error(result.error || 'Nepodařilo se uložit')
}
} catch {
alert.error('Chyba připojení')
}
}
// =========================================================================
// Delete
// =========================================================================
const handleDelete = async () => {
if (!deleteConfirm.record) return
try {
const response = await apiFetch(
`${API_BASE}/attendance/${deleteConfirm.record.id}`,
{ method: 'DELETE' },
)
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, record: null })
await fetchData(false)
alert.success(result.message || result.data?.message || 'Záznam smazán')
} else {
alert.error(result.error || 'Nepodařilo se smazat')
}
} catch {
alert.error('Chyba připojení')
}
}
// =========================================================================
// Print (stub)
// =========================================================================
const handlePrint = async () => {
// TODO: implement print functionality
alert.success('Funkce tisku bude brzy dostupná')
}
// =========================================================================
// Derived
// =========================================================================
const hasData = Object.keys(data.user_totals).length > 0
// =========================================================================
// Public API
// =========================================================================
return {
loading,
month,
setMonth,
filterUserId,
setFilterUserId,
data,
hasData,
showBulkModal,
setShowBulkModal,
bulkSubmitting,
bulkForm,
setBulkForm,
showCreateModal,
setShowCreateModal,
createForm,
setCreateForm,
showEditModal,
setShowEditModal,
editingRecord,
editForm,
setEditForm,
deleteConfirm,
setDeleteConfirm,
projectList,
createProjectLogs,
setCreateProjectLogs,
editProjectLogs,
setEditProjectLogs,
printRef,
openCreateModal,
handleCreateShiftDateChange,
handleCreateSubmit,
openBulkModal,
toggleBulkUser,
toggleAllBulkUsers,
handleBulkSubmit,
openEditModal,
handleEditSubmit,
handleDelete,
handlePrint,
}
}

View File

@@ -0,0 +1,14 @@
import { useState, useEffect } from 'react'
export default function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}

View File

@@ -0,0 +1,91 @@
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
setLoading(true)
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

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

View File

@@ -0,0 +1,19 @@
import { useState, useCallback } from 'react'
export default function useTableSort(defaultSort = 'id') {
const [sort, setSort] = useState(defaultSort)
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const handleSort = useCallback((column: string) => {
setSort(prev => {
if (prev === column) {
setOrder(o => (o === 'asc' ? 'desc' : 'asc'))
return column
}
setOrder('desc')
return column
})
}, [])
return { sort, order, handleSort, activeSort: sort }
}

141
src/admin/invoices.css Normal file
View File

@@ -0,0 +1,141 @@
/* ============================================================================
Invoice Status Badges
============================================================================ */
.admin-badge-invoice-issued {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-badge-invoice-paid {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.admin-badge-invoice-overdue {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* ============================================================================
Invoice Month Navigation
============================================================================ */
.invoice-month-nav {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin-bottom: 0.875rem;
}
.invoice-month-nav span {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
min-width: 120px;
text-align: center;
}
.invoice-month-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.invoice-month-btn:hover:not(:disabled) {
border-color: var(--accent-color);
color: var(--accent-color);
}
.invoice-month-btn:disabled {
opacity: 0.3;
cursor: default;
}
/* ============================================================================
Received Invoices - Upload Modal
============================================================================ */
.received-upload-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.received-upload-card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
}
.received-upload-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.received-upload-file-info {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
color: var(--text-secondary);
}
.received-upload-file-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.received-upload-file-size {
font-size: 0.75rem;
color: var(--text-tertiary);
white-space: nowrap;
}
.received-upload-card-fields {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.received-upload-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
@media (max-width: 640px) {
.invoice-month-btn {
width: 44px;
height: 44px;
}
.received-upload-row {
flex-direction: column;
}
.received-upload-file-name {
max-width: 200px;
}
}

143
src/admin/login.css Normal file
View File

@@ -0,0 +1,143 @@
/* ============================================================================
Login Page
============================================================================ */
.admin-login {
min-height: 100vh;
min-height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bg-primary);
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.admin-login .bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
.admin-login .bg-orb-1 {
width: 400px;
height: 400px;
background: var(--orb-color-1);
top: 10%;
left: 20%;
animation: float 20s ease-in-out infinite;
}
.admin-login .bg-orb-2 {
width: 320px;
height: 320px;
background: var(--orb-color-2);
bottom: 20%;
right: 15%;
animation: float 25s ease-in-out infinite reverse;
}
.admin-login-theme-btn {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
overflow: hidden;
transition: var(--transition);
z-index: 10;
}
.admin-login-theme-btn:hover {
background: var(--glass-bg-solid);
color: var(--text-primary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-login-card {
width: 100%;
max-width: 420px;
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
padding: 2rem;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--glass-shadow);
position: relative;
z-index: 1;
overflow-y: auto;
}
@media (min-width: 640px) {
.admin-login-card {
padding: 2.5rem;
}
}
.admin-login-header {
text-align: center;
margin-bottom: 2rem;
}
.admin-login-logo {
height: 48px;
width: auto;
margin-bottom: 1rem;
}
.admin-login-2fa-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
margin-bottom: 1rem;
}
.admin-login-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.5rem;
}
.admin-login-subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
}
.admin-back-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: var(--transition);
margin-top: 1.5rem;
display: inline-block;
}
.admin-back-link:hover {
color: var(--text-primary);
text-decoration: underline;
}

775
src/admin/offers.css Normal file
View File

@@ -0,0 +1,775 @@
/* ============================================
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;
overflow-y: hidden;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
}
.offers-items-table .admin-table {
min-width: 700px;
margin: 0;
}
.offers-items-table .admin-table thead th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
padding: 8px 10px;
white-space: nowrap;
}
.offers-items-table .admin-table td {
vertical-align: top;
padding: 8px;
}
.offers-items-table .admin-table tbody tr {
transition: background var(--transition);
}
.offers-items-table .admin-table tbody tr:hover {
background: var(--table-row-hover);
}
.offers-items-table .admin-table td .admin-form-input {
display: block;
padding: 6px 8px;
font-size: 13px;
min-height: 32px;
}
/* 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;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
margin-right: 0.375rem;
vertical-align: middle;
}
.offers-lang-badge-cz {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* 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) */
.rich-editor {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
}
.rich-editor .quill {
display: flex;
flex-direction: column;
}
/* Toolbar */
.rich-editor .ql-toolbar.ql-snow {
background: var(--bg-secondary);
border: none;
border-bottom: 1px solid var(--border-color);
border-radius: 0.5rem 0.5rem 0 0;
padding: 0.5rem;
flex-wrap: wrap;
gap: 2px;
}
.rich-editor .ql-toolbar .ql-formats {
margin-right: 8px;
}
/* Toolbar buttons */
.rich-editor .ql-snow .ql-stroke {
stroke: var(--text-secondary);
}
.rich-editor .ql-snow .ql-fill {
fill: var(--text-secondary);
}
.rich-editor .ql-snow .ql-picker-label {
color: var(--text-secondary);
border-color: var(--border-color);
}
.rich-editor .ql-snow button:hover .ql-stroke,
.rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
stroke: var(--text-primary);
}
.rich-editor .ql-snow button:hover .ql-fill,
.rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
fill: var(--text-primary);
}
.rich-editor .ql-snow button:hover,
.rich-editor .ql-snow .ql-picker-label:hover {
color: var(--text-primary);
}
/* Active state */
.rich-editor .ql-snow button.ql-active {
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
border-radius: 4px;
}
.rich-editor .ql-snow button.ql-active .ql-stroke {
stroke: var(--accent-color);
}
.rich-editor .ql-snow button.ql-active .ql-fill,
.rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
fill: var(--accent-color);
}
.rich-editor .ql-snow .ql-picker-item.ql-selected {
color: var(--accent-color);
}
.rich-editor .ql-snow .ql-picker-label.ql-active {
color: var(--accent-color);
}
.rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
stroke: var(--accent-color);
}
/* Dropdowns (font, size, color, align) */
.rich-editor .ql-snow .ql-picker-options {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
z-index: 1000;
padding: 0.25rem;
}
.rich-editor .ql-snow .ql-picker-item {
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.rich-editor .ql-snow .ql-picker-item:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Font picker */
.rich-editor .ql-snow .ql-font .ql-picker-options { min-width: 11rem; max-height: 200px; overflow-y: auto; }
.rich-editor .ql-snow .ql-size .ql-picker-options { max-height: 200px; overflow-y: auto; }
/* Font labels - vysoka specificita kvuli quill.snow.css */
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="arial"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="arial"]::before { content: 'Arial' !important; font-family: Arial, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="tahoma"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="tahoma"]::before { content: 'Tahoma' !important; font-family: Tahoma, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="verdana"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="verdana"]::before { content: 'Verdana' !important; font-family: Verdana, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="georgia"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="georgia"]::before { content: 'Georgia' !important; font-family: Georgia, serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="times-new-roman"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="times-new-roman"]::before { content: 'Times New Roman' !important; font-family: 'Times New Roman', serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="courier-new"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="courier-new"]::before { content: 'Courier New' !important; font-family: 'Courier New', monospace; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="trebuchet-ms"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="trebuchet-ms"]::before { content: 'Trebuchet MS' !important; font-family: 'Trebuchet MS', sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="impact"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="impact"]::before { content: 'Impact' !important; font-family: Impact, sans-serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="comic-sans-ms"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="comic-sans-ms"]::before { content: 'Comic Sans MS' !important; font-family: 'Comic Sans MS', cursive; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="lucida-console"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="lucida-console"]::before { content: 'Lucida Console' !important; font-family: 'Lucida Console', monospace; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="palatino-linotype"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="palatino-linotype"]::before { content: 'Palatino Linotype' !important; font-family: 'Palatino Linotype', serif; }
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="garamond"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="garamond"]::before { content: 'Garamond' !important; font-family: Garamond, serif; }
/* Font classes */
.ql-font-arial { font-family: Arial, sans-serif; }
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
.ql-font-verdana { font-family: Verdana, sans-serif; }
.ql-font-georgia { font-family: Georgia, serif; }
.ql-font-times-new-roman { font-family: 'Times New Roman', serif; }
.ql-font-courier-new { font-family: 'Courier New', monospace; }
.ql-font-trebuchet-ms { font-family: 'Trebuchet MS', sans-serif; }
.ql-font-impact { font-family: Impact, sans-serif; }
.ql-font-comic-sans-ms { font-family: 'Comic Sans MS', cursive; }
.ql-font-lucida-console { font-family: 'Lucida Console', monospace; }
.ql-font-palatino-linotype { font-family: 'Palatino Linotype', serif; }
.ql-font-garamond { font-family: Garamond, serif; }
/* Size picker */
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="8px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="8px"]::before { content: '8px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="9px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="9px"]::before { content: '9px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before { content: '10px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="11px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="11px"]::before { content: '11px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before { content: '12px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before { content: '14px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before { content: '16px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before { content: '18px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before { content: '20px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before { content: '24px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before { content: '28px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before { content: '32px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="36px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="36px"]::before { content: '36px' !important; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="48px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="48px"]::before { content: '48px' !important; }
/* Editor area */
.rich-editor .ql-container.ql-snow {
border: none;
border-radius: 0 0 0.5rem 0.5rem;
font-size: 0.875rem;
}
.rich-editor .ql-editor {
min-height: var(--re-min-height, 120px);
padding: 0.75rem;
color: var(--text-primary);
line-height: 1.6;
font-size: 0.875rem;
background: var(--input-bg);
}
.rich-editor .ql-editor.ql-blank::before {
color: var(--text-tertiary);
font-style: normal;
}
/* Lists inside editor */
.rich-editor .ql-editor ul,
.rich-editor .ql-editor ol {
padding-left: 1.5rem;
}
/* Color picker */
.rich-editor .ql-snow .ql-color-picker .ql-picker-options[aria-hidden="false"] {
width: 176px;
padding: 0.375rem;
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.rich-editor .ql-snow .ql-color-picker .ql-picker-item {
width: 18px;
height: 18px;
border-radius: 2px;
margin: 0;
padding: 0;
flex-shrink: 0;
}
/* Tooltip (link editor) */
.rich-editor .ql-snow .ql-tooltip {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
color: var(--text-primary);
}
.rich-editor .ql-snow .ql-tooltip input[type="text"] {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
color: var(--text-primary);
padding: 0.25rem 0.5rem;
}
.rich-editor .ql-snow .ql-tooltip a {
color: var(--accent-color);
}
/* Read-only rendered rich text (Quill HTML output) */
.rich-text-view {
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.875rem;
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.rich-text-view ul,
.rich-text-view ol {
padding-left: 1.5rem;
margin: 0.25rem 0 0.75rem;
}
.rich-text-view li {
margin-bottom: 0.15rem;
}
.rich-text-view a {
color: var(--accent-color);
}
.rich-text-view strong,
.rich-text-view b {
font-weight: 600;
color: var(--text-primary);
display: inline-block;
margin-top: 0.5rem;
}
.rich-text-view br + b,
.rich-text-view br + strong {
margin-top: 0.75rem;
}
.rich-text-view > br:first-child,
.rich-text-view ul + br,
.rich-text-view ol + br {
display: none;
}
@media (max-width: 640px) {
.offers-editor-section {
padding: 1rem;
}
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
}
.offers-totals-summary {
align-items: stretch;
}
.offers-totals-row {
min-width: unset;
}
}
/* Offer draft row in table */
.offers-draft-row {
background: var(--row-draft);
}
.offers-draft-row-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--warning);
background: color-mix(in srgb, var(--warning) 14%, transparent);
padding: 0.2rem 0.55rem;
border-radius: 99px;
}
/* Expired offer without order */
.offers-expired-row {
background: var(--row-expired);
}
.offers-expired-row td {
color: var(--danger) !important;
}
.offers-expired-row a {
color: var(--danger) !important;
}
/* Invalidated offer */
.offers-invalidated-row {
opacity: 0.6;
}
.offers-invalidated-row td {
color: var(--text-muted) !important;
}
.offers-invalidated-row a {
color: var(--text-muted) !important;
}
/* Read-only form (invalidated offer detail) */
.offers-readonly input[readonly],
.offers-readonly select:disabled {
background-color: var(--bg-secondary);
cursor: default;
}
/* Offer draft indicator */
.offers-draft-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-tertiary);
margin-top: 0.2rem;
opacity: 0.8;
}

View File

@@ -0,0 +1,929 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import { formatTime, calculateWorkMinutes, formatMinutes } from '../utils/attendanceHelpers'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface ShiftRecord {
id: number
user_id: number
shift_date: string
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
notes?: string | null
project_id?: number | null
project_logs?: ProjectLog[]
}
interface ProjectLog {
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
}
interface Project {
id: number
name: string
project_number: string
}
interface LeaveBalance {
vacation_total: number
vacation_used: number
vacation_remaining: number
sick_used: number
}
interface MonthlyFund {
month_name: string
fund: number
worked: number
covered: number
remaining: number
overtime: number
leave_hours: number
vacation_hours: number
sick_hours: number
holiday_hours: number
unpaid_hours: number
}
interface AttendanceData {
ongoing_shift: ShiftRecord | null
today_shifts: ShiftRecord[]
date: string
leave_balance: LeaveBalance
monthly_fund: MonthlyFund | null
project_logs: ProjectLog[]
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)'
if (fund.covered >= fund.fund) return 'linear-gradient(135deg, var(--success), #059669)'
return 'var(--gradient)'
}
export default function Attendance() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = 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],
date_to: new Date().toISOString().split('T')[0],
notes: '',
})
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<{ show: boolean; action: string | null }>({ show: false, action: null })
const geoAbortRef = useRef<AbortController | null>(null)
useEffect(() => {
return () => {
if (geoAbortRef.current) geoAbortRef.current.abort()
}
}, [])
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)
}
}, [alert])
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)
if (!navigator.geolocation) {
alert.warning('GPS není dostupná')
submitPunch(action, {})
return
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords
submitPunch(action, { latitude, longitude, accuracy, address: '' })
if (geoAbortRef.current) geoAbortRef.current.abort()
const controller = new AbortController()
geoAbortRef.current = controller
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, {
headers: { 'Accept-Language': 'cs' },
signal: controller.signal,
})
.then(r => r.json())
.then(geoData => {
if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude, longitude, address: geoData.display_name, punch_action: action }),
}).catch(() => {})
}
})
.catch(() => {})
},
(geoError) => {
let errorMsg = 'Nepodařilo se získat polohu'
if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = 'Přístup k poloze byl zamítnut'
} else if (geoError.code === geoError.TIMEOUT) {
errorMsg = 'Vypršel časový limit'
}
alert.error(errorMsg)
setGpsConfirm({ show: true, action })
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
)
}
const submitPunch = async (action: string, gpsData: Record<string, unknown> = {}) => {
try {
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)
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('Chyba připojení')
}
}
const handleBreak = async () => {
setSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ punch_action: 'break_start' }),
})
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)
}
}
const handleSaveNotes = async () => {
try {
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 response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: newProjectId || null }),
})
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)
}
}
const calculateBusinessDays = (from: string, to: string) => {
if (!from || !to) return 0
const start = new Date(from)
const end = new Date(to)
if (end < start) return 0
let days = 0
const current = new Date(start)
while (current <= end) {
const day = current.getDay()
if (day !== 0 && day !== 6) days++
current.setDate(current.getDate() + 1)
}
return days
}
const handleRequestSubmit = async () => {
setRequestSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(leaveForm),
})
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 (loading) {
return (
<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>
)
}
const { ongoing_shift: ongoingShift, today_shifts: todayShifts, leave_balance: leaveBalance } = data
const isOngoingShift = ongoingShift && !ongoingShift.departure_time
const completedToday = todayShifts.filter(s => s.departure_time)
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8)
const vacationHoursRemaining = leaveBalance.vacation_remaining % 8
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Docházka</h1>
<p className="admin-page-subtitle">
{new Date().toLocaleDateString('cs-CZ', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
</motion.div>
<div className="attendance-layout">
{/* Left Column - Clock In/Out */}
<motion.div
className="attendance-main"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="attendance-clock-card">
<div className="attendance-clock-header">
<div className="attendance-clock-status">
{isOngoingShift ? (
<>
<span className="attendance-status-dot active" />
<span>Pracuji</span>
</>
) : (
<>
<span className="attendance-status-dot" />
<span>Nepracuji</span>
</>
)}
</div>
<div className="attendance-clock-time">
{new Date().toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
{isOngoingShift ? (
<>
<div className="attendance-shift-info">
<div className="attendance-shift-row">
<div className="attendance-shift-item">
<span className="attendance-shift-label">Příchod</span>
<span className="attendance-shift-value success">
{formatTime(ongoingShift.arrival_time)}
</span>
</div>
<div className="attendance-shift-item">
<span className="attendance-shift-label">Pauza</span>
<span className={`attendance-shift-value ${ongoingShift.break_start ? 'success' : ''}`}>
{ongoingShift.break_start
? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
: '—'}
</span>
</div>
<div className="attendance-shift-item">
<span className="attendance-shift-label">Odchod</span>
<span className="attendance-shift-value"></span>
</div>
</div>
</div>
{projects.length > 0 && (
<div className="attendance-project-section">
<div className="attendance-project-header">
<span className="attendance-shift-label">Projekt</span>
{activeProjectId ? (
<span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.8125rem' }}>
{projects.find(p => String(p.id) === String(activeProjectId))
? `${projects.find(p => String(p.id) === String(activeProjectId))!.project_number} ${projects.find(p => String(p.id) === String(activeProjectId))!.name}`
: `Projekt #${activeProjectId}`}
</span>
) : (
<span className="text-muted" style={{ fontSize: '0.8125rem' }}>Žádný</span>
)}
</div>
<select
value={activeProjectId || ''}
onChange={(e) => handleSwitchProject(e.target.value || null)}
disabled={switchingProject}
className="admin-form-select"
style={{ fontSize: '0.875rem' }}
>
<option value=""> Bez projektu </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.project_number} {p.name}</option>
))}
</select>
{projectLogs.length > 0 && (
<div className="attendance-project-logs">
{projectLogs.map((log, i) => {
const start = new Date(log.started_at!)
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end.getTime() - start.getTime()) / 60000)
const h = Math.floor(mins / 60)
const mm = mins % 60
return (
<div key={log.id || i} className="attendance-project-log-item">
<span className="attendance-project-log-name">{log.project_name || `Projekt #${log.project_id}`}</span>
<span className="attendance-project-log-time">
{formatTime(log.started_at)} {log.ended_at ? formatTime(log.ended_at) : 'nyní'}
</span>
<span className="attendance-project-log-duration">{h}:{String(mm).padStart(2, '0')} h</span>
</div>
)
})}
</div>
)}
</div>
)}
<div className="attendance-clock-actions">
{!ongoingShift.break_start && (
<button
onClick={handleBreak}
disabled={submitting}
className="admin-btn admin-btn-secondary"
style={{ width: '100%' }}
>
Pauza (30 min)
</button>
)}
<button
onClick={() => handlePunch('departure')}
disabled={submitting}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{submitting ? 'Zpracovávám...' : 'Odchod'}
</button>
<button
onClick={() => setShowLeaveModal(true)}
className="admin-btn admin-btn-secondary"
style={{ width: '100%' }}
>
Žádost o nepřítomnost
</button>
</div>
<div className="attendance-notes">
<label className="attendance-notes-label">Poznámka ke směně</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Co jste dělali během směny..."
className="admin-form-textarea"
rows={3}
/>
<div className="mt-2">
<button
onClick={handleSaveNotes}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Uložit poznámku
</button>
</div>
</div>
</>
) : (
<div className="attendance-clock-actions">
<button
onClick={() => handlePunch('arrival')}
disabled={submitting}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{submitting ? 'Zpracovávám...' : 'Příchod'}
</button>
<button
onClick={() => setShowLeaveModal(true)}
className="admin-btn admin-btn-secondary"
style={{ width: '100%' }}
>
Žádost o nepřítomnost
</button>
</div>
)}
</div>
{/* Completed Today */}
{completedToday.length > 0 && (
<div className="admin-card mt-6">
<div className="admin-card-header">
<h2 className="admin-card-title">Dnešní dokončené směny</h2>
</div>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Odpracováno</th>
{projects.length > 0 && <th>Projekty</th>}
</tr>
</thead>
<tbody>
{completedToday.map((shift) => {
const shiftLogs = shift.project_logs || []
return (
<tr key={shift.id}>
<td className="admin-mono">{formatTime(shift.arrival_time)}</td>
<td className="admin-mono">
{shift.break_start && shift.break_end
? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
: '—'}
</td>
<td className="admin-mono">{formatTime(shift.departure_time)}</td>
<td className="admin-mono">{formatMinutes(calculateWorkMinutes(shift as any), true)}</td>
{projects.length > 0 && (
<td>
{shiftLogs.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{shiftLogs.map((log, i) => {
const mins = log.ended_at ? Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at!).getTime()) / 60000) : 0
const h = Math.floor(mins / 60)
const mm = mins % 60
return (
<span key={log.id || i} style={{ fontSize: '12px' }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(mm).padStart(2, '0')}h)
</span>
)
})}
</div>
) : '—'}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</motion.div>
{/* Right Column - Stats & Quick Links */}
<motion.div
className="attendance-sidebar"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
{/* Leave Balance Card */}
<div className="attendance-balance-card">
<h3 className="attendance-balance-title">Dovolená {new Date().getFullYear()}</h3>
<div className="attendance-balance-value">
<span className="attendance-balance-number">{vacationDaysRemaining}</span>
<span className="attendance-balance-unit">
{pluralizeDays(vacationDaysRemaining)}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span>
</div>
<div className="attendance-balance-detail">
<span>Celkem: {leaveBalance.vacation_total}h</span>
<span>Čerpáno: {leaveBalance.vacation_used}h</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{ width: `${(leaveBalance.vacation_remaining / leaveBalance.vacation_total) * 100}%` }}
/>
</div>
</div>
{/* Monthly Fund Card */}
{data.monthly_fund && (
<div className="admin-stat-card" style={{ flexDirection: 'column', alignItems: 'stretch' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<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 className="admin-stat-content">
<span className="admin-stat-label">{data.monthly_fund.month_name}</span>
<span className="admin-stat-value">{data.monthly_fund.worked}h / {data.monthly_fund.fund}h</span>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8125rem', marginBottom: '0.5rem' }}>
<span>Odpracováno: {data.monthly_fund.worked}h</span>
{data.monthly_fund.overtime > 0 ? (
<span className="text-warning fw-600">Přesčas: +{data.monthly_fund.overtime}h</span>
) : (
<span>Zbývá: {data.monthly_fund.remaining}h</span>
)}
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, (data.monthly_fund.covered / data.monthly_fund.fund) * 100)}%`,
background: getFundBarBackground(data.monthly_fund),
}}
/>
</div>
{data.monthly_fund.leave_hours > 0 && (
<div className="text-muted" style={{ fontSize: '0.75rem', marginTop: '0.375rem' }}>
{'Pokryto: '}{data.monthly_fund.covered}h (práce {data.monthly_fund.worked}h
{data.monthly_fund.vacation_hours > 0 && ` + 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`}
)
</div>
)}
</div>
</div>
)}
{/* Sick Leave Card */}
<div className="admin-stat-card">
<div className="admin-stat-icon danger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-label">Nemoc {new Date().getFullYear()}</span>
<span className="admin-stat-value">{leaveBalance.sick_used}h čerpáno</span>
</div>
</div>
{/* Quick Links */}
<div className="attendance-quick-links">
<h4 className="attendance-quick-title">Rychlé odkazy</h4>
<Link to="/attendance/requests" className="attendance-quick-link">
<svg width="20" height="20" 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>
<span>Moje žádosti</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
<Link to="/attendance/history" className="attendance-quick-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 3v18h18" />
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3" />
</svg>
<span>Historie docházky</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{hasPermission('attendance.admin') && (
<Link to="/attendance/admin" className="attendance-quick-link">
<svg width="20" height="20" 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>
<span>Správa docházky</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
)}
{hasPermission('attendance.balances') && (
<Link to="/attendance/balances" className="attendance-quick-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
<span>Správa bilancí</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
)}
</div>
</motion.div>
</div>
{/* Leave Modal */}
<AnimatePresence>
{showLeaveModal && (
<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={() => setShowLeaveModal(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 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Typ nepřítomnosti">
<select
value={leaveForm.leave_type}
onChange={(e) => setLeaveForm({ ...leaveForm, leave_type: e.target.value })}
className="admin-form-select"
>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<FormField label="Od">
<AdminDatePicker
mode="date"
value={leaveForm.date_from}
onChange={(val: string) => {
setLeaveForm(prev => ({
...prev,
date_from: val,
date_to: prev.date_to < val ? val : prev.date_to,
}))
}}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={leaveForm.date_to}
minDate={leaveForm.date_from}
onChange={(val: string) => setLeaveForm({ ...leaveForm, date_to: val })}
/>
</FormField>
</div>
{leaveForm.date_from && leaveForm.date_to && (
<div className="admin-form-group">
<div style={{
display: 'flex',
gap: '1.5rem',
padding: '0.75rem 1rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--border-radius)',
fontSize: '0.875rem',
}}>
<span>
<strong>{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)}</strong>{' '}
{(() => {
const d = calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)
if (d === 1) return 'pracovní den'
if (d >= 2 && d <= 4) return 'pracovní dny'
return 'pracovních dnů'
})()}
</span>
<span className="text-muted">
{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) * 8} hodin
</span>
</div>
</div>
)}
<FormField label="Poznámka">
<textarea
value={leaveForm.notes}
onChange={(e) => setLeaveForm({ ...leaveForm, notes: e.target.value })}
placeholder="Volitelná poznámka..."
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowLeaveModal(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleRequestSubmit}
disabled={requestSubmitting || calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0}
className="admin-btn admin-btn-primary"
>
{requestSubmitting ? 'Odesílám...' : 'Odeslat žádost'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={gpsConfirm.show}
onClose={() => { setGpsConfirm({ show: false, action: null }); setSubmitting(false) }}
onConfirm={() => { setGpsConfirm({ show: false, action: null }); submitPunch(gpsConfirm.action!, {}) }}
title="GPS nedostupná"
message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?"
confirmText="Pokračovat"
cancelText="Zrušit"
type="warning"
/>
</div>
)
}

View File

@@ -0,0 +1,341 @@
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import BulkAttendanceModal from '../components/BulkAttendanceModal'
import ShiftFormModal from '../components/ShiftFormModal'
import AttendanceShiftTable from '../components/AttendanceShiftTable'
import useModalLock from '../hooks/useModalLock'
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
import FormField from '../components/FormField'
import { formatMinutes } from '../utils/attendanceHelpers'
interface UserTotalData {
name: string
minutes: number
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
}
function getFundBarBackground(data: UserTotalData) {
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)'
}
export default function AttendanceAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const {
loading, month, setMonth,
filterUserId, setFilterUserId,
data, hasData,
showBulkModal, setShowBulkModal,
bulkSubmitting, bulkForm, setBulkForm,
showCreateModal, setShowCreateModal,
createForm, setCreateForm,
showEditModal, setShowEditModal,
editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
projectList,
createProjectLogs, setCreateProjectLogs,
editProjectLogs, setEditProjectLogs,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
openEditModal, handleEditSubmit,
handleDelete, handlePrint
} = useAttendanceAdmin({ alert })
useModalLock(showBulkModal)
useModalLock(showEditModal)
useModalLock(showCreateModal)
if (!hasPermission('attendance.admin')) return <Forbidden />
// Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad = loading && data.records.length === 0 && Object.keys(data.user_totals).length === 0
if (isInitialLoad) {
return (
<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>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa docházky</h1>
</div>
<div className="admin-page-actions">
{hasData && (
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
)}
<button
onClick={openBulkModal}
className="admin-btn admin-btn-secondary"
>
Vyplnit měsíc
</button>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat záznam
</button>
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
<FormField label="Zaměstnanec">
<select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni</option>
{data.users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</FormField>
</div>
</div>
</motion.div>
{/* User Totals */}
{Object.keys(data.user_totals).length > 0 && (
<motion.div
className="admin-grid admin-grid-3 mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.09 }}
>
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData
return (
<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 className={`attendance-working-badge ${ut.working ? 'working' : 'finished'}`}>
{ut.working ? '\u2713' : '\u2717'}
</span>
</div>
<div className="admin-stat-value">{formatMinutes(ut.minutes)}</div>
<div className="admin-stat-label">odpracováno</div>
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">Dov: {ut.vacation_hours}h</span>
)}
{ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">Nem: {ut.sick_hours}h</span>
)}
{ut.holiday_hours > 0 && (
<span className="attendance-leave-badge badge-holiday">Sv: {ut.holiday_hours}h</span>
)}
{ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">Nep: {ut.unpaid_hours}h</span>
)}
</div>
{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 style={{
height: '100%',
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
</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>
)
})}
</motion.div>
)}
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
</div>
</motion.div>
{/* Modals */}
<BulkAttendanceModal
show={showBulkModal}
onClose={() => setShowBulkModal(false)}
form={bulkForm}
setForm={setBulkForm}
users={data.users}
onSubmit={handleBulkSubmit}
submitting={bulkSubmitting}
toggleUser={toggleBulkUser}
toggleAllUsers={toggleAllBulkUsers}
/>
<ShiftFormModal
mode="create"
show={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateSubmit}
form={createForm}
setForm={setCreateForm}
projectLogs={createProjectLogs}
setProjectLogs={setCreateProjectLogs}
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={null}
/>
<ShiftFormModal
mode="edit"
show={showEditModal && !!editingRecord}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
form={editForm}
setForm={setEditForm}
projectLogs={editProjectLogs}
setProjectLogs={setEditProjectLogs}
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={editingRecord}
/>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, record: null })}
onConfirm={handleDelete}
title="Smazat záznam"
message="Opravdu chcete smazat tento záznam docházky?"
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
)
}

View File

@@ -0,0 +1,728 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import FormField from '../components/FormField'
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
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'
return ''
}
const renderFundDiff = (data: { overtime: number; missing: number }) => {
if (data.overtime > 0) {
return <span className="text-warning fw-600">+{data.overtime}h</span>
}
if (data.missing > 0) {
return <span className="text-danger">-{data.missing}h</span>
}
return <span className="text-success">0h</span>
}
const renderMonthlyStatus = (us: FundUserData, isFulfilled: boolean, isCurrentMonth: boolean) => {
if (us.overtime > 0) {
return <span className="text-warning fw-600" style={{ fontSize: '11px' }}>+{us.overtime}h</span>
}
if (us.missing > 0) {
return <span className="text-danger fw-600" style={{ fontSize: '11px' }}>-{us.missing}h</span>
}
if (isFulfilled && !isCurrentMonth) {
return <span className="text-success" style={{ fontSize: '11px' }}>OK</span>
}
return null
}
const getProgressBackground = (us: FundUserData, isFulfilled: boolean, isCurrentMonth: boolean): string => {
if (us.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
if (isFulfilled) return 'linear-gradient(135deg, var(--success), #059669)'
if (isCurrentMonth) return 'var(--gradient)'
return 'var(--danger)'
}
export default function AttendanceBalances() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [year, setYear] = useState(new Date().getFullYear())
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<{ id: string; name: string } | null>(null)
const [editForm, setEditForm] = useState({
vacation_total: 160,
vacation_used: 0,
sick_used: 0
})
const [resetConfirm, setResetConfirm] = useState<{ show: boolean; userId: string | null; 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 openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name })
setEditForm({
vacation_total: balance.vacation_total,
vacation_used: balance.vacation_used,
sick_used: balance.sick_used
})
setShowEditModal(true)
}
const handleEditSubmit = async () => {
if (!editingUser) return
try {
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 {
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í')
}
}
const years: number[] = []
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
years.push(y)
}
const getYearFundTotals = (userId: string) => {
if (!fundData.months || Object.keys(fundData.months).length === 0) return null
let totalFund = 0
let totalWorked = 0
let totalCovered = 0
for (const monthData of Object.values(fundData.months)) {
totalFund += monthData.fund
const us = monthData.users?.[userId]
if (us) {
totalWorked += us.worked
totalCovered += us.covered
}
}
const missing = Math.max(0, Math.round((totalFund - totalCovered) * 10) / 10)
const overtime = Math.max(0, Math.round((totalCovered - totalFund) * 10) / 10)
return { fund: totalFund, worked: Math.round(totalWorked * 10) / 10, covered: Math.round(totalCovered * 10) / 10, missing, overtime }
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa bilancí</h1>
</div>
<div className="admin-page-actions">
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="admin-form-select"
style={{ minWidth: '100px' }}
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{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>Odpracováno</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.worked}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 */}
{!fundLoading && fundData.months && Object.keys(fundData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
className="mt-6"
>
<h2 className="admin-page-title mb-4" style={{ fontSize: '1.25rem' }}>
Měsíční přehled fondu {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(fundData.months).map(([monthKey, monthData]) => {
const isCurrentMonth = year === currentYear && parseInt(monthKey) === currentMonth
return (
<div
key={monthKey}
className="admin-card"
style={isCurrentMonth ? {
borderColor: 'var(--accent-color)',
boxShadow: '0 0 0 1px var(--accent-color)'
} : {}}
>
<div className="admin-card-body">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 style={{ fontWeight: 600, fontSize: '1rem', margin: 0 }}>
{monthData.month_name}
{isCurrentMonth && (
<span style={{
marginLeft: '0.5rem',
fontSize: '0.7rem',
padding: '0.125rem 0.375rem',
background: 'var(--accent-light)',
color: 'var(--accent-color)',
borderRadius: 'var(--border-radius-sm)',
fontWeight: 500
}}>
aktuální
</span>
)}
</h3>
<span className="text-secondary" style={{ fontSize: '12px' }}>
{monthData.fund}h ({monthData.business_days} dnů)
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{fundData.users && fundData.users.map(user => {
const us = monthData.users?.[String(user.id)]
if (!us) return null
const pct = monthData.fund > 0 ? Math.min(100, (us.covered / monthData.fund) * 100) : 0
const isFulfilled = us.covered >= monthData.fund
return (
<div key={user.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '12px' }}>
<span style={{ color: 'var(--text-primary)' }}>{us.name}</span>
<span style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<span className="text-secondary">{us.worked}h</span>
{renderMonthlyStatus(us, isFulfilled, isCurrentMonth)}
</span>
</div>
<div style={{
marginTop: '0.125rem',
height: '3px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${pct}%`,
background: getProgressBackground(us, isFulfilled, isCurrentMonth),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)
})}
</div>
</div>
</div>
)
})}
</div>
</motion.div>
)}
{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 */}
{!projectLoading && projectData.months && Object.keys(projectData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
className="mt-6"
>
<h2 className="admin-page-title mb-4" style={{ fontSize: '1.25rem' }}>
Měsíční přehled projektů {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(projectData.months).map(([monthKey, monthInfo]) => {
const isCurrentMonth = year === currentYear && parseInt(monthKey) === currentMonth
const totalHours = monthInfo.projects.reduce((sum, p) => sum + p.hours, 0)
if (monthInfo.projects.length === 0) return null
return (
<div
key={monthKey}
className="admin-card"
style={isCurrentMonth ? {
borderColor: 'var(--accent-color)',
boxShadow: '0 0 0 1px var(--accent-color)'
} : {}}
>
<div className="admin-card-body">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 style={{ fontWeight: 600, fontSize: '1rem', margin: 0 }}>
{monthInfo.month_name}
{isCurrentMonth && (
<span style={{
marginLeft: '0.5rem',
fontSize: '0.7rem',
padding: '0.125rem 0.375rem',
background: 'var(--accent-light)',
color: 'var(--accent-color)',
borderRadius: 'var(--border-radius-sm)',
fontWeight: 500
}}>
aktuální
</span>
)}
</h3>
<span className="text-secondary fw-600" style={{ fontSize: '12px' }}>
{totalHours.toFixed(1)}h
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{monthInfo.projects.map((proj) => (
<div key={proj.project_id || 'no-project'}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-primary)' }}>
{proj.project_id ? proj.project_number : 'Bez projektu'}
</span>
<span className="text-secondary fw-600" style={{ fontSize: '12px' }}>
{proj.hours.toFixed(1)}h
</span>
</div>
{proj.project_id && proj.project_name && (
<div className="text-muted" style={{ fontSize: '0.7rem', marginBottom: '0.25rem' }}>
{proj.project_name}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{proj.users.map((u) => {
const pct = proj.hours > 0 ? Math.min(100, (u.hours / proj.hours) * 100) : 0
return (
<div key={u.user_id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '11px' }}>
<span className="text-secondary">{u.user_name}</span>
<span className="text-secondary">{u.hours.toFixed(1)}h</span>
</div>
<div style={{
marginTop: '1px',
height: '3px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${pct}%`,
background: proj.project_id
? 'var(--gradient)'
: '#94a3b8',
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
</div>
)
})}
</div>
</motion.div>
)}
{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 */}
<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 }}
>
<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>
<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 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>
)}
</AnimatePresence>
{/* Reset Confirmation */}
<ConfirmModal
isOpen={resetConfirm.show}
onClose={() => setResetConfirm({ show: false, userId: null, userName: '' })}
onConfirm={handleReset}
title="Resetovat bilanci"
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
/>
</div>
)
}

View File

@@ -0,0 +1,324 @@
import { useState, useEffect } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface User {
id: number | string
name: string
}
interface CreateForm {
user_id: string
shift_date: string
leave_type: string
leave_hours: number
arrival_date: string
arrival_time: string
break_start_date: string
break_start_time: string
break_end_date: string
break_end_time: string
departure_date: string
departure_time: string
notes: string
}
export default function AttendanceCreate() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [users, setUsers] = useState<User[]>([])
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: ''
})
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()
if (!form.user_id || !form.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
}
setSubmitting(true)
try {
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)
}
}
const handleShiftDateChange = (newDate: string) => {
setForm({
...form,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate
})
}
const isWorkType = form.leave_type === 'work'
if (!hasPermission('attendance.admin')) return <Forbidden />
if (loading) {
return (
<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>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
style={{ maxWidth: '600px' }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row">
<FormField label="Zaměstnanec" required>
<select
value={form.user_id}
onChange={(e) => setForm({ ...form, user_id: e.target.value })}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</FormField>
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
/>
</FormField>
</div>
<FormField label="Typ záznamu" required>
<select
value={form.leave_type}
onChange={(e) => setForm({ ...form, leave_type: e.target.value })}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>
{!isWorkType && (
<FormField label="Počet hodin">
<input
type="number"
value={form.leave_hours}
onChange={(e) => setForm({ ...form, leave_hours: parseFloat(e.target.value) })}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">Výchozí 8 hodin pro celý den</small>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) => setForm({ ...form, arrival_date: val })}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) => setForm({ ...form, arrival_time: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) => setForm({ ...form, break_start_date: val })}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) => setForm({ ...form, break_start_time: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) => setForm({ ...form, break_end_date: val })}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) => setForm({ ...form, break_end_time: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) => setForm({ ...form, departure_date: val })}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) => setForm({ ...form, departure_time: val })}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</FormField>
<div className="admin-form-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</form>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,586 @@
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 { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } 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', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec'
]
const formatBreakRange = (record: AttendanceRecord): string => {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
}
return '—'
}
const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{record.project_logs.map((log, i) => {
let h: number, m: number, isActive = false
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0
m = parseInt(String(log.minutes)) || 0
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
</span>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '—'
}
export default function AttendanceHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const printRef = useRef<HTMLDivElement>(null)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
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])
// Compute totals client-side from raw records
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split('-')
const monthIndex = parseInt(monthStr, 10) - 1
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`
let totalMinutes = 0
let vacationHours = 0
let sickHours = 0
let holidayHours = 0
let unpaidHours = 0
for (const record of records) {
const leaveType = record.leave_type || 'work'
if (leaveType === 'work') {
totalMinutes += calculateWorkMinutes(record)
} else {
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
}
}
// Compute monthly fund (working days * 8h)
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10)
const mo = parseInt(monthStr, 10) - 1
// Count holiday records to subtract from business days
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 holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
const leaveHours = vacationHours + sickHours
const covered = Math.round((worked + leaveHours) * 100) / 100
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,
worked,
covered,
remaining,
overtime,
}
return { monthName, totalMinutes, vacationHours, sickHours, holidayHours, unpaidHours, monthlyFund }
}, [records, month])
if (!hasPermission('attendance.history')) return <Forbidden />
const handlePrint = () => {
if (!printRef.current) return
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${computed.monthName}</title>
<style>
* { 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;
}
.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; }
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
.user-header {
background: #f5f5f5;
border: 1px solid #ddd;
padding: 10px 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-header h3 { font-size: 13px; font-weight: 600; }
.user-header .total { font-size: 12px; font-weight: 600; }
.leave-summary {
margin-top: 10px;
padding: 8px 15px;
background: #f9f9f9;
border: 1px solid #ddd;
font-size: 10px;
}
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
.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; }
.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; }
@media print {
body { padding: 0; margin: 0; }
@page { size: A4 portrait; margin: 10mm; }
.user-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
${content}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Historie docházky</h1>
<p className="admin-page-subtitle">{computed.monthName}</p>
</div>
<div className="admin-page-actions">
{records.length > 0 && (
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
)}
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
</div>
</div>
</motion.div>
{/* Monthly Fund Card */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{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>
)}
{!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', 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ů
</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>
<div className="text-muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginTop: '0.375rem' }}>
<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>
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{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 && 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>
)}
</div>
</motion.div>
{/* Hidden Print Content */}
{records.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}>
<table className="print-wrapper-table">
<thead>
<tr><td>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{computed.monthName}</div>
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div>
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ''}</h3>
<span className="total">Odpracováno: {formatMinutes(computed.totalMinutes, true)}</span>
</div>
{(computed.vacationHours > 0 || computed.sickHours > 0 || computed.holidayHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && <><span className="leave-badge badge-vacation">Dovolená: {computed.vacationHours}h</span> </>}
{computed.sickHours > 0 && <><span className="leave-badge badge-sick">Nemoc: {computed.sickHours}h</span> </>}
{computed.holidayHours > 0 && <><span className="leave-badge badge-holiday">Svátek: {computed.holidayHours}h</span> </>}
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{[...records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td className="text-center">
{isLeave || !record.break_start || !record.break_end
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
}
</td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style={{ fontSize: '8px' }}>
{(record.project_logs && record.project_logs.length > 0)
? record.project_logs.map((log, i) => {
let h: number, m: number
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0
} else if (log.started_at && log.ended_at) {
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at).getTime()) / 60000))
h = Math.floor(mins2 / 60); m = mins2 % 60
} else { h = 0; m = 0 }
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
})
: record.project_name || '—'}
</td>
<td>{record.notes || ''}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">Odpracováno:</td>
<td className="text-center">{formatMinutes(computed.totalMinutes, true)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
</td></tr>
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,335 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { formatDate, formatTime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
declare const L: any
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 default function AttendanceLocation() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [loading, setLoading] = useState(true)
const [record, setRecord] = useState<LocationRecord | null>(null)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstanceRef = useRef<unknown>(null)
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance?action=location&id=${id}`)
const result = await response.json()
if (result.success) {
const raw = result.data.record || result.data
// Enrich with user_name from nested users relation
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || ''
setRecord({ ...raw, user_name: userName })
} else {
alert.error('Záznam nebyl nalezen')
navigate('/attendance/admin')
}
} catch {
alert.error('Nepodařilo se načíst data')
navigate('/attendance/admin')
} finally {
setLoading(false)
}
}
fetchData()
}, [id, alert, navigate])
useEffect(() => {
if (!record || loading) return
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
if (!hasAnyLocation || !mapRef.current) return
const loadLeaflet = async () => {
if ((window as unknown as Record<string, unknown>).L) {
initMap()
return
}
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
document.head.appendChild(link)
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.onload = initMap
document.body.appendChild(script)
}
const initMap = () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove()
}
const map = L.map(mapRef.current!)
mapInstanceRef.current = map
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map)
const bounds: [number, number][] = []
interface LocationPoint {
lat: number
lng: number
type: string
label: string
time: string
accuracy: number
}
const locations: LocationPoint[] = []
if (hasArrivalLocation) {
locations.push({
lat: parseFloat(String(record.arrival_lat)),
lng: parseFloat(String(record.arrival_lng)),
type: 'arrival',
label: 'Příchod',
time: formatTime(record.arrival_time),
accuracy: Number(record.arrival_accuracy) || 0
})
}
if (hasDepartureLocation) {
locations.push({
lat: parseFloat(String(record.departure_lat)),
lng: parseFloat(String(record.departure_lng)),
type: 'departure',
label: 'Odchod',
time: formatTime(record.departure_time),
accuracy: Number(record.departure_accuracy) || 0
})
}
locations.forEach(loc => {
const color = loc.type === 'arrival' ? '#22c55e' : '#ef4444'
const marker = L.circleMarker([loc.lat, loc.lng], {
radius: 10,
fillColor: color,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(map)
marker.bindPopup(`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`)
if (loc.accuracy > 0) {
L.circle([loc.lat, loc.lng], {
radius: loc.accuracy,
fillColor: color,
color: color,
weight: 1,
opacity: 0.3,
fillOpacity: 0.1
}).addTo(map)
}
bounds.push([loc.lat, loc.lng])
})
if (bounds.length === 1) {
map.setView(bounds[0], 16)
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [50, 50] })
}
}
loadLeaflet()
return () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove()
mapInstanceRef.current = null
}
}
}, [record, loading])
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`
}
if (!hasPermission('attendance.admin')) return <Forbidden />
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton-line" style={{ width: '100%', height: '300px', borderRadius: '8px' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem' }}>
{[0, 1].map(i => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line h-8" style={{ width: '50%' }} />
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-3/4" />
</div>
</div>
))}
</div>
</div>
)
}
if (!record) {
return null
}
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
const shiftDateStr = record.shift_date.includes('T') ? record.shift_date.split('T')[0] : record.shift_date
const month = shiftDateStr.substring(0, 7)
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link to={`/attendance/admin?month=${month}`} className="admin-btn admin-btn-secondary">
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div
ref={mapRef}
className="attendance-location-map"
/>
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div className={`attendance-location-card ${!hasArrivalLocation ? 'empty' : ''}`}>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.arrival_time ? formatDatetimeLocal(record.arrival_time) : '—'}
</div>
{hasArrivalLocation ? (
<>
<div className="attendance-location-address">
{record.arrival_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy && ` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div className={`attendance-location-card ${!hasDepartureLocation ? 'empty' : ''}`}>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.departure_time ? formatDatetimeLocal(record.departure_time) : '—'}
</div>
{hasDepartureLocation ? (
<>
<div className="attendance-location-address">
{record.departure_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy && ` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
)}
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,437 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import Forbidden from '../components/Forbidden'
import Pagination from '../components/Pagination'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import { czechPlural } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const ACTION_LABELS: Record<string, string> = {
create: 'Vytvoření',
update: 'Úprava',
delete: 'Smazání',
login: 'Přihlášení',
login_failed: 'Neúspěšné přihlášení',
logout: 'Odhlášení',
view: 'Zobrazení',
activate: 'Aktivace',
deactivate: 'Deaktivace',
password_change: 'Změna hesla',
permission_change: 'Změna oprávnění',
access_denied: 'Přístup odepřen',
}
const ACTION_BADGE_CLASS: Record<string, string> = {
create: 'admin-badge-success',
update: 'admin-badge-info',
delete: 'admin-badge-danger',
login: 'admin-badge-secondary',
login_failed: 'admin-badge-danger',
logout: 'admin-badge-secondary',
view: 'admin-badge-info',
activate: 'admin-badge-success',
deactivate: 'admin-badge-warning',
password_change: 'admin-badge-info',
permission_change: 'admin-badge-warning',
access_denied: 'admin-badge-danger',
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
interface AuditLogEntry {
id: number
created_at: string
username: string | null
action: string
entity_type: string | null
description: string | null
user_ip: string | null
}
interface PaginationData {
total: number
page: number
per_page: number
total_pages: number
}
interface Filters {
search: string
action: string
entity_type: string
date_from: string
date_to: string
}
export default function AuditLog() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [logs, setLogs] = useState<AuditLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState<PaginationData | null>(null)
const [filters, setFilters] = useState<Filters>({
search: '',
action: '',
entity_type: '',
date_from: '',
date_to: '',
})
const [showCleanup, setShowCleanup] = useState(false)
const [cleanupDays, setCleanupDays] = useState(90)
const [cleaning, setCleaning] = useState(false)
const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (filters.search) params.set('search', filters.search)
if (filters.action) params.set('action', filters.action)
if (filters.entity_type) params.set('entity_type', filters.entity_type)
if (filters.date_from) params.set('date_from', filters.date_from)
if (filters.date_to) params.set('date_to', filters.date_to)
const response = await apiFetch(`${API_BASE}/audit-log?${params.toString()}`)
const data = await response.json()
if (data.success) {
setLogs(Array.isArray(data.data) ? data.data : [])
setPagination({
total: data.pagination?.total ?? 0,
page: data.pagination?.page ?? 1,
per_page: data.pagination?.limit ?? 50,
total_pages: data.pagination?.total_pages ?? 1,
})
} else {
alert.error(data.error || 'Nepodařilo se načíst audit log')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [filters, alert])
useEffect(() => {
fetchLogs()
}, [fetchLogs])
if (!hasPermission('settings.audit')) {
return <Forbidden />
}
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
const handlePageChange = (newPage: number) => {
fetchLogs(newPage, pagination?.per_page || 50)
}
const handlePerPageChange = (newPerPage: number) => {
fetchLogs(1, newPerPage)
}
const handleCleanup = async () => {
setCleaning(true)
try {
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: cleanupDays }),
})
const data = await response.json()
if (data.success) {
alert.success(data.message)
setShowCleanup(false)
fetchLogs()
} else {
alert.error(data.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setCleaning(false)
}
}
const formatDatetime = (dateString: string | null): string => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('cs-CZ')
}
if (loading && logs.length === 0) {
return (
<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: '160px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '100px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '4px' }} />
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line" style={{ width: '120px' }} />
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div className="admin-skeleton-line" style={{ width: '70px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div className="admin-skeleton-line flex-1" />
<div className="admin-skeleton-line" style={{ width: '90px' }} />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Audit log</h1>
{pagination && (
<p className="admin-page-subtitle">
{pagination.total} {czechPlural(pagination.total, 'záznam', 'záznamy', 'záznamů')}
</p>
)}
</div>
<button
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => setShowCleanup(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
Vyčistit
</button>
</motion.div>
{showCleanup && (
<div className="admin-modal-overlay" style={{ opacity: 1 }}>
<div className="admin-modal-backdrop" onClick={() => !cleaning && setShowCleanup(false)} />
<motion.div
className="admin-modal admin-confirm-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-danger">
<svg width="24" height="24" 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>
</div>
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
<div style={{ margin: '0.75rem auto', maxWidth: '200px' }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
<p className="admin-confirm-message" style={{ fontSize: '12px', opacity: 0.6 }}>Tato akce je nevratná.</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowCleanup(false)}
className="admin-btn admin-btn-secondary"
disabled={cleaning}
>
Zrušit
</button>
<button
type="button"
onClick={handleCleanup}
className="admin-btn admin-btn-primary"
disabled={cleaning}
>
{cleaning ? 'Mažu...' : 'Smazat'}
</button>
</div>
</motion.div>
</div>
)}
<motion.div
className="admin-card mb-4"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row admin-form-row-5">
<FormField label="Hledat">
<input
type="text"
className="admin-form-input"
placeholder="Popis, uživatel..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
</FormField>
<FormField label="Akce">
<select
className="admin-form-select"
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
>
<option value="">Všechny</option>
{ACTION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
<FormField label="Typ entity">
<select
className="admin-form-select"
value={filters.entity_type}
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
>
<option value="">Všechny</option>
{ENTITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
<FormField label="Od">
<AdminDatePicker
mode="date"
value={filters.date_from}
onChange={(val: string) => handleFilterChange('date_from', val)}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={filters.date_to}
onChange={(val: string) => handleFilterChange('date_to', val)}
/>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{loading && Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td><div className="admin-skeleton-line" style={{ width: '110px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '70px', height: '22px', borderRadius: '10px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '60%', height: '14px' }} /></td>
<td><div className="admin-skeleton-line" style={{ width: '90px', height: '14px' }} /></td>
</tr>
))}
{!loading && logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td>
</tr>
)}
{!loading && logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">{formatDatetime(log.created_at)}</td>
<td className="fw-500">{log.username || '-'}</td>
<td>
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>{ENTITY_TYPE_LABELS[log.entity_type || ''] || log.entity_type || '-'}</td>
<td>{log.description || '-'}</td>
<td className="admin-mono">{log.user_ip || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
/>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,788 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import FormField from '../components/FormField'
import { motion } from 'framer-motion'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const DEFAULT_FIELD_ORDER = ['street', 'city_postal', 'country', 'company_id', 'vat_id']
const FIELD_LABELS: Record<string, string> = {
street: 'Ulice',
city_postal: 'Město + PSČ',
country: 'Země',
company_id: 'IČO',
vat_id: 'DIČ',
}
const currentYear = new Date().getFullYear().toString().slice(-2)
interface CustomField {
name: string
value: string
showLabel: boolean
_key: string
}
interface CompanyForm {
company_name: string
street: string
city: string
postal_code: string
country: string
company_id: string
vat_id: string
quotation_prefix: string
default_currency: string
default_vat_rate: number
order_type_code: string
invoice_type_code: string
}
interface BankAccount {
id: number
account_name: string
bank_name: string
account_number: string
iban: string
bic: string
currency: string
is_default: boolean
}
interface BankForm {
account_name: string
bank_name: string
account_number: string
iban: string
bic: string
currency: string
is_default: boolean
}
export default function CompanySettings() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const logoUrlRef = useRef<string | null>(null)
const [form, setForm] = useState<CompanyForm>({
company_name: '',
street: '',
city: '',
postal_code: '',
country: '',
company_id: '',
vat_id: '',
quotation_prefix: 'N',
default_currency: 'EUR',
default_vat_rate: 21,
order_type_code: '71',
invoice_type_code: '81',
})
const [customFields, setCustomFields] = useState<CustomField[]>([])
const customFieldKeyCounter = useRef(0)
const [fieldOrder, setFieldOrder] = useState<string[]>([...DEFAULT_FIELD_ORDER])
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([])
const [bankLoading, setBankLoading] = useState(true)
const [bankSaving, setBankSaving] = useState(false)
const [editingBank, setEditingBank] = useState<number | null>(null)
const [bankForm, setBankForm] = useState<BankForm>({ account_name: '', bank_name: '', account_number: '', iban: '', bic: '', currency: 'CZK', is_default: false })
const getFullFieldOrder = useCallback((): string[] => {
const allBuiltIn = [...DEFAULT_FIELD_ORDER]
const order = [...fieldOrder].filter(k => k !== 'company_name')
for (const f of allBuiltIn) {
if (!order.includes(f)) order.push(f)
}
for (let i = 0; i < customFields.length; i++) {
const key = `custom_${i}`
if (!order.includes(key)) order.push(key)
}
return order.filter(key => {
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
return idx < customFields.length
}
return true
})
}, [fieldOrder, customFields])
const moveField = (index: number, direction: number) => {
const order = getFullFieldOrder()
const newIndex = index + direction
if (newIndex < 0 || newIndex >= order.length) return
const updated = [...order]
;[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]]
setFieldOrder(updated)
}
const getFieldDisplayName = (key: string): string => {
if (FIELD_LABELS[key]) return FIELD_LABELS[key]
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
const cf = customFields[idx]
if (cf) return cf.name ? `${cf.name}: ${cf.value || '...'}` : cf.value || `Vlastní pole ${idx + 1}`
}
return key
}
const fetchLogo = useCallback(async () => {
try {
const resp = await apiFetch(`${API_BASE}/company-settings/logo`)
if (resp.ok) {
const blob = await resp.blob()
setLogoUrl(prev => {
if (prev) URL.revokeObjectURL(prev)
const url = URL.createObjectURL(blob)
logoUrlRef.current = url
return url
})
}
} catch {
// ignore - no logo
}
}, [])
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const d = result.data
setForm({
company_name: d.company_name || '',
street: d.street || '',
city: d.city || '',
postal_code: d.postal_code || '',
country: d.country || '',
company_id: d.company_id || '',
vat_id: d.vat_id || '',
quotation_prefix: d.quotation_prefix || 'N',
default_currency: d.default_currency || 'EUR',
default_vat_rate: d.default_vat_rate || 21,
order_type_code: d.order_type_code || '71',
invoice_type_code: d.invoice_type_code || '81',
})
const cf = Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map((f: { name: string; value: string; showLabel?: boolean }) => ({ ...f, _key: `cf-${++customFieldKeyCounter.current}` }))
: []
setCustomFields(cf)
if (Array.isArray(d.supplier_field_order) && d.supplier_field_order.length > 0) {
setFieldOrder(d.supplier_field_order)
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER])
}
if (d.has_logo) {
fetchLogo()
}
} else {
alert.error(result.error || 'Nepodařilo se načíst nastavení')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert, fetchLogo])
const fetchBankAccounts = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/bank-accounts`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setBankAccounts(result.data)
}
} catch {
// ignore
} finally {
setBankLoading(false)
}
}, [])
const resetBankForm = () => {
setEditingBank(null)
setBankForm({ account_name: '', bank_name: '', account_number: '', iban: '', bic: '', currency: 'CZK', is_default: false })
}
const handleBankSave = async () => {
if (!bankForm.account_name.trim()) {
alert.error('Název účtu je povinný')
return
}
setBankSaving(true)
try {
const isEdit = editingBank !== null
const url = isEdit ? `${API_BASE}/bank-accounts/${editingBank}` : `${API_BASE}/bank-accounts`
const response = await apiFetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bankForm)
})
const result = await response.json()
if (result.success) {
alert.success(result.message)
resetBankForm()
fetchBankAccounts()
} else {
alert.error(result.error || 'Chyba při ukládání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setBankSaving(false)
}
}
const handleBankDelete = async (id: number) => {
if (!confirm('Opravdu smazat tento bankovní účet?')) return
try {
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
alert.success(result.message)
if (editingBank === id) resetBankForm()
fetchBankAccounts()
} else {
alert.error(result.error || 'Chyba při mazání')
}
} catch {
alert.error('Chyba připojení')
}
}
const startEditBank = (account: BankAccount) => {
setEditingBank(account.id)
setBankForm({
account_name: account.account_name || '',
bank_name: account.bank_name || '',
account_number: account.account_number || '',
iban: account.iban || '',
bic: account.bic || '',
currency: account.currency || 'CZK',
is_default: !!account.is_default
})
}
useEffect(() => {
fetchData()
fetchBankAccounts()
}, [fetchData, fetchBankAccounts])
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (logoUrlRef.current) URL.revokeObjectURL(logoUrlRef.current)
}
}, [])
const handleSave = async () => {
setSaving(true)
try {
const payload = {
...form,
custom_fields: customFields.filter(f => f.name.trim() || f.value.trim()),
supplier_field_order: getFullFieldOrder(),
}
const response = await apiFetch(`${API_BASE}/company-settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Nastavení bylo uloženo')
} else {
alert.error(result.error || 'Nepodařilo se uložit nastavení')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingLogo(true)
try {
const formData = new FormData()
formData.append('logo', file)
const response = await apiFetch(`${API_BASE}/company-settings/logo`, {
method: 'POST',
body: formData
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Logo bylo nahráno')
fetchLogo()
} else {
alert.error(result.error || 'Nepodařilo se nahrát logo')
}
} catch {
alert.error('Chyba připojení')
} finally {
setUploadingLogo(false)
e.target.value = ''
}
}
const updateField = (field: keyof CompanyForm, value: string | number) => {
setForm(prev => ({ ...prev, [field]: value }))
}
if (!hasPermission('offers.settings')) return <Forbidden />
if (loading) {
return (
<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 className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1.25rem' }}>
{[0, 1, 2, 3, 4, 5].map(i => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-line h-8" style={{ width: '60%' }} />
{[0, 1, 2].map(j => (
<div key={j} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
const fullFieldOrder = getFullFieldOrder()
const renderBankButtonContent = (): React.ReactNode => {
if (bankSaving) {
return <><div className="admin-spinner admin-spinner-sm" />Ukládání...</>
}
if (editingBank !== null) return 'Uložit změny'
return (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat účet
</>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Nastavení firmy</h1>
<p className="admin-page-subtitle">Firemní údaje, číslování dokladů a výchozí hodnoty</p>
</div>
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : 'Uložit nastavení'}
</button>
</motion.div>
<div className="offers-settings-grid">
{/* Company Info */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Firemní údaje</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<FormField label="Název firmy">
<input type="text" value={form.company_name} onChange={(e) => updateField('company_name', e.target.value)} className="admin-form-input" />
</FormField>
<div className="admin-form-row">
<FormField label="Ulice">
<input type="text" value={form.street} onChange={(e) => updateField('street', e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="Město">
<input type="text" value={form.city} onChange={(e) => updateField('city', e.target.value)} className="admin-form-input" />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="PSČ">
<input type="text" value={form.postal_code} onChange={(e) => updateField('postal_code', e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="Země">
<input type="text" value={form.country} onChange={(e) => updateField('country', e.target.value)} className="admin-form-input" />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="IČO">
<input type="text" value={form.company_id} onChange={(e) => updateField('company_id', e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="DIČ">
<input type="text" value={form.vat_id} onChange={(e) => updateField('vat_id', e.target.value)} className="admin-form-input" />
</FormField>
</div>
<div style={{ marginTop: 4 }}>
<label className="admin-form-label" style={{ display: 'block', marginBottom: 4 }}>Vlastní pole</label>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div className="admin-form-row" style={{ marginBottom: 0, alignItems: 'flex-end' }}>
<FormField label={idx === 0 ? 'Název' : '\u00A0'} style={{ flex: 1 }}>
<input
type="text"
value={field.name}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], name: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
placeholder="Např. Tel."
/>
</FormField>
<FormField label={idx === 0 ? 'Hodnota' : '\u00A0'} style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="text"
value={field.value}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], value: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const key = `custom_${idx}`
setFieldOrder(prev =>
prev
.filter(k => k !== key)
.map(k => {
if (k.startsWith('custom_')) {
const ki = parseInt(k.split('_')[1])
if (ki > idx) return `custom_${ki - 1}`
}
return k
})
)
setCustomFields(customFields.filter((_, i) => i !== idx))
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</FormField>
</div>
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
<input
type="checkbox"
checked={field.showLabel !== false}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], showLabel: e.target.checked }
setCustomFields(updated)
}}
/>
<span style={{ fontSize: '0.8rem' }}>Zobrazit název v PDF</span>
</label>
</div>
))}
<button
type="button"
onClick={() => setCustomFields([...customFields, { name: '', value: '', showLabel: true, _key: `cf-${++customFieldKeyCounter.current}` }])}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: '0.85rem' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat pole
</button>
</div>
</div>
</div>
</motion.div>
{/* Bank Accounts */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Bankovní účty</h3>
</div>
<div className="admin-card-body">
{bankLoading ? (
<div className="admin-skeleton" style={{ gap: '1rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
) : (
<>
{bankAccounts.length > 0 && (
<div className="admin-table-responsive mb-4">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Banka</th>
<th>Číslo účtu</th>
<th>IBAN</th>
<th>BIC/SWIFT</th>
<th>Měna</th>
<th style={{ width: 70 }}>Výchozí</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{bankAccounts.map(acc => (
<tr key={acc.id} style={editingBank === acc.id ? { background: 'var(--bg-tertiary)' } : undefined}>
<td>{acc.account_name}</td>
<td>{acc.bank_name}</td>
<td className="admin-mono">{acc.account_number}</td>
<td className="admin-mono">{acc.iban}</td>
<td className="admin-mono">{acc.bic}</td>
<td>{acc.currency}</td>
<td className="text-center">
{acc.is_default ? <span className="text-accent fw-600"></span> : '\u2013'}
</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button type="button" onClick={() => startEditBank(acc)} className="admin-btn-icon" title="Upravit" aria-label="Upravit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button type="button" onClick={() => handleBankDelete(acc.id)} className="admin-btn-icon danger" title="Smazat" aria-label="Smazat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ background: 'var(--bg-tertiary)', borderRadius: 'var(--border-radius)', padding: 16 }}>
<h4 className="text-secondary" style={{ margin: '0 0 12px', fontSize: '0.9rem' }}>
{editingBank !== null ? 'Upravit účet' : 'Přidat nový účet'}
</h4>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Název účtu" required>
<input type="text" value={bankForm.account_name} onChange={e => setBankForm(f => ({ ...f, account_name: e.target.value }))} className="admin-form-input" placeholder="Např. Hlavní CZK účet" />
</FormField>
<FormField label="Název banky">
<input type="text" value={bankForm.bank_name} onChange={e => setBankForm(f => ({ ...f, bank_name: e.target.value }))} className="admin-form-input" placeholder="Např. MONETA Money Bank, a.s." />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Číslo účtu">
<input type="text" value={bankForm.account_number} onChange={e => setBankForm(f => ({ ...f, account_number: e.target.value }))} className="admin-form-input" placeholder="123456789/0600" />
</FormField>
<FormField label="Měna">
<select value={bankForm.currency} onChange={e => setBankForm(f => ({ ...f, currency: e.target.value }))} className="admin-form-select">
<option value="CZK">CZK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="IBAN">
<input type="text" value={bankForm.iban} onChange={e => setBankForm(f => ({ ...f, iban: e.target.value }))} className="admin-form-input" placeholder="CZ65 0800 0000 1920 0014 5399" />
</FormField>
<FormField label="BIC / SWIFT">
<input type="text" value={bankForm.bic} onChange={e => setBankForm(f => ({ ...f, bic: e.target.value }))} className="admin-form-input" placeholder="GIBACZPX" />
</FormField>
</div>
<label className="admin-form-checkbox">
<input type="checkbox" checked={bankForm.is_default} onChange={e => setBankForm(f => ({ ...f, is_default: e.target.checked }))} />
<span>Výchozí účet (použije se automaticky při vytváření faktury)</span>
</label>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" onClick={handleBankSave} className="admin-btn admin-btn-primary" disabled={bankSaving} style={{ fontSize: '0.85rem' }}>
{renderBankButtonContent()}
</button>
{editingBank !== null && (
<button type="button" onClick={resetBankForm} className="admin-btn admin-btn-secondary" style={{ fontSize: '0.85rem' }}>
Zrušit
</button>
)}
</div>
</div>
</div>
</>
)}
</div>
</motion.div>
{/* PDF Field Order */}
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.08 }}>
<div className="admin-card-header">
<h3 className="admin-card-title">Pořadí polí dodavatele v PDF</h3>
</div>
<div className="admin-card-body">
<small className="admin-form-hint" style={{ display: 'block', marginBottom: 12 }}>
Určuje pořadí řádků v adresním bloku dodavatele na PDF nabídce.
</small>
<div className="admin-reorder-list">
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button type="button" onClick={() => moveField(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button type="button" onClick={() => moveField(index, 1)} disabled={index === fullFieldOrder.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
</div>
<span className={`admin-reorder-label${key.startsWith('custom_') ? ' accent' : ''}`}>
{getFieldDisplayName(key)}
</span>
</div>
))}
</div>
</div>
</motion.div>
{/* Logo */}
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<div className="admin-card-header">
<h3 className="admin-card-title">Logo</h3>
</div>
<div className="admin-card-body">
<div className="offers-logo-section">
{logoUrl && (
<div className="offers-logo-preview">
<img src={logoUrl} alt="Logo" />
</div>
)}
<label className="admin-btn admin-btn-secondary" style={{ cursor: 'pointer' }}>
{uploadingLogo ? (
<><div className="admin-spinner admin-spinner-sm" />Nahrávání...</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát logo
</>
)}
<input type="file" accept="image/*" onChange={handleLogoUpload} style={{ display: 'none' }} disabled={uploadingLogo} />
</label>
<small className="admin-form-hint">PNG, JPEG, GIF nebo WebP, max 5 MB</small>
</div>
</div>
</motion.div>
{/* Cislovani dokladu */}
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<div className="admin-card-header">
<h3 className="admin-card-title">Číslování dokladů</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<FormField label="Nabídky — prefix">
<input type="text" value={form.quotation_prefix} onChange={(e) => updateField('quotation_prefix', e.target.value)} className="admin-form-input" placeholder="N" style={{ maxWidth: 120 }} />
<small className="admin-form-hint">
Formát: ROK/PREFIX/ČÍSLO ukázka: {new Date().getFullYear()}/{form.quotation_prefix || 'N'}/001
</small>
</FormField>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
<FormField label="Objednávky a projekty — typový kód">
<input type="text" value={form.order_type_code} onChange={(e) => updateField('order_type_code', e.target.value)} className="admin-form-input" placeholder="71" style={{ maxWidth: 120 }} />
<small className="admin-form-hint">
Formát: RRKÓD#### ukázka: {currentYear}{form.order_type_code || '71'}0001
</small>
</FormField>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
<FormField label="Faktury — typový kód">
<input type="text" value={form.invoice_type_code} onChange={(e) => updateField('invoice_type_code', e.target.value)} className="admin-form-input" placeholder="81" style={{ maxWidth: 120 }} />
<small className="admin-form-hint">
Formát: RRKÓD#### ukázka: {currentYear}{form.invoice_type_code || '81'}0001
</small>
</FormField>
</div>
</div>
</motion.div>
{/* Default values */}
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<div className="admin-card-header">
<h3 className="admin-card-title">Výchozí hodnoty</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<select value={form.default_currency} onChange={(e) => updateField('default_currency', e.target.value)} className="admin-form-select">
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="CZK">CZK</option>
<option value="GBP">GBP</option>
</select>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<input type="number" value={form.default_vat_rate} onChange={(e) => updateField('default_vat_rate', parseFloat(e.target.value) || 0)} className="admin-form-input" step="0.1" />
</FormField>
</div>
</div>
</div>
</motion.div>
</div>
</div>
)
}

View File

@@ -0,0 +1,378 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
import { getCzechDate } from '../utils/dashboardHelpers'
import DashKpiCards from '../components/dashboard/DashKpiCards'
import DashQuickActions from '../components/dashboard/DashQuickActions'
import DashActivityFeed from '../components/dashboard/DashActivityFeed'
import DashAttendanceToday from '../components/dashboard/DashAttendanceToday'
import DashProfile from '../components/dashboard/DashProfile'
import DashSessions from '../components/dashboard/DashSessions'
const API_BASE = '/api/admin'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DashData = Record<string, any>
export default function Dashboard() {
const { user, updateUser } = useAuth()
const alert = useAlert()
const [dashData, setDashData] = useState<DashData | null>(null)
const [dashLoading, setDashLoading] = useState(true)
const [punching, setPunching] = useState(false)
// 2FA state - sdileny mezi profilem a bannerem
const [totpEnabled, setTotpEnabled] = useState(false)
const [totpLoading, setTotpLoading] = useState(true)
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
const [totpSecret, setTotpSecret] = useState<string | null>(null)
const [totpQrUri, setTotpQrUri] = useState<string | null>(null)
const [totpCode, setTotpCode] = useState('')
const [totpSubmitting, setTotpSubmitting] = useState(false)
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
const [disableCode, setDisableCode] = useState('')
useModalLock(show2FASetup)
useModalLock(show2FADisable)
const fetchDashboard = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/dashboard`)
const data = await response.json()
if (data.success !== false) {
setDashData(data.data || data)
}
} catch (err) {
if (import.meta.env.DEV) {
console.error('Dashboard fetch error:', err)
}
} finally {
setDashLoading(false)
}
}, [])
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
// 2FA status fetch
const fetch2FAStatus = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/totp/setup`)
const data = await response.json()
if (data.success) {
setTotpEnabled(!!user?.totpEnabled)
}
} catch {
// 2FA status fetch failed silently
setTotpEnabled(!!user?.totpEnabled)
} finally {
setTotpLoading(false)
}
}, [user?.totpEnabled])
useEffect(() => {
fetch2FAStatus()
}, [fetch2FAStatus])
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = () => {
const action = dashData?.my_shift?.has_ongoing ? 'departure' : 'arrival'
setPunching(true)
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ punch_action: action, ...gpsData })
})
const result = await response.json()
if (result.success) {
alert.success(result.data?.message || 'Docházka zaznamenána')
fetchDashboard()
} else {
alert.error(result.error || 'Chyba při záznamu docházky')
}
} catch {
alert.error('Chyba pripojeni')
} finally {
setPunching(false)
}
}
if (!navigator.geolocation) {
submitPunch({})
return
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude, accuracy } = pos.coords
submitPunch({ latitude, longitude, accuracy, address: '' })
},
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
)
}
// 2FA handlery
const handleStart2FASetup = async () => {
setTotpSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/totp/setup`)
const data = await response.json()
if (data.success) {
setTotpSecret(data.data.secret)
setTotpQrUri(data.data.uri || data.data.qr_uri)
setTotpCode('')
setBackupCodes(null)
setShow2FASetup(true)
} else {
alert.error(data.error || 'Nepodařilo se vygenerovat 2FA klíč')
}
} catch {
alert.error('Chyba připojení')
} finally {
setTotpSubmitting(false)
}
}
const handleConfirm2FA = async () => {
if (!totpCode.trim()) return
setTotpSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/totp/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() })
})
const data = await response.json()
if (data.success) {
setTotpEnabled(true)
setBackupCodes(data.data?.backup_codes || null)
setTotpSecret(null)
setTotpQrUri(null)
updateUser({ totpEnabled: true })
alert.success('2FA bylo aktivováno')
} else {
alert.error(data.error || 'Neplatný kód')
setTotpCode('')
}
} catch {
alert.error('Chyba připojení')
} finally {
setTotpSubmitting(false)
}
}
const handleDisable2FA = async () => {
if (!disableCode.trim()) return
setTotpSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/totp/disable`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: disableCode.trim() })
})
const data = await response.json()
if (data.success) {
setTotpEnabled(false)
setShow2FADisable(false)
setDisableCode('')
updateUser({ totpEnabled: false })
alert.success('2FA bylo deaktivováno')
} else {
alert.error(data.error || 'Neplatný kód')
setDisableCode('')
}
} catch {
alert.error('Chyba připojení')
} finally {
setTotpSubmitting(false)
}
}
return (
<div className="dash">
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">
Vítejte zpět, {user?.fullName || user?.username}
</h1>
<p className="admin-page-subtitle">{getCzechDate()}</p>
</div>
</motion.div>
{/* 2FA Required Banner */}
{user?.require2FA && !user?.totpEnabled && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ border: '2px solid var(--danger)', background: 'var(--danger-light)' }}
>
<div className="admin-card-body" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div className="flex-row-gap">
<div style={{
width: 40, height: 40, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--danger-light)', color: 'var(--danger)', flexShrink: 0
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<div>
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
<div className="text-secondary" style={{ fontSize: '0.875rem' }}>
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete, nemáte přístup k ostatním sekcím systému.
</div>
</div>
</div>
<button onClick={handleStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary" style={{ flexShrink: 0 }}>
{totpSubmitting ? 'Generuji...' : 'Aktivovat 2FA nyní'}
</button>
</div>
</motion.div>
)}
{/* Skeleton loading */}
{dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.25rem' }}>
<div className="dash-kpi-grid dash-kpi-4">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-line h-24" style={{ borderRadius: '10px' }} />
))}
</div>
<div className="dash-quick-actions">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '10px' }} />
))}
</div>
<div className="dash-main-grid">
<div className="admin-skeleton-line" style={{ height: '320px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ height: '320px', borderRadius: '10px' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div className="admin-skeleton-line" style={{ height: '150px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ height: '150px', borderRadius: '10px' }} />
</div>
</div>
<div className="dash-bottom">
<div className="admin-skeleton-line" style={{ height: '200px', borderRadius: '10px' }} />
<div className="admin-skeleton-line" style={{ height: '200px', borderRadius: '10px' }} />
</div>
</div>
)}
{/* KPI cards */}
{!dashLoading && <DashKpiCards dashData={dashData} />}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions
dashData={dashData}
punching={punching}
onPunch={handleQuickPunch}
/>
)}
{/* Main content grid */}
{!dashLoading && <motion.div
className="dash-main-grid"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<DashActivityFeed activities={dashData?.recent_activity} />
<DashAttendanceToday attendance={dashData?.attendance} />
{/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col">
{dashData?.projects && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Aktivní projekty</h2>
<Link to="/projects" className="admin-btn admin-btn-primary admin-btn-sm">Vše &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{dashData.projects.active_projects.length === 0 && (
<div className="dash-empty-row">Žádné aktivní projekty</div>
)}
{dashData.projects.active_projects.map((p: { id: number; name: string; customer_name: string | null }) => (
<Link key={p.id} to={`/projects/${p.id}`} className="dash-project-row">
<div className="dash-project-name">{p.name}</div>
{p.customer_name && <div className="dash-project-customer">{p.customer_name}</div>}
</Link>
))}
</div>
</div>
)}
{dashData?.offers && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Nabídky</h2>
<Link to="/offers" className="admin-btn admin-btn-primary admin-btn-sm">Zobrazit &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-stat-row">
<span>Otevřené</span>
<span className="admin-badge admin-badge-info">{dashData.offers.open_count}</span>
</div>
<div className="dash-stat-row">
<span>Převedené na objednávku</span>
<span className="admin-badge admin-badge-success">{dashData.offers.converted_count}</span>
</div>
<div className="dash-stat-row">
<span>Prošlé</span>
<span className="admin-badge admin-badge-warning">{dashData.offers.expired_count}</span>
</div>
</div>
</div>
)}
</div>
</motion.div>}
{/* Profile + Sessions */}
{!dashLoading && <div className="dash-bottom">
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
totpSubmitting={totpSubmitting}
onStart2FASetup={handleStart2FASetup}
onConfirm2FA={handleConfirm2FA}
onDisable2FA={handleDisable2FA}
totpSecret={totpSecret}
totpQrUri={totpQrUri}
totpCode={totpCode}
setTotpCode={setTotpCode}
backupCodes={backupCodes}
setBackupCodes={setBackupCodes}
show2FASetup={show2FASetup}
setShow2FASetup={setShow2FASetup}
show2FADisable={show2FADisable}
setShow2FADisable={setShow2FADisable}
disableCode={disableCode}
setDisableCode={setDisableCode}
/>
<DashSessions />
</div>}
</div>
)
}

View File

@@ -0,0 +1,597 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import { motion } from 'framer-motion'
import apiFetch from '../utils/api'
import { formatCurrency } from '../utils/formatters'
const API_BASE = '/api/admin'
const VAT_OPTIONS = [
{ value: 21, label: '21%' },
{ value: 12, label: '12%' },
{ value: 0, label: '0%' }
]
interface InvoiceItem {
_key: string
description: string
quantity: number
unit: string
unit_price: number
vat_rate: number
}
interface Customer {
id: number
name: string
company_id?: string
city?: string
}
interface BankAccount {
id: number
account_name: string
account_number?: string
bank_name?: string
bic?: string
iban?: string
is_default?: boolean
}
interface InvoiceForm {
customer_id: number | null
customer_name: string
order_id: number | null
issue_date: string
due_date: string
tax_date: string
currency: string
apply_vat: number
vat_rate: number
payment_method: string
constant_symbol: string
issued_by: string
notes: string
bank_account_id: number | string
bank_name: string
bank_swift: string
bank_iban: string
bank_account: string
}
export default function InvoiceCreate() {
const keyCounterRef = useRef(0)
const emptyItem = useCallback((): InvoiceItem => ({
_key: `inv-${++keyCounterRef.current}`,
description: '',
quantity: 1,
unit: 'ks',
unit_price: 0,
vat_rate: 21
}), [])
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const alert = useAlert()
const { hasPermission, user } = useAuth()
const rawOrderId = searchParams.get('fromOrder')
const fromOrderId = rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null
const [form, setForm] = useState<InvoiceForm>({
customer_id: null,
customer_name: '',
order_id: fromOrderId ? Number(fromOrderId) : null,
issue_date: new Date().toISOString().split('T')[0],
due_date: new Date(Date.now() + 14 * 86400000).toISOString().split('T')[0],
tax_date: new Date().toISOString().split('T')[0],
currency: 'CZK',
apply_vat: 1,
vat_rate: 21,
payment_method: 'Příkazem',
constant_symbol: '0308',
issued_by: user?.fullName || '',
notes: '',
bank_account_id: '',
bank_name: '',
bank_swift: '',
bank_iban: '',
bank_account: ''
})
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([])
const [dueDays, setDueDays] = useState(14)
const [items, setItems] = useState<InvoiceItem[]>([emptyItem()])
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [loadingInit, setLoadingInit] = useState(true)
const [invoiceNumber, setInvoiceNumber] = useState('')
// Customer selector
const [customers, setCustomers] = useState<Customer[]>([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
// Draft
const DRAFT_KEY = 'boha_invoice_draft'
const isManual = !fromOrderId
const clearDraft = useCallback(() => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
}, [])
// Load init data
useEffect(() => {
const load = async () => {
try {
const promises = [
apiFetch(`${API_BASE}/invoices/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/bank-accounts`)
]
if (fromOrderId) {
promises.push(apiFetch(`${API_BASE}/invoices/order-data/${fromOrderId}`))
}
const results = await Promise.all(promises)
const numRes = results[0]
if (numRes.ok) {
const numData = await numRes.json()
if (numData.success) setInvoiceNumber(numData.data?.next_number || numData.data?.number || '')
}
const custRes = results[1]
if (custRes.ok) {
const custData = await custRes.json()
if (custData.success) setCustomers(Array.isArray(custData.data) ? custData.data : custData.data?.customers || [])
}
const bankRes = results[2]
if (bankRes.ok) {
const bankData = await bankRes.json()
if (bankData.success && Array.isArray(bankData.data)) {
setBankAccounts(bankData.data)
const defaultAcc = bankData.data.find((a: BankAccount) => a.is_default)
if (defaultAcc) {
setForm(prev => ({
...prev,
bank_account_id: defaultAcc.id,
bank_name: defaultAcc.bank_name || '',
bank_swift: defaultAcc.bic || '',
bank_iban: defaultAcc.iban || '',
bank_account: defaultAcc.account_number || ''
}))
}
}
}
// Pre-fill from order
if (fromOrderId && results[3]?.ok) {
const orderData = await results[3].json()
if (orderData.success) {
const order = orderData.data
const vatRate = Number(order.vat_rate) || 21
setForm(prev => ({
...prev,
customer_id: order.customer_id,
customer_name: order.customer_name || '',
order_id: order.id,
currency: order.currency || 'CZK',
apply_vat: Number(order.apply_vat) || 0,
vat_rate: vatRate
}))
if (order.items?.length > 0) {
setItems(order.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || '',
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || '',
unit_price: Number(item.unit_price) || 0,
vat_rate: vatRate
})))
}
}
}
} catch {
alert.error('Chyba při načítání dat')
} finally {
setLoadingInit(false)
}
}
load()
}, [fromOrderId, alert])
// Due date calculation
useEffect(() => {
if (!form.issue_date) return
const d = new Date(form.issue_date)
d.setDate(d.getDate() + dueDays)
setForm(prev => ({ ...prev, due_date: d.toISOString().split('T')[0] }))
}, [form.issue_date, dueDays])
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [showCustomerDropdown])
const selectBankAccount = (accountId: string) => {
const acc = bankAccounts.find(a => a.id === Number(accountId))
if (acc) {
setForm(prev => ({
...prev,
bank_account_id: acc.id,
bank_name: acc.bank_name || '',
bank_swift: acc.bic || '',
bank_iban: acc.iban || '',
bank_account: acc.account_number || ''
}))
} else {
setForm(prev => ({ ...prev, bank_account_id: '', bank_name: '', bank_swift: '', bank_iban: '', bank_account: '' }))
}
}
const selectCustomer = (customer: Customer) => {
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
setErrors(prev => ({ ...prev, customer_id: '' }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
// Items management
const updateItem = (index: number, field: keyof InvoiceItem, value: string | number) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addItem = () => setItems(prev => [...prev, emptyItem()])
const removeItem = (index: number) => {
if (items.length <= 1) return
setItems(prev => prev.filter((_, i) => i !== index))
}
// Totals
const totals = useMemo(() => {
let subtotal = 0
const vatByRate: Record<number, number> = {}
items.forEach(item => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
subtotal += lineTotal
if (form.apply_vat) {
const rate = Number(item.vat_rate) || 0
if (!vatByRate[rate]) vatByRate[rate] = 0
vatByRate[rate] += lineTotal * rate / 100
}
})
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0)
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }
}, [items, form.apply_vat])
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
const newErrors: Record<string, string> = {}
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
if (!form.issue_date) newErrors.issue_date = 'Zadejte datum'
if (!form.tax_date) newErrors.tax_date = 'Zadejte datum'
if (!form.bank_account_id) newErrors.bank_account_id = 'Vyberte bankovní účet'
if (items.length === 0 || items.every(i => !i.description.trim())) {
newErrors.items = 'Přidejte alespoň jednu položku'
}
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/invoices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...form,
invoice_number: invoiceNumber,
items: items.filter(i => i.description.trim()).map((item, i) => ({
...item,
position: i
}))
})
})
const result = await response.json()
if (result.success) {
clearDraft()
alert.success(result.message || 'Faktura byla vytvořena')
navigate(`/invoices/${result.data.invoice_id}`)
} else {
alert.error(result.error || 'Nepodařilo se vytvořit fakturu')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
if (!hasPermission('invoices.create')) return <Forbidden />
if (loadingInit) {
return (
<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">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div className="admin-page-header" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
<div className="flex-row gap-4">
<Link to="/invoices" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
Nová faktura {invoiceNumber && <span className="text-tertiary">({invoiceNumber})</span>}
</h1>
{fromOrderId && <p className="admin-page-subtitle">Z objednávky</p>}
</div>
</div>
<div className="admin-page-actions">
<button onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (<><div className="admin-spinner admin-spinner-sm" />Ukládání...</>) : 'Uložit'}
</button>
</div>
</motion.div>
<form onSubmit={handleSubmit}>
{/* Basic info */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.06 }}>
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="offers-form-row-3">
<FormField label="Číslo faktury">
<input type="text" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} className="admin-form-input" />
</FormField>
<FormField label="Odběratel" error={errors.customer_id} required>
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
<button type="button" onClick={() => setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))} className="admin-btn-icon" title="Odebrat zákazníka">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
) : (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka (název, IČ, město)..."
autoComplete="off"
/>
{showCustomerDropdown && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">Žádní zákazníci</div>
) : (
filteredCustomers.slice(0, 10).map(c => (
<div key={c.id} className="offers-customer-dropdown-item" onMouseDown={() => selectCustomer(c)}>
<div>{c.name}</div>
{(c.company_id || c.city) && (
<div>{c.company_id && `IČ: ${c.company_id}`}{c.city && ` · ${c.city}`}</div>
)}
</div>
))
)}
</div>
)}
</div>
)}
</FormField>
<FormField label="Vystavil">
<input type="text" value={form.issued_by} className="admin-form-input" readOnly style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }} />
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Datum vystavení" error={errors.issue_date} required>
<AdminDatePicker mode="date" value={form.issue_date} onChange={(val: string) => { setForm(prev => ({ ...prev, issue_date: val })); setErrors(prev => ({ ...prev, issue_date: '' })) }} />
</FormField>
<FormField label="Splatnost (dny)">
<select value={dueDays} onChange={(e) => setDueDays(Number(e.target.value))} className="admin-form-select">
{Array.from({ length: 60 }, (_, i) => i + 1).map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
{form.due_date && (
<span className="text-tertiary" style={{ fontSize: '0.75rem', marginTop: '0.25rem' }}>
Splatnost: {new Date(form.due_date).toLocaleDateString('cs-CZ')}
</span>
)}
</FormField>
<FormField label="DÚZP" error={errors.tax_date} required>
<AdminDatePicker mode="date" value={form.tax_date} onChange={(val: string) => { setForm(prev => ({ ...prev, tax_date: val })); setErrors(prev => ({ ...prev, tax_date: '' })) }} />
</FormField>
</div>
<div className="offers-form-row-3">
<FormField label="Forma úhrady">
<select value={form.payment_method} onChange={(e) => setForm(prev => ({ ...prev, payment_method: e.target.value }))} className="admin-form-select">
<option value="Příkazem">Příkazem</option>
<option value="Hotově">Hotově</option>
<option value="Dobírka">Dobírka</option>
</select>
</FormField>
<FormField label="Měna">
<select value={form.currency} onChange={(e) => setForm(prev => ({ ...prev, currency: e.target.value }))} className="admin-form-select">
<option value="CZK">CZK ()</option>
<option value="EUR">EUR</option>
<option value="USD">USD ($)</option>
</select>
</FormField>
<FormField label="DPH">
<div className="flex-row-gap">
<label className="admin-form-checkbox" style={{ whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={!!form.apply_vat} onChange={(e) => setForm(prev => ({ ...prev, apply_vat: e.target.checked ? 1 : 0 }))} />
<span>Uplatnit DPH</span>
</label>
</div>
</FormField>
</div>
<FormField label="Bankovní účet" error={errors.bank_account_id} required>
<select
value={form.bank_account_id}
onChange={(e) => { selectBankAccount(e.target.value); setErrors(prev => ({ ...prev, bank_account_id: '' })) }}
className="admin-form-select"
>
<option value="">{'\u2014'} Vyberte účet {'\u2014'}</option>
{bankAccounts.map(acc => (
<option key={acc.id} value={acc.id}>
{acc.account_name}{acc.account_number ? ` (${acc.account_number})` : ''}{acc.is_default ? ' ★' : ''}
</option>
))}
</select>
</FormField>
</div>
</motion.div>
{/* Items */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<div className="flex-between mb-4">
<div>
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
{errors.items && <span className="admin-form-error">{errors.items}</span>}
</div>
<button type="button" onClick={addItem} className="admin-btn admin-btn-primary admin-btn-sm">+ Přidat položku</button>
</div>
<div className="offers-items-table">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
{form.apply_vat ? <th style={{ width: '5rem', textAlign: 'center' }}>DPH</th> : null}
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
<th style={{ width: '2.5rem' }}></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item._key}>
<td className="text-tertiary text-center fw-500">{index + 1}</td>
<td>
<input type="text" value={item.description} onChange={(e) => updateItem(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." />
</td>
<td>
<input type="number" value={item.quantity} onChange={(e) => updateItem(index, 'quantity', e.target.value)} className="admin-form-input" min="0" step="any" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} />
</td>
<td>
<input type="text" value={item.unit} onChange={(e) => updateItem(index, 'unit', e.target.value)} className="admin-form-input" placeholder="ks" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} />
</td>
<td>
<input type="number" value={item.unit_price} onChange={(e) => updateItem(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} />
</td>
{form.apply_vat ? (
<td>
<select value={item.vat_rate} onChange={(e) => updateItem(index, 'vat_rate', Number(e.target.value))} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}>
{VAT_OPTIONS.map(o => (<option key={o.value} value={o.value}>{o.label}</option>))}
</select>
</td>
) : null}
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
{formatCurrency(lineTotal, form.currency)}
</td>
<td>
{items.length > 1 && (
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, form.currency)}</span>
</div>
{form.apply_vat && Object.entries(totals.vatByRate).map(([rate, amount]) => (
<div key={rate} className="offers-totals-row">
<span>DPH {rate}%:</span>
<span>{formatCurrency(amount, form.currency)}</span>
</div>
))}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, form.currency)}</span>
</div>
</div>
</motion.div>
{/* Notes */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<h3 className="admin-card-title">Veřejné poznámky na faktuře</h3>
<textarea
className="admin-form-input"
rows={4}
value={form.notes}
onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))}
placeholder="Poznámky zobrazené na faktuře..."
/>
</motion.div>
</form>
</div>
)
}

View File

@@ -0,0 +1,598 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate } from '../utils/formatters'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
issued: 'Vystavena',
paid: 'Zaplacena',
overdue: 'Po splatnosti'
}
const STATUS_CLASSES: Record<string, string> = {
issued: 'admin-badge-invoice-issued',
paid: 'admin-badge-invoice-paid',
overdue: 'admin-badge-invoice-overdue'
}
const TRANSITION_LABELS: Record<string, string> = { paid: 'Zaplaceno' }
const TRANSITION_CLASSES: Record<string, string> = { paid: 'admin-btn admin-btn-primary' }
const VAT_OPTIONS = [
{ value: 21, label: '21%' },
{ value: 12, label: '12%' },
{ value: 0, label: '0%' }
]
interface InvoiceItem {
id?: number
description: string
quantity: number
unit: string
unit_price: number
vat_rate: number
}
interface EditItem extends InvoiceItem {
_key: string
}
interface InvoiceCustomer {
company_id?: string
vat_id?: string
}
interface Invoice {
id: number
invoice_number: string
customer_name: string | null
customer?: InvoiceCustomer
order_id?: number
order_number?: string
currency: string
status: string
issue_date: string
due_date: string
tax_date: string
payment_method: string
issued_by: string | null
paid_date?: string
notes: string
apply_vat: number | string
items: InvoiceItem[]
valid_transitions?: string[]
}
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [invoice, setInvoice] = useState<Invoice | null>(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState<string | null>(null)
const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null }>({ show: false, status: null })
const [pdfLoading, setPdfLoading] = useState(false)
const [langModal, setLangModal] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
// Edit items
const [editingItems, setEditingItems] = useState(false)
const [editItems, setEditItems] = useState<EditItem[]>([])
const editKeyCounter = useRef(0)
const fetchDetail = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setInvoice(result.data)
setNotes(result.data.notes || '')
} else {
alert.error(result.error || 'Nepodařilo se načíst fakturu')
navigate('/invoices')
}
} catch {
alert.error('Chyba připojení')
navigate('/invoices')
} finally {
setLoading(false)
}
}, [id, alert, navigate])
useEffect(() => {
fetchDetail()
}, [fetchDetail])
const totals = useMemo(() => {
if (!invoice?.items) return { subtotal: 0, vatByRate: {} as Record<number, number>, totalVat: 0, total: 0 }
let subtotal = 0
const vatByRate: Record<number, number> = {}
invoice.items.forEach(item => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
subtotal += lineTotal
if (Number(invoice.apply_vat)) {
const rate = Number(item.vat_rate) || 0
if (!vatByRate[rate]) vatByRate[rate] = 0
vatByRate[rate] += lineTotal * rate / 100
}
})
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0)
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }
}, [invoice])
if (!hasPermission('invoices.view')) return <Forbidden />
const handleStatusChange = async () => {
if (!statusConfirm.status) return
setStatusChanging(statusConfirm.status)
setStatusConfirm({ show: false, status: null })
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: statusConfirm.status })
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Stav byl změněn')
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
} finally {
setStatusChanging(null)
}
}
const handleSaveNotes = async () => {
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes })
})
const result = await response.json()
if (result.success) {
alert.success('Poznámky byly uloženy')
} else {
alert.error(result.error || 'Nepodařilo se uložit poznámky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleViewPdf = async (lang = 'cs') => {
setLangModal(false)
const newWindow = window.open('', '_blank')
setPdfLoading(true)
try {
const response = await apiFetch(`${API_BASE}/invoices-pdf/${id}?lang=${encodeURIComponent(lang)}`)
if (!response.ok) {
newWindow?.close()
alert.error('Nepodařilo se vygenerovat PDF')
return
}
const html = await response.text()
if (newWindow) {
newWindow.document.open()
newWindow.document.write(html)
newWindow.document.close()
newWindow.onload = () => newWindow.print()
}
} catch {
newWindow?.close()
alert.error('Chyba připojení')
} finally {
setPdfLoading(false)
}
}
// Edit items
const startEditItems = () => {
if (!invoice) return
setEditItems(invoice.items.map(item => ({
_key: `ei-${++editKeyCounter.current}`,
description: item.description || '',
quantity: Number(item.quantity) || 1,
unit: item.unit || '',
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || 21
})))
setEditingItems(true)
}
const updateEditItem = (index: number, field: string, value: string | number) => {
setEditItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addEditItem = () => {
setEditItems(prev => [...prev, { _key: `ei-${++editKeyCounter.current}`, description: '', quantity: 1, unit: 'ks', unit_price: 0, vat_rate: 21 }])
}
const removeEditItem = (index: number) => {
if (editItems.length <= 1) return
setEditItems(prev => prev.filter((_, i) => i !== index))
}
const saveEditItems = async () => {
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: editItems.filter(i => i.description.trim()).map((item, i) => ({ ...item, position: i }))
})
})
const result = await response.json()
if (result.success) {
alert.success('Položky byly uloženy')
setEditingItems(false)
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se uložit položky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Faktura byla smazána')
navigate('/invoices')
} else {
alert.error(result.error || 'Nepodařilo se smazat fakturu')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
setDeleteConfirm(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row gap-2">
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
)
}
if (!invoice) return null
const isDraft = invoice.status === 'issued'
const isPaid = invoice.status === 'paid'
return (
<div>
{/* Header */}
<motion.div className="admin-page-header" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
<div className="flex-row gap-4">
<Link to="/invoices" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title flex-row-gap">
Faktura {invoice.invoice_number}
<span className={`admin-badge ${STATUS_CLASSES[invoice.status] || ''}`}>
{STATUS_LABELS[invoice.status] || invoice.status}
</span>
</h1>
</div>
</div>
<div className="admin-page-actions">
{hasPermission('invoices.export') && (
<button onClick={() => setLangModal(true)} className="admin-btn admin-btn-secondary" disabled={pdfLoading}>
{pdfLoading ? (<><div className="admin-spinner admin-spinner-sm" />PDF...</>) : (<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>PDF</>)}
</button>
)}
{hasPermission('invoices.edit') && invoice.valid_transitions?.map(status => (
<button key={status} onClick={() => setStatusConfirm({ show: true, status })} className={TRANSITION_CLASSES[status] || 'admin-btn admin-btn-secondary'} disabled={statusChanging === status}>
{statusChanging === status ? <div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> : (TRANSITION_LABELS[status] || status)}
</button>
))}
{hasPermission('invoices.delete') && (
<button onClick={() => setDeleteConfirm(true)} className="admin-btn admin-btn-primary">Smazat</button>
)}
</div>
</motion.div>
{/* Info */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.06 }}>
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form">
<div className="offers-form-row-3 mb-2">
<FormField label="Zákazník">
<div className="fw-500">{invoice.customer_name || '\u2014'}</div>
{invoice.customer && (
<div className="text-tertiary" style={{ fontSize: '0.8rem', marginTop: '0.2rem' }}>
{invoice.customer.company_id && `IČ: ${invoice.customer.company_id}`}
{invoice.customer.vat_id && ` · DIČ: ${invoice.customer.vat_id}`}
</div>
)}
</FormField>
<FormField label="Objednávka">
<div>
{invoice.order_id ? (
<Link to={`/orders/${invoice.order_id}`} className="link-accent">{invoice.order_number}</Link>
) : '\u2014'}
</div>
</FormField>
<FormField label="Měna"><div>{invoice.currency}</div></FormField>
</div>
<div className="offers-form-row-3 mb-2">
<FormField label="Datum vystavení"><div>{formatDate(invoice.issue_date)}</div></FormField>
<FormField label="Datum splatnosti">
<div className={invoice.status === 'overdue' ? 'text-danger fw-600' : ''}>{formatDate(invoice.due_date)}</div>
</FormField>
<FormField label="DÚZP"><div>{formatDate(invoice.tax_date)}</div></FormField>
</div>
<div className="offers-form-row-3">
<FormField label="Forma úhrady"><div>{invoice.payment_method}</div></FormField>
<FormField label="Variabilní symbol"><div>{invoice.invoice_number}</div></FormField>
<FormField label="Vystavil"><div>{invoice.issued_by || '\u2014'}</div></FormField>
</div>
{invoice.paid_date && (
<div className="admin-form-row mt-2">
<FormField label="Datum úhrady">
<div style={{ color: 'var(--success)', fontWeight: 500 }}>{formatDate(invoice.paid_date)}</div>
</FormField>
</div>
)}
</div>
</motion.div>
{/* Items */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<div className="flex-between mb-4">
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
{isDraft && hasPermission('invoices.edit') && (
editingItems ? (
<div className="flex-row gap-2">
<button type="button" onClick={addEditItem} className="admin-btn admin-btn-secondary admin-btn-sm">+ Přidat položku</button>
<button onClick={saveEditItems} className="admin-btn admin-btn-primary admin-btn-sm" disabled={saving}>{saving ? 'Ukládání...' : 'Uložit položky'}</button>
<button onClick={() => setEditingItems(false)} className="admin-btn admin-btn-secondary admin-btn-sm">Zrušit</button>
</div>
) : (
<button onClick={startEditItems} className="admin-btn admin-btn-secondary admin-btn-sm">Upravit položky</button>
)
)}
</div>
{editingItems ? (
<div className="offers-items-table">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
<th style={{ width: '5rem', textAlign: 'center' }}>%DPH</th>
<th style={{ width: '5.5rem' }}></th>
</tr>
</thead>
<tbody>
{editItems.map((item, index) => (
<tr key={item._key}>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td><input type="text" value={item.description} onChange={(e) => updateEditItem(index, 'description', e.target.value)} className="admin-form-input fw-500" placeholder="Popis položky..." /></td>
<td><input type="number" value={item.quantity} onChange={(e) => updateEditItem(index, 'quantity', e.target.value)} className="admin-form-input" min="0" step="any" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
<td><input type="text" value={item.unit} onChange={(e) => updateEditItem(index, 'unit', e.target.value)} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
<td><input type="number" value={item.unit_price} onChange={(e) => updateEditItem(index, 'unit_price', e.target.value)} className="admin-form-input" step="any" style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} /></td>
<td>
{Number(invoice.apply_vat) ? (
<select value={item.vat_rate} onChange={(e) => updateEditItem(index, 'vat_rate', Number(e.target.value))} className="admin-form-input" style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}>
{VAT_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : (
<span className="text-tertiary" style={{ display: 'block', textAlign: 'center' }}>0%</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
{editItems.length > 1 && (
<button type="button" onClick={() => removeEditItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<>
{invoice.items?.length > 0 ? (
<div className="offers-items-table">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '8rem', textAlign: 'right' }}>Jedn. cena</th>
<th style={{ width: '4rem', textAlign: 'center' }}>%DPH</th>
<th style={{ width: '9rem', textAlign: 'right' }}>Celkem</th>
</tr>
</thead>
<tbody>
{invoice.items.map((item, index) => {
const lineSubtotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
const lineVat = Number(invoice.apply_vat) ? lineSubtotal * (Number(item.vat_rate) || 0) / 100 : 0
return (
<tr key={item.id || index}>
<td className="text-tertiary" style={{ textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td className="fw-500">{item.description || '\u2014'}</td>
<td style={{ textAlign: 'center' }}>{item.quantity} {item.unit && <span className="text-tertiary">{item.unit}</span>}</td>
<td style={{ textAlign: 'center' }}>{item.unit || '\u2014'}</td>
<td className="admin-mono text-right">{formatCurrency(item.unit_price, invoice.currency)}</td>
<td style={{ textAlign: 'center' }}>{Number(invoice.apply_vat) ? Number(item.vat_rate) : 0}%</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600 }}>{formatCurrency(lineSubtotal + lineVat, invoice.currency)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p className="text-tertiary">Žádné položky.</p>
)}
</>
)}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, invoice.currency)}</span>
</div>
{Number(invoice.apply_vat) > 0 && Object.entries(totals.vatByRate).map(([rate, amount]) => (
<div key={rate} className="offers-totals-row">
<span>DPH {rate}%:</span>
<span>{formatCurrency(amount, invoice.currency)}</span>
</div>
))}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, invoice.currency)}</span>
</div>
</div>
</motion.div>
{/* Notes */}
<motion.div className="offers-editor-section" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
<h3 className="admin-card-title">Veřejné poznámky na faktuře</h3>
{isPaid ? (
notes && notes.trim() && notes !== '<p><br></p>' ? (
<div dangerouslySetInnerHTML={{ __html: notes }} />
) : (
<p className="text-tertiary">Žádné poznámky.</p>
)
) : (
<>
<textarea className="admin-form-input" rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Poznámky zobrazené na faktuře..." />
{hasPermission('invoices.edit') && (
<div className="mt-2">
<button onClick={handleSaveNotes} className="admin-btn admin-btn-secondary admin-btn-sm" disabled={saving}>
{saving ? 'Ukládání...' : 'Uložit poznámky'}
</button>
</div>
)}
</>
)}
</motion.div>
{/* Status change confirm */}
<ConfirmModal
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav faktury"
message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${STATUS_LABELS[statusConfirm.status || '']}"?`}
confirmText={TRANSITION_LABELS[statusConfirm.status || ''] || 'Potvrdit'}
cancelText="Zrušit"
type="default"
/>
{/* Delete confirm */}
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${invoice.invoice_number}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
{/* Language selection for PDF */}
<AnimatePresence>
{langModal && (
<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={() => setLangModal(false)} />
<motion.div className="admin-modal admin-confirm-modal" role="dialog" aria-modal="true" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h2 className="admin-confirm-title">Jazyk faktury</h2>
<p className="admin-confirm-message">V jakém jazyce chcete vygenerovat fakturu?</p>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => handleViewPdf('cs')} className="admin-btn admin-btn-primary">Čeština</button>
<button type="button" onClick={() => handleViewPdf('en')} className="admin-btn admin-btn-primary">English</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,651 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const ReceivedInvoices = lazy(() => import('./ReceivedInvoices'))
const API_BASE = '/api/admin'
const DRAFT_KEY = 'boha_invoice_draft'
const MONTH_NAMES = [
'leden', 'únor', 'březen', 'duben', 'květen', 'červen',
'červenec', 'srpen', 'září', 'říjen', 'listopad', 'prosinec'
]
interface CurrencyAmount {
amount: number
currency: string
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return '0 Kč'
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
}
function formatCzkWithDetail(amounts: CurrencyAmount[], totalCzk: number | null | undefined): { value: string; detail: string | null } {
if (!Array.isArray(amounts) || amounts.length === 0) return { value: '0 Kč', detail: null }
const hasForeign = amounts.some(a => a.currency !== 'CZK')
if (hasForeign && totalCzk !== null && totalCzk !== undefined) {
return {
value: formatCurrency(totalCzk, 'CZK'),
detail: formatMultiCurrency(amounts),
}
}
return { value: formatMultiCurrency(amounts), detail: null }
}
const STATUS_LABELS: Record<string, string> = {
issued: 'Vystavena',
paid: 'Zaplacena',
overdue: 'Po splatnosti'
}
const STATUS_CLASSES: Record<string, string> = {
issued: 'admin-badge-invoice-issued',
paid: 'admin-badge-invoice-paid',
overdue: 'admin-badge-invoice-overdue'
}
const STATUS_FILTERS = [
{ value: '', label: 'Vše' },
{ value: 'issued', label: 'Vystavené' },
{ value: 'paid', label: 'Zaplacené' },
{ value: 'overdue', label: 'Po splatnosti' }
]
interface Invoice {
id: number
invoice_number: string
customer_name: string | null
status: string
issue_date: string
due_date: string
total: number
currency: string
}
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
}
interface DraftData {
form: Record<string, unknown>
items: Record<string, unknown>[]
savedAt?: string
}
export default function Invoices() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [activeTab, setActiveTab] = useState('issued')
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false)
const { sort, order, handleSort, activeSort } = useTableSort('invoice_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [statusFilter, setStatusFilter] = useState('')
const now = new Date()
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1)
const [statsYear, setStatsYear] = useState(now.getFullYear())
const [stats, setStats] = useState<InvoiceStats | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
const hasLoadedOnce = useRef(false)
const slideDirection = useRef(0)
const [slideKey, setSlideKey] = useState(0)
const isCurrentMonth = statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear()
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`
const fetchStats = useCallback(async () => {
setStatsLoading(true)
try {
const res = await apiFetch(`${API_BASE}/invoices/stats?month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
setSlideKey(k => k + 1)
}
} catch { /* ignore */ } finally {
setStatsLoading(false)
}
}, [statsMonth, statsYear])
useEffect(() => { fetchStats() }, [fetchStats])
const prevMonth = () => {
slideDirection.current = -1
if (statsMonth === 1) {
setStatsMonth(12)
setStatsYear(y => y - 1)
} else {
setStatsMonth(m => m - 1)
}
}
const nextMonth = () => {
if (isCurrentMonth) return
slideDirection.current = 1
if (statsMonth === 12) {
setStatsMonth(1)
setStatsYear(y => y + 1)
} else {
setStatsMonth(m => m + 1)
}
}
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; invoice: Invoice | null }>({ show: false, invoice: null })
const [deleting, setDeleting] = useState(false)
const [pdfLoading, setPdfLoading] = useState<number | null>(null)
const [langModal, setLangModal] = useState<Invoice | null>(null)
const [draft, setDraft] = useState<DraftData | null>(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as DraftData
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed
} catch { /* ignore */ }
return null
})
const discardDraft = () => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraft(null)
}
const { items: invoices, loading, initialLoad, pagination, refetch: fetchData } = useListData<Invoice>('invoices', {
search, sort, order, page,
extraParams: statusFilter ? { status: statusFilter } : {},
errorMsg: 'Nepodařilo se načíst faktury'
})
if (!hasPermission('invoices.view')) return <Forbidden />
const handleDelete = async () => {
if (!deleteConfirm.invoice) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/invoices/${deleteConfirm.invoice.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, invoice: null })
alert.success(result.message || 'Faktura byla smazána')
fetchData()
fetchStats()
} else {
alert.error(result.error || 'Nepodařilo se smazat fakturu')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const toggleStatus = async (inv: Invoice) => {
if (inv.status === 'paid') return
try {
const res = await apiFetch(`${API_BASE}/invoices/${inv.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'paid' }),
})
const data = await res.json()
if (data.success) {
alert.success('Faktura označena jako zaplacená')
fetchData()
fetchStats()
} else {
alert.error(data.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
}
}
const handlePdf = async (inv: Invoice, lang = 'cs') => {
if (pdfLoading) return
setLangModal(null)
setPdfLoading(inv.id)
try {
const response = await apiFetch(`${API_BASE}/invoices-pdf/${inv.id}?lang=${encodeURIComponent(lang)}`)
if (response.status === 401) return
if (!response.ok) {
alert.error('Nepodařilo se vygenerovat PDF')
return
}
const html = await response.text()
const w = window.open('', '_blank')
if (w) {
w.document.open()
w.document.write(html)
w.document.close()
w.onload = () => w.print()
} else {
alert.error('Prohlížeč zablokoval vyskakovací okno')
}
} catch {
alert.error('Chyba při generování PDF')
} finally {
setPdfLoading(null)
}
}
if (initialLoad) {
return (
<div>
<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 className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</div>
<div className="dash-kpi-grid dash-kpi-4">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line" style={{ width: '80px' }} />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line" style={{ width: '70px' }} />
<div className="admin-skeleton-line" style={{ width: '90px' }} />
<div className="admin-skeleton-line" style={{ width: '90px' }} />
<div className="admin-skeleton-line" style={{ width: '100px' }} />
</div>
))}
</div>
</div>
</div>
</div>
)
}
return (
<div>
<motion.div className="admin-page-header" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}>
<div>
<h1 className="admin-page-title">Faktury</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? invoices.length} {czechPlural(pagination?.total ?? invoices.length, 'faktura', 'faktury', 'faktur')}
</p>
</div>
{hasPermission('invoices.create') && (
<div className="admin-page-actions">
{activeTab === 'received' ? (
<button className="admin-btn admin-btn-primary" onClick={() => setReceivedUploadOpen(true)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát faktury
</button>
) : (
<Link to="/invoices/new" className="admin-btn admin-btn-primary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nová faktura
</Link>
)}
</div>
)}
</motion.div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.06 }}>
<div className="invoice-month-nav">
<button className="invoice-month-btn" onClick={prevMonth} aria-label="Předchozí měsíc">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6" /></svg>
</button>
<span>{monthLabel}</span>
<button className="invoice-month-btn" onClick={nextMonth} disabled={isCurrentMonth} aria-label="Následující měsíc">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
<div className="offers-tabs mb-4" style={{ justifyContent: 'center' }}>
<button className={`offers-tab ${activeTab === 'issued' ? 'active' : ''}`} onClick={() => setActiveTab('issued')}>Vydané</button>
<button className={`offers-tab ${activeTab === 'received' ? 'active' : ''}`} onClick={() => setActiveTab('received')}>Přijaté</button>
</div>
</motion.div>
{activeTab === 'received' ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.1 }}>
<Suspense fallback={
<div className="dash-kpi-grid dash-kpi-4" style={{ marginBottom: '1.5rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
}>
<ReceivedInvoices statsMonth={statsMonth} statsYear={statsYear} uploadOpen={receivedUploadOpen} setUploadOpen={setReceivedUploadOpen} />
</Suspense>
</motion.div>
) : (
<>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.1 }}>
{!hasLoadedOnce.current && statsLoading ? (
<div className="dash-kpi-grid dash-kpi-4" style={{ marginBottom: '1.5rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
) : stats && (
<div style={{ overflow: 'hidden', marginBottom: '1.5rem' }}>
<AnimatePresence mode="popLayout" initial={false} custom={slideDirection.current}>
<motion.div
key={slideKey}
className="dash-kpi-grid dash-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir: number) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (dir: number) => ({ x: `${(dir || 0) * -105}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
{(() => {
const paid = formatCzkWithDetail(stats.paid_month, stats.paid_month_czk)
const wait = formatCzkWithDetail(stats.awaiting, stats.awaiting_czk)
const over = formatCzkWithDetail(stats.overdue, stats.overdue_czk)
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk)
const countFooter = (count: number, zero: string) => count > 0
? `${count} ${czechPlural(count, 'faktura', 'faktury', 'faktur')}`
: zero
return (
<>
<div className="admin-stat-card success">
<div className="admin-stat-label">Uhrazeno ({MONTH_NAMES[statsMonth - 1]})</div>
<div className="admin-stat-value admin-mono">{paid.value}</div>
<div className="admin-stat-footer">
{[paid.detail, countFooter(stats.paid_month_count, 'žádné úhrady')].filter(Boolean).join(' · ')}
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">Čeká úhrada <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
<div className="admin-stat-value admin-mono">{wait.value}</div>
<div className="admin-stat-footer">
{[wait.detail, countFooter(stats.awaiting_count, 'vše uhrazeno')].filter(Boolean).join(' · ')}
</div>
</div>
<div className="admin-stat-card danger">
<div className="admin-stat-label">Po splatnosti <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
<div className="admin-stat-value admin-mono">{over.value}</div>
<div className="admin-stat-footer">
{[over.detail, stats.overdue_count === 0 ? 'vše v pořádku' : countFooter(stats.overdue_count, '')].filter(Boolean).join(' · ')}
</div>
</div>
<div className="admin-stat-card info">
<div className="admin-stat-label">DPH ({MONTH_NAMES[statsMonth - 1]})</div>
<div className="admin-stat-value admin-mono">{vat.value}</div>
<div className="admin-stat-footer">{vat.detail || 'z vydaných faktur'}</div>
</div>
</>
)
})()}
</motion.div>
</AnimatePresence>
</div>
)}
</motion.div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
<div className="offers-tabs mb-6">
{STATUS_FILTERS.map(f => (
<button
key={f.value}
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
onClick={() => { setStatusFilter(f.value); setPage(1) }}
>
{f.label}
</button>
))}
</div>
</motion.div>
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input"
placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..."
/>
</div>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission('invoices.create') && (
<p className="text-tertiary" style={{ fontSize: '0.875rem' }}>
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('invoice_number')}>
Číslo <SortIcon column="invoice_number" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('issue_date')}>
Vystaveno <SortIcon column="issue_date" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('due_date')}>
Splatnost <SortIcon column="due_date" sort={activeSort} order={order} />
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{' · '}{new Date(draft.savedAt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</span>
</td>
<td>{(draft.form.customer_name as string) || '\u2014'}</td>
<td>{'\u2014'}</td>
<td className="admin-mono">
{draft.form.issue_date ? formatDate(draft.form.issue_date as string) : '\u2014'}
</td>
<td className="admin-mono">
{draft.form.due_date ? formatDate(draft.form.due_date as string) : '\u2014'}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link to="/invoices/new" className="admin-btn-icon" title="Pokračovat v konceptu" aria-label="Pokračovat v konceptu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
<button onClick={discardDraft} className="admin-btn-icon danger" title="Zahodit koncept">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue = inv.status === 'overdue' || (inv.status === 'issued' && inv.due_date && new Date(inv.due_date) < new Date(new Date().toDateString()))
return (
<tr key={inv.id} className={isOverdue ? 'offers-expired-row' : ''}>
<td className="admin-mono">
<Link to={`/invoices/${inv.id}`} className="link-accent">{inv.invoice_number}</Link>
</td>
<td>{inv.customer_name || '\u2014'}</td>
<td>
{inv.status === 'paid' ? (
<span className={`admin-badge ${STATUS_CLASSES[inv.status]}`}>{STATUS_LABELS[inv.status]}</span>
) : (
<button onClick={() => toggleStatus(inv)} className={`admin-badge ${STATUS_CLASSES[inv.status] || ''}`} style={{ cursor: 'pointer' }}>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">{formatDate(inv.issue_date)}</td>
<td className="admin-mono" style={inv.status === 'overdue' ? { color: 'var(--danger)', fontWeight: 600 } : undefined}>
{formatDate(inv.due_date)}
</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 500 }}>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/invoices/${inv.id}`} className="admin-btn-icon" title="Detail" aria-label="Detail">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</Link>
{hasPermission('invoices.export') && (
<button onClick={() => setLangModal(inv)} className="admin-btn-icon" title="PDF" disabled={pdfLoading === inv.id}>
{pdfLoading === inv.id ? (
<div className="admin-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission('invoices.delete') && (
<button onClick={() => setDeleteConfirm({ show: true, invoice: inv })} className="admin-btn-icon danger" title="Smazat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<AnimatePresence>
{langModal && (
<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={() => setLangModal(null)} />
<motion.div className="admin-modal admin-confirm-modal" role="dialog" aria-modal="true" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h2 className="admin-confirm-title">Jazyk faktury</h2>
<p className="admin-confirm-message">V jakém jazyce chcete vygenerovat fakturu?</p>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => handlePdf(langModal, 'cs')} className="admin-btn admin-btn-primary">Čeština</button>
<button type="button" onClick={() => handlePdf(langModal, 'en')} className="admin-btn admin-btn-primary">English</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,503 @@
import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { motion, AnimatePresence } from 'framer-motion'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import { czechPlural } from '../utils/formatters'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
interface RawLeaveRequest {
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
reviewed_at?: string
users_leave_requests_user_idTousers?: { first_name: string; last_name: string }
users_leave_requests_reviewer_idTousers?: { first_name: string; last_name: string } | null
}
interface LeaveRequest {
id: number
employee_name: string
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_name?: string
reviewer_note?: string
created_at: string
reviewed_at?: string
}
function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
const user = raw.users_leave_requests_user_idTousers
const reviewer = raw.users_leave_requests_reviewer_idTousers
return {
id: raw.id,
employee_name: user ? `${user.first_name} ${user.last_name}` : 'Neznámý',
leave_type: raw.leave_type,
date_from: raw.date_from,
date_to: raw.date_to,
total_days: raw.total_days,
total_hours: raw.total_hours,
status: raw.status,
notes: raw.notes,
reviewer_name: reviewer ? `${reviewer.first_name} ${reviewer.last_name}` : undefined,
reviewer_note: raw.reviewer_note,
created_at: raw.created_at,
reviewed_at: raw.reviewed_at,
}
}
export default function LeaveApproval() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'pending' | 'processed'>('pending')
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>([])
const [approveModal, setApproveModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectModal, setRejectModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectNote, setRejectNote] = useState('')
const [processing, setProcessing] = useState(false)
useModalLock(rejectModal.open)
const fetchPending = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=pending`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest)
setPendingRequests(mapped)
setPendingCount(result.pagination?.total ?? mapped.length)
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
}
}, [alert])
const fetchProcessed = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=approved`)
if (response.status === 401) return
const resultApproved = await response.json()
const response2 = await apiFetch(`${API_BASE}/leave-requests?status=rejected`)
if (response2.status === 401) return
const resultRejected = await response2.json()
const all = [
...(resultApproved.success ? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest) : []),
...(resultRejected.success ? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest) : [])
].sort((a: LeaveRequest, b: LeaveRequest) => new Date(b.reviewed_at!).getTime() - new Date(a.reviewed_at!).getTime())
setProcessedRequests(all)
} catch {
alert.error('Nepodařilo se načíst vyřízené žádosti')
}
}, [alert])
useEffect(() => {
setLoading(true)
fetchPending().finally(() => setLoading(false))
}, [fetchPending])
useEffect(() => {
if (activeTab === 'processed' && processedRequests.length === 0) {
fetchProcessed()
}
}, [activeTab, processedRequests.length, fetchProcessed])
if (!hasPermission('attendance.approve')) return <Forbidden />
const handleApprove = async () => {
setProcessing(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${approveModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setApproveModal({ open: false, request: null })
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla schválena')
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setProcessing(false)
}
}
const handleReject = async () => {
if (!rejectNote.trim()) {
alert.error('Důvod zamítnutí je povinný')
return
}
setProcessing(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${rejectModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'rejected', reviewer_note: rejectNote })
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setRejectModal({ open: false, request: null })
setRejectNote('')
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla zamítnuta')
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setProcessing(false)
}
}
if (loading) {
return (
<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 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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, 'žádost čeká', 'žádosti čekají', 'žádostí čeká')} na schválení`
: 'Žádné čekající žádosti'
}
</p>
</div>
</motion.div>
{/* Tabs */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="offers-tabs mb-6">
<button
className={`offers-tab ${activeTab === 'pending' ? 'active' : ''}`}
onClick={() => setActiveTab('pending')}
>
Ke schválení
{pendingCount > 0 && (
<span className="admin-badge badge-pending" style={{ marginLeft: '0.5rem', fontSize: '0.7rem', padding: '0.15rem 0.5rem' }}>
{pendingCount}
</span>
)}
</button>
<button
className={`offers-tab ${activeTab === 'processed' ? 'active' : ''}`}
onClick={() => setActiveTab('processed')}
>
Vyřízené
</button>
</div>
</motion.div>
{/* Pending Tab */}
{activeTab === 'pending' && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
{pendingRequests.length === 0 ? (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted mb-4">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p>Žádné čekající žádosti</p>
</div>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
<div className="admin-card-body" style={{ padding: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<div className="flex-1">
<div className="flex-row-gap mb-2">
<strong style={{ fontSize: '1rem' }}>{req.employee_name}</strong>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</div>
<div className="text-secondary" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', fontSize: '0.875rem' }}>
<span>
<strong>{formatDate(req.date_from)}</strong> <strong>{formatDate(req.date_to)}</strong>
</span>
<span>{req.total_days} {czechPlural(req.total_days, 'den', 'dny', 'dnů')} ({req.total_hours}h)</span>
<span className="text-muted">Podáno: {formatDatetime(req.created_at)}</span>
</div>
{req.notes && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.875rem', fontStyle: 'italic' }}>
{req.notes}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
onClick={() => setApproveModal({ open: true, request: req })}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--success-light)', color: 'var(--success)', border: 'none' }}
>
Schválit
</button>
<button
onClick={() => setRejectModal({ open: true, request: req })}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--danger-light)', color: 'var(--danger)', border: 'none' }}
>
Zamítnout
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</motion.div>
)}
{/* Processed Tab */}
{activeTab === 'processed' && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{processedRequests.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné vyřízené žádosti</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Stav</th>
<th>Schválil</th>
<th>Poznámka</th>
<th>Vyřízeno</th>
</tr>
</thead>
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td><strong>{req.employee_name}</strong></td>
<td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">{formatDate(req.date_from)}</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || '—'}</td>
<td style={{ maxWidth: '200px' }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40 ? `${req.reviewer_note.substring(0, 40)}...` : req.reviewer_note}
</span>
) : '—'}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
)}
{/* Approve Confirmation */}
<ConfirmModal
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, 'den', 'dny', 'dnů')} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ''} pro ${approveModal.request.employee_name}?`
: ''
}
confirmText="Schválit"
type="info"
loading={processing}
/>
{/* Reject Modal */}
<AnimatePresence>
{rejectModal.open && (
<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={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }} />
<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 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Zamítnout žádost</h2>
</div>
<div className="admin-modal-body">
{rejectModal.request && (
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {leaveTypeLabels[rejectModal.request.leave_type]},{' '}
{formatDate(rejectModal.request.date_from)} {formatDate(rejectModal.request.date_to)} ({rejectModal.request.total_days} dnů)
</p>
)}
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</FormField>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
Zrušit
</button>
<button
type="button"
onClick={handleReject}
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? 'Zpracování...' : 'Zamítnout'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,258 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import ConfirmModal from '../components/ConfirmModal'
const API_BASE = '/api/admin'
const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
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 default function LeaveRequests() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [requests, setRequests] = useState<LeaveRequest[]>([])
const [cancelModal, setCancelModal] = useState<{ open: boolean; id: number | null }>({ open: false, id: null })
const [cancelling, setCancelling] = useState(false)
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setRequests(result.data)
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => {
fetchRequests()
}, [fetchRequests])
if (!hasPermission('attendance.record')) return <Forbidden />
const handleCancel = async () => {
setCancelling(true)
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${cancelModal.id}`, {
method: 'DELETE',
})
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setCancelModal({ open: false, id: null })
await fetchRequests()
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setCancelling(false)
}
}
if (loading) {
return (
<div>
<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 className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
)
}
function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) => text.length > 40 ? `${text.substring(0, 40)}...` : text
if (req.status === 'rejected' && req.reviewer_note) {
return (
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }} title={req.reviewer_note}>
{truncate(req.reviewer_note)}
</span>
)
}
if (req.notes) {
return (
<span className="text-secondary" style={{ fontSize: '0.875rem' }} title={req.notes}>
{truncate(req.notes)}
</span>
)
}
return <span className="text-muted"></span>
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Moje žádosti</h1>
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th></th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">{formatDate(req.date_from)}</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: '200px' }}>
{renderNoteCell(req)}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
{formatDatetime(req.created_at)}
</td>
<td>
{req.status === 'pending' && (
<button
onClick={() => setCancelModal({ open: true, id: req.id })}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelling}
/>
</div>
)
}

321
src/admin/pages/Login.tsx Normal file
View File

@@ -0,0 +1,321 @@
import { useState, useEffect, useRef } from 'react'
import { Navigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { useTheme } from '../../context/ThemeContext'
import { shouldShowSessionExpiredAlert, shouldShowLogoutAlert } from '../utils/api'
import FormField from '../components/FormField'
export default function Login() {
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth()
const alert = useAlert()
const { theme, toggleTheme } = useTheme()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false)
const [shake, setShake] = useState(false)
const [animatingOut, setAnimatingOut] = useState(false)
// 2FA state
const [show2FA, setShow2FA] = useState(false)
const [loginToken, setLoginToken] = useState<string | null>(null)
const [totpCode, setTotpCode] = useState('')
const [useBackupCode, setUseBackupCode] = useState(false)
const totpInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (shouldShowSessionExpiredAlert()) {
alert.warning('Vaše relace vypršela. Přihlaste se prosím znovu.')
} else if (shouldShowLogoutAlert()) {
alert.success('Byli jste úspěšně odhlášeni.')
}
}, [alert])
// Auto-focus TOTP input
useEffect(() => {
if (show2FA && totpInputRef.current) {
totpInputRef.current.focus()
}
}, [show2FA, useBackupCode])
if (authLoading) {
return (
<div className="admin-login">
<div className="admin-loading">
<div className="admin-spinner" />
</div>
</div>
)
}
if (isAuthenticated && !animatingOut) {
return <Navigate to="/" replace />
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
const result = await login(username, password, remember)
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null)
setShow2FA(true)
setTotpCode('')
setLoading(false)
} else if (!result.success) {
alert.error(result.error ?? 'Chyba přihlášení')
setShake(true)
setTimeout(() => setShake(false), 500)
setLoading(false)
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
}
}
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!totpCode.trim()) return
setLoading(true)
const result = await verify2FA(loginToken!, totpCode.trim(), remember, useBackupCode)
if (!result.success) {
alert.error(result.error ?? 'Chyba ověření')
setShake(true)
setTimeout(() => setShake(false), 500)
setTotpCode('')
if (totpInputRef.current) totpInputRef.current.focus()
setLoading(false)
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
}
}
const handleBack = () => {
setShow2FA(false)
setLoginToken(null)
setTotpCode('')
setUseBackupCode(false)
}
return (
<motion.div
className="admin-login"
initial={{ opacity: 0, scale: 0.98 }}
animate={animatingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
}
transition={animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
}
>
<div className="bg-orb bg-orb-1" />
<div className="bg-orb bg-orb-2" />
<button
onClick={toggleTheme}
className="admin-login-theme-btn"
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
>
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</span>
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
</button>
<AnimatePresence mode="wait">
{!show2FA ? (
<motion.div
key="login"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<img
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
alt="Logo"
className="admin-login-logo"
/>
<h1 className="admin-login-title">Interní systém</h1>
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>
</div>
<form onSubmit={handleSubmit} className="admin-form">
<FormField label="Uživatelské jméno nebo e-mail">
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
className="admin-form-input"
placeholder="Zadejte uživatelské jméno"
/>
</FormField>
<FormField label="Heslo">
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="admin-form-input"
placeholder="Zadejte heslo"
/>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
<span>Zapamatovat si </span>
</label>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
Přihlašování...
</>
) : (
'Přihlásit se'
)}
</button>
</form>
</motion.div>
) : (
<motion.div
key="2fa"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<div className="admin-login-2fa-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
<p className="admin-login-subtitle">
{useBackupCode
? 'Zadejte jeden ze záložních kódů'
: 'Zadejte 6místný kód z autentizační aplikace'
}
</p>
</div>
<form onSubmit={handle2FASubmit} className="admin-form">
<FormField label={useBackupCode ? 'Záložní kód' : 'Ověřovací kód'}>
<input
ref={totpInputRef}
id="totp-code"
type="text"
inputMode={useBackupCode ? 'text' : 'numeric'}
pattern={useBackupCode ? undefined : '[0-9]*'}
maxLength={useBackupCode ? 8 : 6}
value={totpCode}
onChange={(e) => {
const val = useBackupCode ? e.target.value : e.target.value.replace(/\D/g, '')
setTotpCode(val)
}}
required
autoComplete="one-time-code"
className="admin-form-input"
placeholder={useBackupCode ? 'XXXXXXXX' : '000000'}
style={useBackupCode ? {} : {
textAlign: 'center',
fontSize: '1.5rem',
letterSpacing: '0.5rem',
fontFamily: 'monospace'
}}
/>
</FormField>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
Ověřování...
</>
) : (
'Ověřit'
)}
</button>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
<button
onClick={() => {
setUseBackupCode(!useBackupCode)
setTotpCode('')
}}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
>
{useBackupCode ? 'Použít autentizační aplikaci' : 'Použít záložní kód'}
</button>
<button
onClick={handleBack}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
>
&larr; Zpět na přihlášení
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}

View File

@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
export default function NotFound() {
return (
<motion.div
className="admin-empty-state"
style={{ minHeight: '60vh', justifyContent: 'center' }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>
404
</h2>
<p>Stránka nebyla nalezena.</p>
<Link to="/" className="admin-btn admin-btn-primary" style={{ marginTop: '0.5rem' }}>
Zpět na Dashboard
</Link>
</motion.div>
)
}

File diff suppressed because it is too large Load Diff

656
src/admin/pages/Offers.tsx Normal file
View File

@@ -0,0 +1,656 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import useModalLock from '../hooks/useModalLock'
import Pagination from '../components/Pagination'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
const DRAFT_KEY = 'boha_offer_draft'
interface Quotation {
id: number
quotation_number: string
project_code: string
customer_name: string
created_at: string
valid_until: string
currency: string
total: number
status: string
order_id?: number
}
interface Draft {
form: {
project_code: string
customer_name: string
created_at: string
valid_until: string
currency: string
}
items: unknown[]
savedAt?: string
}
export default function Offers() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const { sort, order, handleSort, activeSort } = useTableSort('quotation_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
const [deleting, setDeleting] = useState(false)
const [invalidateConfirm, setInvalidateConfirm] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
const [invalidating, setInvalidating] = useState(false)
const [duplicating, setDuplicating] = useState<number | null>(null)
const [pdfLoading, setPdfLoading] = useState<number | null>(null)
const [creatingOrder, setCreatingOrder] = useState<number | null>(null)
const [orderModal, setOrderModal] = useState<{ show: boolean; quotation: Quotation | null }>({ show: false, quotation: null })
useModalLock(orderModal.show)
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
const [orderAttachment, setOrderAttachment] = useState<File | null>(null)
const [draft, setDraft] = useState<Draft | null>(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed
} catch { /* ignore corrupt data */ }
return null
})
const { items: quotations, loading, initialLoad, pagination, refetch: fetchData } = useListData('offers', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst nabídky'
})
const discardDraft = () => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraft(null)
}
const getRowClass = (invalidated: boolean, expired: boolean) => {
if (invalidated) return 'offers-invalidated-row'
if (expired) return 'offers-expired-row'
return ''
}
if (!hasPermission('offers.view')) return <Forbidden />
const handleDuplicate = async (quotation: Quotation) => {
setDuplicating(quotation.id)
try {
const response = await apiFetch(`${API_BASE}/offers/${quotation.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Nabídka byla duplikována')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se duplikovat nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDuplicating(null)
}
}
const handleCreateOrder = async () => {
if (!customerOrderNumber.trim() || !orderModal.quotation) return
setCreatingOrder(orderModal.quotation.id)
try {
const formData = new FormData()
formData.append('quotationId', String(orderModal.quotation.id))
formData.append('customerOrderNumber', customerOrderNumber.trim())
if (orderAttachment) {
formData.append('attachment', orderAttachment)
}
const response = await apiFetch(`${API_BASE}/orders`, {
method: 'POST',
body: formData
})
const result = await response.json()
if (result.success) {
setOrderModal({ show: false, quotation: null })
alert.success(result.message || 'Objednávka byla vytvořena')
navigate(`/orders/${result.data.order_id}`)
} else {
alert.error(result.error || 'Nepodařilo se vytvořit objednávku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setCreatingOrder(null)
}
}
const handleDelete = async () => {
if (!deleteConfirm.quotation) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/offers/${deleteConfirm.quotation.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, quotation: null })
alert.success(result.message || 'Nabídka byla smazána')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const handleInvalidate = async () => {
if (!invalidateConfirm.quotation) return
setInvalidating(true)
try {
const response = await apiFetch(`${API_BASE}/offers/${invalidateConfirm.quotation.id}/invalidate`, {
method: 'POST'
})
const result = await response.json()
if (result.success) {
setInvalidateConfirm({ show: false, quotation: null })
alert.success(result.message || 'Nabídka byla zneplatněna')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se zneplatnit nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setInvalidating(false)
}
}
const handlePdf = async (quotation: Quotation) => {
if (pdfLoading) return
setPdfLoading(quotation.id)
try {
const response = await apiFetch(`${API_BASE}/offers-pdf/${quotation.id}`)
if (response.status === 401) return
if (!response.ok) {
alert.error('Nepodařilo se vygenerovat PDF')
return
}
const html = await response.text()
const w = window.open('', '_blank')
if (w) {
w.document.open()
w.document.write(html)
w.document.close()
w.onload = () => w.print()
} else {
alert.error('Prohlížeč zablokoval vyskakovací okno')
}
} catch {
alert.error('Chyba při generování PDF')
} finally {
setPdfLoading(null)
}
}
if (initialLoad) {
return (
<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 style={{ display: 'flex', gap: '0.5rem' }}>
<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: '1.25rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px', marginBottom: '0.5rem' }} />
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Nabídky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? quotations.length} {czechPlural(pagination?.total ?? quotations.length, 'nabídka', 'nabídky', 'nabídek')}
</p>
</div>
<div className="admin-page-actions">
{hasPermission('offers.settings') && (
<Link to="/offers/templates" className="admin-btn admin-btn-secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 21V9" />
</svg>
Šablony
</Link>
)}
{hasPermission('offers.create') && (
<Link to="/offers/new" className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nová nabídka
</Link>
)}
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input"
placeholder="Hledat podle čísla, projektu nebo zákazníka..."
/>
</div>
{quotations.length === 0 && !draft ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
</div>
<p>Zatím nejsou žádné nabídky.</p>
{hasPermission('offers.create') && (
<Link to="/offers/new" className="admin-btn admin-btn-primary">
Vytvořit první nabídku
</Link>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('quotation_number')}>
Číslo <SortIcon column="quotation_number" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('project_code')}>
Projekt <SortIcon column="project_code" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('created_at')}>
Datum <SortIcon column="created_at" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('valid_until')}>
Platnost <SortIcon column="valid_until" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('currency')}>
Měna <SortIcon column="currency" sort={activeSort} order={order} />
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{' · '}{new Date(draft.savedAt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</span>
</td>
<td>
{draft.form.project_code || '—'}
</td>
<td>{draft.form.customer_name || '—'}</td>
<td className="admin-mono">
{draft.form.created_at ? formatDate(draft.form.created_at) : '—'}
</td>
<td className="admin-mono">
{draft.form.valid_until ? formatDate(draft.form.valid_until) : '—'}
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{draft.form.currency || '—'}
</span>
</td>
<td />
<td>
<div className="admin-table-actions">
<Link to="/offers/new" className="admin-btn-icon" title="Pokračovat v konceptu" aria-label="Pokračovat v konceptu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)}
{(quotations as Quotation[]).map((q) => {
const isInvalidated = q.status === 'invalidated'
const isExpired = !isInvalidated && !q.order_id && q.valid_until && new Date(q.valid_until) < new Date(new Date().toDateString())
return (
<tr key={q.id} className={getRowClass(isInvalidated, !!isExpired)}>
<td>
<Link to={`/offers/${q.id}`} className="link-accent">
{q.quotation_number}
</Link>
</td>
<td>
{q.project_code || '—'}
</td>
<td>{q.customer_name || '—'}</td>
<td className="admin-mono">
{formatDate(q.created_at)}
</td>
<td className="admin-mono">
{formatDate(q.valid_until)}
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{q.currency}
</span>
</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(q.total, q.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/offers/${q.id}`} className="admin-btn-icon" title={isInvalidated ? 'Zobrazit' : 'Upravit'} aria-label={isInvalidated ? 'Zobrazit' : 'Upravit'}>
{isInvalidated ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link>
{!isInvalidated && hasPermission('offers.create') && (
<button
onClick={() => handleDuplicate(q)}
className="admin-btn-icon"
title="Duplikovat"
disabled={duplicating === q.id}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
)}
{!isInvalidated && q.order_id ? (
<Link to={`/orders/${q.order_id}`} className="admin-btn-icon accent" title="Zobrazit objednávku" aria-label="Zobrazit objednávku">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text x="12" y="16.5" textAnchor="middle" fill="currentColor" stroke="none" fontSize="9" fontWeight="700">O</text>
</svg>
</Link>
) : !isInvalidated && hasPermission('orders.create') && (
<button
onClick={() => { setCustomerOrderNumber(''); setOrderAttachment(null); setOrderModal({ show: true, quotation: q }) }}
className="admin-btn-icon"
title="Vytvořit objednávku"
disabled={creatingOrder === q.id}
>
{creatingOrder === q.id ? (
<div className="admin-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
)}
</button>
)}
{isExpired && !isInvalidated && hasPermission('offers.edit') && (
<button
onClick={() => setInvalidateConfirm({ show: true, quotation: q })}
className="admin-btn-icon"
title="Zneplatnit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
</button>
)}
{hasPermission('offers.export') && (
<button
onClick={() => handlePdf(q)}
className="admin-btn-icon"
title="PDF"
disabled={pdfLoading === q.id}
>
{pdfLoading === q.id ? (
<div className="admin-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission('offers.delete') && (
<button
onClick={() => setDeleteConfirm({ show: true, quotation: q })}
className="admin-btn-icon danger"
title="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
)
})}
{quotations.length === 0 && draft && search && (
<tr>
<td colSpan={8} className="text-muted" style={{ textAlign: 'center', padding: '1.5rem' }}>
Žádné nabídky odpovídající hledání.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
onConfirm={handleDelete}
title="Smazat nabídku"
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<ConfirmModal
isOpen={invalidateConfirm.show}
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
onConfirm={handleInvalidate}
title="Zneplatnit nabídku"
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
confirmText="Zneplatnit"
cancelText="Zrušit"
type="danger"
loading={invalidating}
/>
<AnimatePresence>
{orderModal.show && (
<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={() => !creatingOrder && setOrderModal({ show: false, quotation: null })} />
<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 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Vytvořit objednávku</h2>
<p className="text-secondary" style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>
Nabídka: <strong>{orderModal.quotation?.quotation_number}</strong>
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={e => setCustomerOrderNumber(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !creatingOrder && handleCreateOrder()}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-color)" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span style={{ fontSize: '0.875rem' }}>
{orderAttachment.name} <span className="text-tertiary">({(orderAttachment.size / 1024).toFixed(0)} KB)</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: 'auto' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label className="admin-btn admin-btn-secondary admin-btn-sm" style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={e => setOrderAttachment(e.target.files?.[0] || null)}
style={{ display: 'none' }}
/>
</label>
)}
<small className="admin-form-hint" style={{ marginTop: '0.25rem' }}>Max 10 MB</small>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button onClick={() => setOrderModal({ show: false, quotation: null })} className="admin-btn admin-btn-secondary" disabled={!!creatingOrder}>
Zrušit
</button>
<button onClick={handleCreateOrder} className="admin-btn admin-btn-primary" disabled={!!creatingOrder || !customerOrderNumber.trim()}>
{creatingOrder ? 'Vytváření...' : 'Vytvořit'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,664 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const DEFAULT_CUSTOMER_FIELD_ORDER = ['street', 'city_postal', 'country', 'company_id', 'vat_id']
const CUSTOMER_FIELD_LABELS: Record<string, string> = {
street: 'Ulice',
city_postal: 'Město + PSČ',
country: 'Země',
company_id: 'IČO',
vat_id: 'DIČ',
}
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[]
}
interface CustomField {
name: string
value: string
showLabel: boolean
_key?: string
}
interface CustomerForm {
name: string
street: string
city: string
postal_code: string
country: string
company_id: string
vat_id: string
}
export default function OffersCustomers() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [customers, setCustomers] = useState<Customer[]>([])
const [search, setSearch] = useState('')
const [showModal, setShowModal] = useState(false)
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<CustomerForm>({
name: '',
street: '',
city: '',
postal_code: '',
country: '',
company_id: '',
vat_id: '',
})
const [customFields, setCustomFields] = useState<CustomField[]>([])
const customFieldKeyCounter = useRef(0)
const [fieldOrder, setFieldOrder] = useState<string[]>([...DEFAULT_CUSTOMER_FIELD_ORDER])
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; customer: Customer | null }>({ show: false, customer: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
const getFullFieldOrder = useCallback(() => {
const allBuiltIn = [...DEFAULT_CUSTOMER_FIELD_ORDER]
const order = [...fieldOrder].filter(k => k !== 'name')
for (const f of allBuiltIn) {
if (!order.includes(f)) order.push(f)
}
for (let i = 0; i < customFields.length; i++) {
const key = `custom_${i}`
if (!order.includes(key)) order.push(key)
}
return order.filter(key => {
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
return idx < customFields.length
}
return true
})
}, [fieldOrder, customFields])
const moveField = (index: number, direction: number) => {
const order = getFullFieldOrder()
const newIndex = index + direction
if (newIndex < 0 || newIndex >= order.length) return
const updated = [...order]
;[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]]
setFieldOrder(updated)
}
const getFieldDisplayName = (key: string) => {
if (CUSTOMER_FIELD_LABELS[key]) return CUSTOMER_FIELD_LABELS[key]
if (key.startsWith('custom_')) {
const idx = parseInt(key.split('_')[1])
const cf = customFields[idx]
if (cf) return cf.name ? `${cf.name}: ${cf.value || '...'}` : cf.value || `Vlastní pole ${idx + 1}`
}
return key
}
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/customers`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setCustomers(Array.isArray(result.data) ? result.data : [])
} else {
alert.error(result.error || 'Nepodařilo se načíst zákazníky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
const openCreateModal = () => {
setEditingCustomer(null)
setForm({
name: '', street: '', city: '', postal_code: '', country: '',
company_id: '', vat_id: ''
})
setCustomFields([])
setFieldOrder([...DEFAULT_CUSTOMER_FIELD_ORDER])
setShowModal(true)
}
const openEditModal = (customer: Customer) => {
setEditingCustomer(customer)
setForm({
name: customer.name || '',
street: customer.street || '',
city: customer.city || '',
postal_code: customer.postal_code || '',
country: customer.country || '',
company_id: customer.company_id || '',
vat_id: customer.vat_id || '',
})
const cf = Array.isArray(customer.custom_fields) && customer.custom_fields.length > 0
? customer.custom_fields.map(f => ({ ...f, _key: `cf-${++customFieldKeyCounter.current}` }))
: []
setCustomFields(cf)
if (Array.isArray(customer.customer_field_order) && customer.customer_field_order.length > 0) {
setFieldOrder(customer.customer_field_order)
} else {
setFieldOrder([...DEFAULT_CUSTOMER_FIELD_ORDER])
}
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingCustomer(null)
}
const handleSubmit = async () => {
if (!form.name.trim()) {
alert.error('Název zákazníka je povinný')
return
}
setSaving(true)
try {
const url = editingCustomer
? `${API_BASE}/customers/${editingCustomer.id}`
: `${API_BASE}/customers`
const payload = {
...form,
custom_fields: customFields.filter(f => f.name.trim() || f.value.trim()),
customer_field_order: getFullFieldOrder(),
}
const response = await apiFetch(url, {
method: editingCustomer ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message || (editingCustomer ? 'Zákazník byl aktualizován' : 'Zákazník byl vytvořen'))
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se uložit zákazníka')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.customer) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/customers/${deleteConfirm.customer.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, customer: null })
alert.success(result.message || 'Zákazník byl smazán')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat zákazníka')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (!hasPermission('offers.view')) return <Forbidden />
const filteredCustomers = search
? customers.filter(c =>
(c.name || '').toLowerCase().includes(search.toLowerCase()) ||
(c.company_id || '').includes(search) ||
(c.city || '').toLowerCase().includes(search.toLowerCase())
)
: customers
if (loading) {
return (
<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 className="admin-skeleton-line h-10" style={{ width: '160px', borderRadius: '8px' }} />
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px', marginBottom: '0.5rem' }} />
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
const fullFieldOrder = getFullFieldOrder()
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Zákazníci</h1>
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
</div>
{hasPermission('offers.create') && (
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat zákazníka
</button>
)}
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat zákazníky..."
/>
</div>
{filteredCustomers.length === 0 ? (
<div className="admin-empty-state">
<p>{search ? 'Žádní zákazníci odpovídající hledání.' : 'Zatím nejsou žádní zákazníci.'}</p>
{!search && hasPermission('offers.create') && (
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
Přidat prvního zákazníka
</button>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Město</th>
<th>IČO</th>
<th>DIČ</th>
<th>Nabídky</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{filteredCustomers.map((customer) => (
<tr key={customer.id}>
<td>
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>
{customer.name}
</div>
{customer.street && (
<div className="text-tertiary" style={{ fontSize: '11px' }}>
{customer.street}
</div>
)}
</td>
<td>{customer.city || '—'}</td>
<td>{customer.company_id || '—'}</td>
<td>{customer.vat_id || '—'}</td>
<td>
<span className="admin-badge admin-badge-info">
{customer.quotation_count || 0}
</span>
</td>
<td>
<div className="admin-table-actions">
{hasPermission('offers.edit') && (
<button
onClick={() => openEditModal(customer)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
{hasPermission('offers.delete') && (
<button
onClick={() => setDeleteConfirm({ show: true, customer })}
className="admin-btn-icon danger"
title={customer.quotation_count > 0 ? 'Nelze smazat zákazníka s nabídkami' : 'Smazat'}
aria-label={customer.quotation_count > 0 ? 'Nelze smazat zákazníka s nabídkami' : 'Smazat'}
disabled={customer.quotation_count > 0}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Create/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={closeModal} />
<motion.div
className="admin-modal"
style={{ maxWidth: 720 }}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingCustomer ? 'Upravit zákazníka' : 'Nový zákazník'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název" required>
<input
type="text"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
className="admin-form-input"
placeholder="Název firmy / jméno"
/>
</FormField>
<FormField label="Ulice">
<input
type="text"
value={form.street}
onChange={(e) => setForm(prev => ({ ...prev, street: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Město">
<input
type="text"
value={form.city}
onChange={(e) => setForm(prev => ({ ...prev, city: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<FormField label="PSČ">
<input
type="text"
value={form.postal_code}
onChange={(e) => setForm(prev => ({ ...prev, postal_code: e.target.value }))}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Země">
<input
type="text"
value={form.country}
onChange={(e) => setForm(prev => ({ ...prev, country: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.company_id}
onChange={(e) => setForm(prev => ({ ...prev, company_id: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
value={form.vat_id}
onChange={(e) => setForm(prev => ({ ...prev, vat_id: e.target.value }))}
className="admin-form-input"
/>
</FormField>
</div>
{/* Dynamic custom fields */}
<div style={{ marginTop: 4 }}>
<label className="admin-form-label" style={{ display: 'block', marginBottom: 4 }}>Vlastní pole</label>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div className="admin-form-row" style={{ marginBottom: 0, alignItems: 'flex-end' }}>
<FormField label={idx === 0 ? 'Název' : '\u00A0'} style={{ flex: 1 }}>
<input
type="text"
value={field.name}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], name: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
placeholder="Např. Kontakt"
/>
</FormField>
<FormField label={idx === 0 ? 'Hodnota' : '\u00A0'} style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="text"
value={field.value}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], value: e.target.value }
setCustomFields(updated)
}}
className="admin-form-input"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const key = `custom_${idx}`
setFieldOrder(prev => {
return prev
.filter(k => k !== key)
.map(k => {
if (k.startsWith('custom_')) {
const ki = parseInt(k.split('_')[1])
if (ki > idx) return `custom_${ki - 1}`
}
return k
})
})
setCustomFields(customFields.filter((_, i) => i !== idx))
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</FormField>
</div>
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
<input
type="checkbox"
checked={field.showLabel !== false}
onChange={(e) => {
const updated = [...customFields]
updated[idx] = { ...updated[idx], showLabel: e.target.checked }
setCustomFields(updated)
}}
/>
<span style={{ fontSize: '0.8rem' }}>Zobrazit název v PDF</span>
</label>
</div>
))}
<button
type="button"
onClick={() => setCustomFields([...customFields, { name: '', value: '', showLabel: true, _key: `cf-${++customFieldKeyCounter.current}` }])}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: '0.85rem' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat pole
</button>
</div>
{/* Field order for PDF */}
<div style={{ marginTop: 16 }}>
<label className="admin-form-label">Pořadí polí v PDF</label>
<small className="admin-form-hint" style={{ display: 'block', marginBottom: 8 }}>
Určuje pořadí řádků v adresním bloku zákazníka na PDF nabídce.
</small>
<div className="admin-reorder-list">
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button
type="button"
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Nahoru"
aria-label="Nahoru"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button
type="button"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
className="admin-btn-icon"
title="Dolů"
aria-label="Dolů"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
</div>
<span className={`admin-reorder-label${key.startsWith('custom_') ? ' accent' : ''}`}>
{getFieldDisplayName(key)}
</span>
</div>
))}
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary" disabled={saving}>
Zrušit
</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving && (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
)}
{!saving && (editingCustomer ? 'Uložit změny' : 'Vytvořit zákazníka')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirm Modal */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, customer: null })}
onConfirm={handleDelete}
title="Smazat zákazníka"
message={`Opravdu chcete smazat zákazníka "${deleteConfirm.customer?.name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,627 @@
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface ItemTemplate {
id: number
name: string
description: string
default_price: number
category: string
}
interface ScopeSection {
_key: string
title: string
title_cz: string
content: string
}
interface ScopeTemplate {
id: number
name: string
sections?: ScopeSection[]
}
interface ItemForm {
name: string
description: string
default_price: number
category: string
}
interface ScopeForm {
name: string
sections: ScopeSection[]
}
export default function OffersTemplates() {
const { hasPermission } = useAuth()
const [activeTab, setActiveTab] = useState<'items' | 'scopes'>('items')
if (!hasPermission('offers.settings')) return <Forbidden />
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Šablony</h1>
<p className="admin-page-subtitle">Šablony položek a rozsahu projektu</p>
</div>
</motion.div>
<div className="offers-tabs">
<button
className={`offers-tab ${activeTab === 'items' ? 'active' : ''}`}
onClick={() => setActiveTab('items')}
>
Šablony položek
</button>
<button
className={`offers-tab ${activeTab === 'scopes' ? 'active' : ''}`}
onClick={() => setActiveTab('scopes')}
>
Šablony rozsahu
</button>
</div>
{activeTab === 'items' ? <ItemTemplatesTab /> : <ScopeTemplatesTab />}
</div>
)
}
// --- Item Templates Tab ---
function ItemTemplatesTab() {
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [templates, setTemplates] = useState<ItemTemplate[]>([])
const [showModal, setShowModal] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<ItemForm>({ name: '', description: '', default_price: 0, category: '' })
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; template: ItemTemplate | null }>({ show: false, template: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates?action=items`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : [])
}
} catch {
alert.error('Nepodařilo se načíst šablony')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => { fetchData() }, [fetchData])
const openCreate = () => {
setEditingTemplate(null)
setForm({ name: '', description: '', default_price: 0, category: '' })
setShowModal(true)
}
const openEdit = (t: ItemTemplate) => {
setEditingTemplate(t)
setForm({ name: t.name || '', description: t.description || '', default_price: t.default_price || 0, category: t.category || '' })
setShowModal(true)
}
const handleSubmit = async () => {
if (!form.name.trim()) {
alert.error('Název šablony je povinný')
return
}
setSaving(true)
try {
const body = editingTemplate ? { ...form, id: editingTemplate.id } : form
const response = await apiFetch(`${API_BASE}/offers-templates?action=item`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const result = await response.json()
if (result.success) {
setShowModal(false)
await new Promise(r => setTimeout(r, 300))
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.template) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/offers-templates?action=item&id=${deleteConfirm.template.id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, template: null })
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<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 circle" />
<div className="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 className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Šablony položek ({templates.length})</h3>
<button onClick={openCreate} className="admin-btn admin-btn-primary admin-btn-sm">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat
</button>
</div>
<div className="admin-card-body">
{templates.length === 0 ? (
<div className="admin-empty-state"><p>Zatím žádné šablony položek.</p></div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Popis</th>
<th>Cena</th>
<th>Kategorie</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td className="fw-500">{t.name}</td>
<td style={{ color: 'var(--text-secondary)' }}>{t.description || '—'}</td>
<td>{Number(t.default_price).toFixed(2)}</td>
<td style={{ color: 'var(--text-secondary)' }}>{t.category || '—'}</td>
<td>
<div className="admin-table-actions">
<button onClick={() => openEdit(t)} className="admin-btn-icon" title="Upravit" aria-label="Upravit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button onClick={() => setDeleteConfirm({ show: true, template: t })} className="admin-btn-icon danger" title="Smazat" aria-label="Smazat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Item Template Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(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 }}>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{editingTemplate ? 'Upravit šablonu' : 'Nová šablona položky'}</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název" required>
<input type="text" value={form.name} onChange={(e) => setForm(p => ({ ...p, name: e.target.value }))} className="admin-form-input" />
</FormField>
<FormField label="Popis">
<textarea value={form.description} onChange={(e) => setForm(p => ({ ...p, description: e.target.value }))} className="admin-form-input" rows={2} />
</FormField>
<div className="admin-form-row">
<FormField label="Výchozí cena">
<input type="number" value={form.default_price} onChange={(e) => setForm(p => ({ ...p, default_price: parseFloat(e.target.value) || 0 }))} className="admin-form-input" step="0.01" />
</FormField>
<FormField label="Kategorie">
<input type="text" value={form.category} onChange={(e) => setForm(p => ({ ...p, category: e.target.value }))} className="admin-form-input" />
</FormField>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary" disabled={saving}>Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving && (<><div className="admin-spinner admin-spinner-sm" />Ukládání...</>)}
{!saving && (editingTemplate ? 'Uložit' : 'Vytvořit')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, template: null })}
onConfirm={handleDelete}
title="Smazat šablonu"
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</>
)
}
// --- Scope Templates Tab ---
function ScopeTemplatesTab() {
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [templates, setTemplates] = useState<ScopeTemplate[]>([])
const [showModal, setShowModal] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<ScopeForm>({ name: '', sections: [] })
const sectionKeyCounter = useRef(0)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; template: ScopeTemplate | null }>({ show: false, template: null })
const [deleting, setDeleting] = useState(false)
useModalLock(showModal)
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setTemplates(Array.isArray(result.data) ? result.data : [])
}
} catch {
alert.error('Nepodařilo se načíst šablony')
} finally {
setLoading(false)
}
}, [alert])
useEffect(() => { fetchData() }, [fetchData])
const openCreate = () => {
setEditingTemplate(null)
setForm({ name: '', sections: [{ _key: `sc-${++sectionKeyCounter.current}`, title: '', title_cz: '', content: '' }] })
setShowModal(true)
}
const openEdit = async (t: ScopeTemplate) => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates/${t.id}`)
const result = await response.json()
if (result.success) {
setEditingTemplate(result.data)
setForm({
name: result.data.name || '',
sections: result.data.sections?.length
? result.data.sections.map((s: { title?: string; title_cz?: string; content?: string }) => ({ _key: `sc-${++sectionKeyCounter.current}`, title: s.title || '', title_cz: s.title_cz || '', content: s.content || '' }))
: [{ _key: `sc-${++sectionKeyCounter.current}`, title: '', title_cz: '', content: '' }]
})
setShowModal(true)
}
} catch {
alert.error('Nepodařilo se načíst detail šablony')
}
}
const addSection = () => {
setForm(prev => ({ ...prev, sections: [...prev.sections, { _key: `sc-${++sectionKeyCounter.current}`, title: '', title_cz: '', content: '' }] }))
}
const removeSection = (index: number) => {
setForm(prev => ({
...prev,
sections: prev.sections.filter((_, i) => i !== index)
}))
}
const updateSection = (index: number, field: string, value: string) => {
setForm(prev => ({
...prev,
sections: prev.sections.map((s, i) => i === index ? { ...s, [field]: value } : s)
}))
}
const moveSection = (index: number, direction: number) => {
setForm(prev => {
const newSections = [...prev.sections]
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= newSections.length) return prev
;[newSections[index], newSections[targetIndex]] = [newSections[targetIndex], newSections[index]]
return { ...prev, sections: newSections }
})
}
const handleSubmit = async () => {
if (!form.name.trim()) {
alert.error('Název šablony je povinný')
return
}
setSaving(true)
try {
const url = editingTemplate
? `${API_BASE}/offers-templates/${editingTemplate.id}`
: `${API_BASE}/offers-templates`
const method = editingTemplate ? 'PUT' : 'POST'
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
const result = await response.json()
if (result.success) {
setShowModal(false)
await new Promise(r => setTimeout(r, 300))
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.template) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/offers-templates/${deleteConfirm.template.id}`, { method: 'DELETE' })
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, template: null })
alert.success(result.message)
fetchData()
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<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 circle" />
<div className="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 className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Šablony rozsahu ({templates.length})</h3>
<button onClick={openCreate} className="admin-btn admin-btn-primary admin-btn-sm">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat
</button>
</div>
<div className="admin-card-body">
{templates.length === 0 ? (
<div className="admin-empty-state"><p>Zatím žádné šablony rozsahu.</p></div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td className="fw-500">{t.name}</td>
<td>
<div className="admin-table-actions">
<button onClick={() => openEdit(t)} className="admin-btn-icon" title="Upravit" aria-label="Upravit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button onClick={() => setDeleteConfirm({ show: true, template: t })} className="admin-btn-icon danger" title="Smazat" aria-label="Smazat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Scope Template Modal (large) */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(false)} />
<motion.div className="admin-modal admin-modal-lg" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{editingTemplate ? 'Upravit šablonu rozsahu' : 'Nová šablona rozsahu'}</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název šablony" required>
<input type="text" value={form.name} onChange={(e) => setForm(p => ({ ...p, name: e.target.value }))} className="admin-form-input" />
</FormField>
<div className="admin-form-group">
<label className="admin-form-label mb-2">Sekce</label>
<div className="offers-scope-list">
{form.sections.map((section, index) => (
<div key={section._key} className="offers-scope-section">
<div className="offers-scope-section-header">
<span className="offers-scope-number">{index + 1}.</span>
<span className="offers-scope-title">{section.title || section.title_cz || `Sekce ${index + 1}`}</span>
<div className="offers-scope-actions">
<button type="button" onClick={() => moveSection(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Posunout nahoru" aria-label="Posunout nahoru">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
</button>
<button type="button" onClick={() => moveSection(index, 1)} disabled={index === form.sections.length - 1} className="admin-btn-icon" title="Posunout dolů" aria-label="Posunout dolů">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
{form.sections.length > 1 && (
<button type="button" onClick={() => removeSection(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
)}
</div>
</div>
<div className="admin-form">
<div className="admin-form-row">
<FormField label={<><span className="offers-lang-badge">EN</span> Název sekce</>}>
<input type="text" value={section.title} onChange={(e) => updateSection(index, 'title', e.target.value)} className="admin-form-input" placeholder="Název sekce (anglicky)" />
</FormField>
<FormField label={<><span className="offers-lang-badge offers-lang-badge-cz">CZ</span> Název sekce</>}>
<input type="text" value={section.title_cz} onChange={(e) => updateSection(index, 'title_cz', e.target.value)} className="admin-form-input" placeholder="Název sekce (česky)" />
</FormField>
</div>
<FormField label="Obsah">
<textarea
value={section.content}
onChange={(e) => updateSection(index, 'content', e.target.value)}
className="admin-form-input"
placeholder="Obsah sekce..."
rows={6}
style={{ minHeight: '150px' }}
/>
</FormField>
</div>
</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="button" onClick={addSection} className="admin-btn admin-btn-secondary admin-btn-sm">
+ Přidat sekci
</button>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary" disabled={saving}>Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{saving && (<><div className="admin-spinner admin-spinner-sm" />Ukládání...</>)}
{!saving && (editingTemplate ? 'Uložit' : 'Vytvořit')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, template: null })}
onConfirm={handleDelete}
title="Smazat šablonu"
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,671 @@
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
const DOMPurify = (window as any).DOMPurify || { sanitize: (html: string) => html }
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate } from '../utils/formatters'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
const STATUS_CLASSES: Record<string, string> = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
const TRANSITION_LABELS: Record<string, string> = {
v_realizaci: 'Zahájit realizaci',
dokoncena: 'Dokončit'
}
const TRANSITION_CLASSES: Record<string, string> = {
v_realizaci: 'admin-btn admin-btn-primary',
dokoncena: 'admin-btn admin-btn-primary'
}
interface OrderItem {
id?: number
description: string
item_description?: string
quantity: number
unit: string
unit_price: number
is_included_in_total: number | boolean
}
interface OrderSection {
id?: number
title: string
title_cz?: string
content: string
}
interface Invoice {
id: number
invoice_number: string
}
interface Project {
id: number
project_number: string
name: string
has_nas_folder?: boolean
}
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?: Invoice
project?: Project
}
export default function OrderDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [order, setOrder] = useState<OrderData | null>(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [statusChanging, setStatusChanging] = useState<string | null>(null)
const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null }>({ show: false, status: null })
const [editingNumber, setEditingNumber] = useState(false)
const [orderNumber, setOrderNumber] = useState('')
const [savingNumber, setSavingNumber] = useState(false)
const [attachmentLoading, setAttachmentLoading] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const fetchDetail = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setOrder(result.data)
setNotes(result.data.notes || '')
} else {
alert.error(result.error || 'Nepodařilo se načíst objednávku')
navigate('/orders')
}
} catch {
alert.error('Chyba připojení')
navigate('/orders')
} finally {
setLoading(false)
}
}, [id, alert, navigate])
useEffect(() => {
fetchDetail()
}, [fetchDetail])
const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 }
const subtotal = order.items.reduce((sum, item) => {
if (Number(item.is_included_in_total)) {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
}
return sum
}, 0)
const vatAmount = Number(order.apply_vat) ? subtotal * ((Number(order.vat_rate) || 0) / 100) : 0
return { subtotal, vatAmount, total: subtotal + vatAmount }
}, [order])
if (!hasPermission('orders.view')) return <Forbidden />
const handleStatusChange = async () => {
if (!statusConfirm.status) return
setStatusChanging(statusConfirm.status)
setStatusConfirm({ show: false, status: null })
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: statusConfirm.status })
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Stav byl změněn')
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
} finally {
setStatusChanging(null)
}
}
const handleStartEditNumber = () => {
if (!order) return
setOrderNumber(order.order_number)
setEditingNumber(true)
}
const handleSaveNumber = async () => {
if (!order) return
const trimmed = orderNumber.trim()
if (!trimmed) return
if (trimmed === order.order_number) {
setEditingNumber(false)
return
}
setSavingNumber(true)
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_number: trimmed })
})
const result = await response.json()
if (result.success) {
alert.success('Číslo objednávky bylo změněno')
setEditingNumber(false)
fetchDetail()
} else {
alert.error(result.error || 'Nepodařilo se změnit číslo')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSavingNumber(false)
}
}
const handleSaveNotes = async () => {
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: notes })
})
const result = await response.json()
if (result.success) {
alert.success('Poznámky byly uloženy')
} else {
alert.error(result.error || 'Nepodařilo se uložit poznámky')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleViewAttachment = async () => {
const newWindow = window.open('', '_blank')
setAttachmentLoading(true)
try {
const response = await apiFetch(`${API_BASE}/orders/${id}/attachment`)
if (!response.ok) {
newWindow?.close()
alert.error('Nepodařilo se stáhnout přílohu')
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
if (newWindow) newWindow.location.href = url
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
newWindow?.close()
alert.error('Chyba připojení')
} finally {
setAttachmentLoading(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/orders/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Objednávka byla smazána')
navigate('/orders')
} else {
alert.error(result.error || 'Nepodařilo se smazat objednávku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
setDeleteConfirm(false)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row gap-2">
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
)
}
if (!order) return null
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<Link to="/orders" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title flex-row-gap">
{editingNumber ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
Objednávka
<input
type="text"
value={orderNumber}
onChange={(e) => setOrderNumber(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveNumber()
if (e.key === 'Escape') setEditingNumber(false)
}}
className="admin-form-input"
style={{ width: '10rem', fontSize: '1rem', padding: '0.25rem 0.5rem', height: 'auto' }}
autoFocus
disabled={savingNumber}
/>
<button onClick={handleSaveNumber} className="admin-btn-icon" title="Uložit" aria-label="Uložit" disabled={savingNumber}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent-color)" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
<button onClick={() => setEditingNumber(false)} className="admin-btn-icon" title="Zrušit" aria-label="Zrušit" disabled={savingNumber}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
Objednávka {order.order_number}
{hasPermission('orders.edit') && (
<button onClick={handleStartEditNumber} className="admin-btn-icon" title="Změnit číslo" aria-label="Změnit číslo" style={{ opacity: 0.5 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
</span>
)}
<span className={`admin-badge ${STATUS_CLASSES[order.status] || ''}`}>
{STATUS_LABELS[order.status] || order.status}
</span>
</h1>
</div>
</div>
<div className="admin-page-actions">
{order.invoice ? (
<Link to={`/invoices/${order.invoice.id}`} className="admin-btn admin-btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Faktura {order.invoice.invoice_number}
</Link>
) : (
hasPermission('invoices.create') && order.status === 'dokoncena' && (
<Link to={`/invoices/new?fromOrder=${order.id}`} className="admin-btn admin-btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Vytvořit fakturu
</Link>
)
)}
{hasPermission('orders.edit') && order.valid_transitions?.filter(s => s !== 'stornovana').length! > 0 && (
order.valid_transitions!.filter(s => s !== 'stornovana').map(status => (
<button
key={status}
onClick={() => setStatusConfirm({ show: true, status })}
className={TRANSITION_CLASSES[status] || 'admin-btn admin-btn-secondary'}
disabled={statusChanging === status}
>
{statusChanging === status ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
TRANSITION_LABELS[status] || status
)}
</button>
))
)}
{hasPermission('orders.delete') && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
</motion.div>
{/* Info card */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Informace</h3>
<div className="admin-form-row mb-2">
<FormField label="Nabídka">
<div>
<Link to={`/offers/${order.quotation_id}`} className="link-accent">
{order.quotation_number}
</Link>
{order.project_code && (
<span className="text-tertiary" style={{ marginLeft: '0.5rem' }}>({order.project_code})</span>
)}
</div>
</FormField>
<FormField label="Projekt">
<div>
{order.project ? (
<Link to={`/projects/${order.project.id}`} className="link-accent">
{order.project.project_number} {order.project.name}
</Link>
) : '—'}
</div>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3 mb-2">
<FormField label="Zákazník">
<div className="fw-500">{order.customer_name || '—'}</div>
</FormField>
<FormField label="Číslo obj. zákazníka">
<div>{order.customer_order_number || '—'}</div>
</FormField>
<FormField label="Měna">
<div>{order.currency}</div>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3 mb-2">
<FormField label="Datum vytvoření">
<div>{formatDate(order.created_at)}</div>
</FormField>
<FormField label="Příloha">
<div>
{order.attachment_name ? (
<button
onClick={handleViewAttachment}
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}
disabled={attachmentLoading}
>
{attachmentLoading ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
{order.attachment_name}
</button>
) : '—'}
</div>
</FormField>
</div>
</div>
</motion.div>
{/* Items (read-only) */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
{order.items?.length > 0 ? (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
<th>Popis</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
<th style={{ width: '8rem', textAlign: 'right', whiteSpace: 'nowrap' }}>Jedn. cena</th>
<th style={{ width: '4rem', textAlign: 'center' }}>V ceně</th>
<th style={{ width: '9rem', textAlign: 'right', whiteSpace: 'nowrap' }}>Celkem</th>
</tr>
</thead>
<tbody>
{order.items.map((item, index) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
return (
<tr key={item.id || index}>
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
<td>
<div className="fw-500">{item.description || '—'}</div>
{item.item_description && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-tertiary)', marginTop: '0.25rem' }}>{item.item_description}</div>
)}
</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'center' }}>{item.unit || '—'}</td>
<td className="admin-mono" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>{formatCurrency(item.unit_price, order.currency)}</td>
<td style={{ textAlign: 'center' }}>{Number(item.is_included_in_total) ? 'Ano' : 'Ne'}</td>
<td className="admin-mono" style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>{formatCurrency(lineTotal, order.currency)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p style={{ color: 'var(--text-tertiary)' }}>Žádné položky.</p>
)}
{/* Totals */}
<div className="offers-totals-summary">
<div className="offers-totals-row">
<span>Mezisoučet:</span>
<span>{formatCurrency(totals.subtotal, order.currency)}</span>
</div>
{Number(order.apply_vat) > 0 && (
<div className="offers-totals-row">
<span>DPH ({order.vat_rate}%):</span>
<span>{formatCurrency(totals.vatAmount, order.currency)}</span>
</div>
)}
<div className="offers-totals-row offers-totals-total">
<span>Celkem k úhradě:</span>
<span>{formatCurrency(totals.total, order.currency)}</span>
</div>
</div>
</div>
</motion.div>
{/* Sections (read-only) */}
{order.sections?.length > 0 && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Rozsah projektu</h3>
{order.scope_title && (
<div style={{ fontWeight: 500, marginBottom: '0.5rem' }}>{order.scope_title}</div>
)}
{order.scope_description && (
<div style={{ color: 'var(--text-secondary)', marginBottom: '1rem' }}>{order.scope_description}</div>
)}
<div className="offers-scope-list">
{order.sections.map((section, index) => (
<div key={section.id || index} className="offers-scope-section" style={{ cursor: 'default' }}>
<div className="offers-scope-section-header">
<span className="offers-scope-number">{index + 1}.</span>
<span className="offers-scope-title">{(order.language === 'CZ' ? (section.title_cz || section.title) : (section.title || section.title_cz)) || `Sekce ${index + 1}`}</span>
</div>
{section.content && (
<div
className="offers-scope-content rich-text-view"
style={{ padding: '1rem' }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(section.content) }}
/>
)}
</div>
))}
</div>
</div>
</motion.div>
)}
{/* Notes (editable) */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.2 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
<FormField label="Poznámky">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="admin-form-input"
rows={4}
placeholder="Interní poznámky k objednávce..."
disabled={!hasPermission('orders.edit')}
/>
</FormField>
{hasPermission('orders.edit') && (
<div className="mt-2">
<button
onClick={handleSaveNotes}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={saving}
>
{saving ? 'Ukládání...' : 'Uložit poznámky'}
</button>
</div>
)}
</div>
</motion.div>
{/* Status change confirmation */}
<ConfirmModal
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${STATUS_LABELS[statusConfirm.status || '']}"?${statusConfirm.status === 'dokoncena' ? ' Projekt bude automaticky dokončen.' : ''}`}
confirmText={TRANSITION_LABELS[statusConfirm.status || ''] || 'Potvrdit'}
cancelText="Zrušit"
type="default"
/>
{/* Delete confirmation */}
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;{order.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
{order.project?.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
)}
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

290
src/admin/pages/Orders.tsx Normal file
View File

@@ -0,0 +1,290 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
const STATUS_CLASSES: Record<string, string> = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
interface Order {
id: number
order_number: string
quotation_id: number
quotation_number: string
customer_name: string
status: string
created_at: string
total: number
currency: string
invoice_id?: number
}
export default function Orders() {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('order_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; order: Order | null }>({ show: false, order: null })
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: orders, loading, initialLoad, pagination, refetch: fetchData } = useListData('orders', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst objednávky'
})
if (!hasPermission('orders.view')) return <Forbidden />
const handleDelete = async () => {
if (!deleteConfirm.order) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/orders/${deleteConfirm.order.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
alert.success(result.message || 'Objednávka byla smazána')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat objednávku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (initialLoad) {
return (
<div>
<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 className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</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 circle" />
<div className="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 className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Objednávky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? orders.length} {czechPlural(pagination?.total ?? orders.length, 'objednávka', 'objednávky', 'objednávek')}
</p>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input"
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
/>
</div>
{orders.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
</div>
<p>Zatím nejsou žádné objednávky.</p>
<p className="text-tertiary" style={{ fontSize: '0.875rem' }}>
Objednávky se vytvářejí z nabídek.
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('order_number')}>
Číslo <SortIcon column="order_number" sort={activeSort} order={order} />
</th>
<th>Nabídka</th>
<th>Zákazník</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('created_at')}>
Datum <SortIcon column="created_at" sort={activeSort} order={order} />
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(orders as Order[]).map((o) => (
<tr key={o.id}>
<td className="admin-mono">
<Link to={`/orders/${o.id}`} className="link-accent">
{o.order_number}
</Link>
</td>
<td>
<Link to={`/offers/${o.quotation_id}`} className="text-secondary" style={{ textDecoration: 'none' }}>
{o.quotation_number}
</Link>
</td>
<td>{o.customer_name || '—'}</td>
<td>
<span className={`admin-badge ${STATUS_CLASSES[o.status] || ''}`}>
{STATUS_LABELS[o.status] || o.status}
</span>
</td>
<td className="admin-mono">
{formatDate(o.created_at)}
</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(o.total, o.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/orders/${o.id}`} className="admin-btn-icon" title="Detail" aria-label="Detail">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</Link>
{o.invoice_id ? (
<Link to={`/invoices/${o.invoice_id}`} className="admin-btn-icon accent" title="Zobrazit fakturu" aria-label="Zobrazit fakturu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text x="12" y="16.5" textAnchor="middle" fill="currentColor" stroke="none" fontSize="9" fontWeight="700">F</text>
</svg>
</Link>
) : hasPermission('invoices.create') && (
<Link to={`/invoices/new?fromOrder=${o.id}`} className="admin-btn-icon" title="Vytvořit fakturu" aria-label="Vytvořit fakturu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
</Link>
)}
{hasPermission('orders.delete') && (
<button
onClick={() => setDeleteConfirm({ show: true, order: o })}
className="admin-btn-icon danger"
title="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,318 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Customer {
id: number
name: string
company_id?: string
city?: string
}
interface User {
id: number
name: string
}
interface ProjectForm {
project_number: string
name: string
customer_id: number | null
customer_name: string
start_date: string
responsible_user_id: string
}
export default function ProjectCreate() {
const navigate = useNavigate()
const alert = useAlert()
const { hasPermission } = useAuth()
const [form, setForm] = useState<ProjectForm>({
project_number: '',
name: '',
customer_id: null,
customer_name: '',
start_date: new Date().toISOString().split('T')[0],
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
const [loadingNumber, setLoadingNumber] = useState(true)
// Customer selector state
const [customers, setCustomers] = useState<Customer[]>([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
// Load initial data
useEffect(() => {
const load = async () => {
try {
const [numRes, custRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/projects/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/users`)
])
const numData = await numRes.json()
if (numData.success) {
setForm(prev => ({ ...prev, project_number: numData.data?.next_number || numData.data?.number || '' }))
}
const custData = await custRes.json()
if (custData.success) {
setCustomers(Array.isArray(custData.data) ? custData.data : custData.data?.items || [])
}
const usersData = await usersRes.json()
if (usersData.success) {
const rawUsers = Array.isArray(usersData.data) ? usersData.data : usersData.data?.items || []
setUsers(rawUsers.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
}
} catch {
alert.error('Chyba při načítání dat')
} finally {
setLoadingNumber(false)
}
}
load()
}, [alert])
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [showCustomerDropdown])
if (!hasPermission('projects.create')) return <Forbidden />
const selectCustomer = (customer: Customer) => {
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
setErrors(prev => ({ ...prev, customer_id: undefined }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
const clearCustomer = () => {
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
}
const updateForm = (field: keyof ProjectForm, value: unknown) => {
setForm(prev => ({ ...prev, [field]: value }))
setErrors(prev => ({ ...prev, [field]: undefined }))
}
const handleSave = async () => {
const newErrors: Record<string, string> = {}
if (!form.name.trim()) newErrors.name = 'Název projektu je povinný'
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const body = {
name: form.name.trim(),
customer_id: form.customer_id,
start_date: form.start_date,
project_number: form.project_number.trim(),
responsible_user_id: form.responsible_user_id || null
}
const res = await apiFetch(`${API_BASE}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
if (data.success) {
navigate(`/projects/${data.data.project_id}`, { state: { created: true } })
} else {
alert.error(data.error || 'Nepodařilo se vytvořit projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
if (loadingNumber) {
return (
<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">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">Nový projekt</h1>
<p className="admin-page-subtitle">Ruční vytvoření projektu</p>
</div>
</div>
<div className="admin-page-actions">
<button
onClick={handleSave}
disabled={saving}
className="admin-btn admin-btn-primary"
>
{saving ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ overflow: 'visible' }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo projektu">
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm('project_number', e.target.value)}
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
) : (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
/>
{showCustomerDropdown && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map(c => (
<div
key={c.id}
className="offers-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>
{c.city && <div>{c.city}</div>}
</div>
))
)}
</div>
)}
</div>
)}
</FormField>
<FormField label="Datum zahájení">
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
className="admin-form-select"
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</FormField>
</div>
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,645 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
function formatNoteDate(dateStr: string) {
if (!dateStr) return ''
const d = new Date(dateStr)
const day = d.getDate()
const month = d.getMonth() + 1
const year = d.getFullYear()
const hours = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
return `${day}. ${month}. ${year} ${hours}:${mins}`
}
interface Note {
id: number
content: string
user_name: string
created_at: string
}
interface User {
id: number
name: string
}
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
}
interface ProjectForm {
name: string
status: string
start_date: string
end_date: string
responsible_user_id: string
}
export default function ProjectDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [project, setProject] = useState<ProjectData | null>(null)
const [form, setForm] = useState<ProjectForm>({
name: '',
status: 'aktivni',
start_date: '',
end_date: '',
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
// Dynamic notes
const [notes, setNotes] = useState<Note[]>([])
const [notesLoading, setNotesLoading] = useState(true)
const [newNote, setNewNote] = useState('')
const [addingNote, setAddingNote] = useState(false)
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null)
const createdShown = useRef(false)
useEffect(() => {
if ((location.state as { created?: boolean })?.created && !createdShown.current) {
createdShown.current = true
alert.success('Projekt byl vytvořen')
navigate(location.pathname, { replace: true, state: {} })
}
}, [location.state, location.pathname, alert, navigate])
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setNotes(result.data.project_notes || [])
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false)
}
}
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const p = result.data
setProject(p)
setForm({
name: p.name || '',
status: p.status || 'aktivni',
start_date: (p.start_date || '').substring(0, 10),
end_date: (p.end_date || '').substring(0, 10),
responsible_user_id: p.responsible_user_id || ''
})
} else {
alert.error(result.error || 'Nepodařilo se načíst projekt')
navigate('/projects')
}
} catch {
alert.error('Chyba připojení')
navigate('/projects')
} finally {
setLoading(false)
}
}
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/users`)
if (res.status === 401) return
const data = await res.json()
if (data.success) {
const raw = Array.isArray(data.data) ? data.data : data.data?.items || []
setUsers(raw.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
}
} catch {
// silent
}
}
fetchDetail()
fetchNotes()
fetchUsers()
}, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission('projects.view')) return <Forbidden />
const updateForm = (field: keyof ProjectForm, value: string) => setForm(prev => ({ ...prev, [field]: value }))
const handleSave = async () => {
if (!form.name.trim()) {
alert.error('Název projektu je povinný')
return
}
setSaving(true)
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null
})
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Projekt byl aktualizován')
} else {
alert.error(result.error || 'Nepodařilo se uložit projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
if (result.success) {
navigate('/projects')
setTimeout(() => alert.success('Projekt byl smazán'), 300)
} else {
alert.error(result.error || 'Nepodařilo se smazat projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const handleAddNote = async () => {
if (!newNote.trim()) return
setAddingNote(true)
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newNote.trim() })
})
const result = await response.json()
if (result.success) {
setNotes(prev => [result.data.note, ...prev])
setNewNote('')
alert.success('Poznámka byla přidána')
} else {
alert.error(result.error || 'Nepodařilo se přidat poznámku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setAddingNote(false)
}
}
const handleDeleteNote = async (noteId: number) => {
setDeletingNoteId(noteId)
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setNotes(prev => prev.filter(n => n.id !== noteId))
alert.success('Poznámka byla smazána')
} else {
alert.error(result.error || 'Nepodařilo se smazat poznámku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeletingNoteId(null)
}
}
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
</div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
)
}
if (!project) return null
const canEdit = hasPermission('projects.edit')
return (
<div>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
Projekt {project.project_number}
</h1>
</div>
</div>
{canEdit && (
<div className="admin-page-actions">
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : 'Uložit'}
</button>
{!project.order_id && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
)}
</motion.div>
{/* Form */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo projektu">
<input
type="text"
value={project.project_number}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/>
</FormField>
<FormField label="Název">
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
disabled={!canEdit}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zákazník">
<input
type="text"
value={project.customer_name || '—'}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/>
</FormField>
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
className="admin-form-select"
disabled={!canEdit}
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Stav">
<select
value={form.status}
onChange={(e) => updateForm('status', e.target.value)}
className="admin-form-select"
disabled={!canEdit}
>
<option value="aktivni">Aktivní</option>
<option value="dokonceny">Dokončený</option>
<option value="zruseny">Zrušený</option>
</select>
</FormField>
<FormField label="Datum zahájení">
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
disabled={!canEdit}
/>
</FormField>
<FormField label="Datum ukončení">
<AdminDatePicker
mode="date"
value={form.end_date}
onChange={(val: string) => updateForm('end_date', val)}
disabled={!canEdit}
/>
</FormField>
</div>
</div>
</div>
</motion.div>
{/* Notes */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
{/* Add note */}
<div className="mb-4">
<textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
className="admin-form-input"
rows={2}
placeholder="Napište poznámku..."
style={{ resize: 'vertical', width: '100%' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey && newNote.trim()) {
handleAddNote()
}
}}
/>
<div className="mt-2">
<button
onClick={handleAddNote}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={addingNote || !newNote.trim()}
>
{addingNote ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
'Přidat poznámku'
)}
</button>
</div>
</div>
{/* Legacy notes (read-only) */}
{project.notes && (
<div style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
marginBottom: '0.5rem',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.25rem' }}>
Starší poznámka (před zavedením systému)
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{project.notes}</div>
</div>
)}
{/* Notes list */}
{notesLoading && (
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '8px' }} />
))}
</div>
)}
{!notesLoading && notes.length === 0 && !project.notes && (
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem', textAlign: 'center', padding: '1rem 0' }}>
Zatím žádné poznámky
</div>
)}
{!notesLoading && (notes.length > 0 || project.notes) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{notes.map(note => (
<div
key={note.id}
style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
position: 'relative'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div className="flex-1">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>
{note.user_name}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
{formatNoteDate(note.created_at)}
</span>
</div>
<div style={{ whiteSpace: 'pre-wrap', fontSize: '0.875rem', lineHeight: 1.5 }}>
{note.content}
</div>
</div>
{isAdmin && (
<button
onClick={() => handleDeleteNote(note.id)}
className="admin-btn-icon"
title="Smazat poznámku"
disabled={deletingNoteId === note.id}
style={{ flexShrink: 0, opacity: deletingNoteId === note.id ? 0.5 : 1 }}
>
{deletingNoteId === note.id ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
)}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
{/* Files placeholder - ProjectFileManager not yet migrated */}
<motion.div
className="admin-card"
style={{ marginBottom: '1rem' }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
Správa souborů projektu bude dostupná v příští verzi.
</p>
</div>
</motion.div>
{/* Links */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Propojení</h3>
<div className="admin-form-row">
<FormField label="Objednávka">
<div>
{project.order_id ? (
<Link to={`/orders/${project.order_id}`} className="link-accent">
{project.order_number}
{project.order_status && (
<span className="text-tertiary" style={{ fontWeight: 400, marginLeft: '0.5rem' }}>
({STATUS_LABELS[project.order_status] || project.order_status})
</span>
)}
</Link>
) : '—'}
</div>
</FormField>
<FormField label="Nabídka">
<div>
{project.quotation_id ? (
<Link to={`/offers/${project.quotation_id}`} className="link-accent">
{project.quotation_number}
</Link>
) : '—'}
</div>
</FormField>
</div>
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
)}
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,287 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import apiFetch from '../utils/api'
import { formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
const STATUS_CLASSES: Record<string, string> = {
aktivni: 'admin-badge-project-aktivni',
dokonceny: 'admin-badge-project-dokonceny',
zruseny: 'admin-badge-project-zruseny'
}
interface Project {
id: number
project_number: string
name: string
customer_name: string
responsible_user_name: string
status: string
start_date: string
end_date: string
order_id?: number
order_number?: string
}
export default function Projects() {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('project_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: projects, setItems: setProjects, loading, initialLoad, pagination } = useListData<Project>('projects', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst projekty'
})
if (!hasPermission('projects.view')) return <Forbidden />
const handleDelete = async () => {
if (!deleteTarget) return
setDeletingId(deleteTarget.id)
try {
const res = await apiFetch(`${API_BASE}/projects/${deleteTarget.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Projekt byl smazán')
setProjects((prev: Project[]) => prev.filter(p => p.id !== deleteTarget.id))
} else {
alert.error(data.error || 'Nepodařilo se smazat projekt')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeletingId(null)
setDeleteTarget(null)
setDeleteFiles(false)
}
}
if (initialLoad) {
return (
<div>
<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 className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</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 circle" />
<div className="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 className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Projekty</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? projects.length} {czechPlural(pagination?.total ?? projects.length, 'projekt', 'projekty', 'projektů')}
</p>
</div>
{hasPermission('projects.create') && (
<Link to="/projects/new" className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nový projekt
</Link>
)}
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s', pointerEvents: loading ? 'none' : 'auto' }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input"
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
/>
</div>
{projects.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<p>Zatím nejsou žádné projekty.</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
Vytvořte první projekt tlačítkem výše nebo automaticky při vytvoření objednávky.
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('project_number')}>
Číslo <SortIcon column="project_number" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('name')}>
Název <SortIcon column="name" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th>Zodpovědná osoba</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('start_date')}>
Začátek <SortIcon column="start_date" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('end_date')}>
Konec <SortIcon column="end_date" sort={activeSort} order={order} />
</th>
<th>Objednávka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(projects as Project[]).map((p) => (
<tr key={p.id}>
<td className="admin-mono">
<Link to={`/projects/${p.id}`} className="link-accent">
{p.project_number}
</Link>
</td>
<td className="fw-500">{p.name || '—'}</td>
<td>{p.customer_name || '—'}</td>
<td>{p.responsible_user_name || '—'}</td>
<td>
<span className={`admin-badge ${STATUS_CLASSES[p.status] || ''}`}>
{STATUS_LABELS[p.status] || p.status}
</span>
</td>
<td className="admin-mono">{formatDate(p.start_date)}</td>
<td className="admin-mono">{formatDate(p.end_date)}</td>
<td>
{p.order_id ? (
<Link to={`/orders/${p.order_id}`} className="text-secondary" style={{ textDecoration: 'none' }}>
{p.order_number}
</Link>
) : '—'}
</td>
<td>
<div className="admin-table-actions">
<Link to={`/projects/${p.id}`} className="admin-btn-icon" title="Upravit" aria-label="Upravit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
{!p.order_id && hasPermission('projects.create') && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"
title="Smazat projekt"
disabled={deletingId === p.id}
>
{deletingId === p.id ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
)}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
</>
}
confirmText="Smazat"
type="danger"
loading={!!deletingId}
/>
</div>
)
}

View File

@@ -0,0 +1,986 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort'
import useModalLock from '../hooks/useModalLock'
import AdminDatePicker from '../components/AdminDatePicker'
const API_BASE = '/api/admin'
const STATUS_LABELS: Record<string, string> = { unpaid: 'Neuhrazena', paid: 'Uhrazena' }
const STATUS_CLASSES: Record<string, string> = { unpaid: 'admin-badge-invoice-overdue', paid: 'admin-badge-invoice-paid' }
const CURRENCY_OPTIONS = ['CZK', 'EUR', 'USD', 'GBP']
const VAT_RATE_OPTIONS = [0, 10, 12, 15, 21]
const MONTH_NAMES = [
'leden', 'únor', 'březen', 'duben', 'květen', 'červen',
'červenec', 'srpen', 'září', 'říjen', 'listopad', 'prosinec'
]
interface CurrencyAmount {
amount: number
currency: string
}
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
}
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
}
interface UploadMeta {
supplier_name: string
invoice_number: string
amount: string
currency: string
vat_rate: string
issue_date: string
due_date: string
notes: string
}
interface EditInvoice extends Omit<ReceivedInvoice, 'amount' | 'vat_rate'> {
amount: string
vat_rate: string
_originalStatus: string
}
interface UploadErrors {
[idx: number]: {
[field: string]: string
}
}
interface ReceivedInvoicesProps {
statsMonth: number
statsYear: number
uploadOpen: boolean
setUploadOpen: (open: boolean) => void
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) { return '0 Kč' }
return amounts.map(a => formatCurrency(a.amount, a.currency)).join(' · ')
}
function formatCzkWithDetail(amounts: CurrencyAmount[], totalCzk: number | null | undefined): { value: string; detail: string | null } {
if (!Array.isArray(amounts) || amounts.length === 0) { return { value: '0 Kč', detail: null } }
const hasForeign = amounts.some(a => a.currency !== 'CZK')
if (hasForeign && totalCzk !== null && totalCzk !== undefined) {
return { value: formatCurrency(totalCzk, 'CZK'), detail: formatMultiCurrency(amounts) }
}
return { value: formatMultiCurrency(amounts), detail: null }
}
function emptyMeta(): UploadMeta {
return {
supplier_name: '',
invoice_number: '',
amount: '',
currency: 'CZK',
vat_rate: '21',
issue_date: '',
due_date: '',
notes: '',
}
}
export default function ReceivedInvoices({ statsMonth, statsYear, uploadOpen, setUploadOpen }: ReceivedInvoicesProps) {
const alert = useAlert()
const { hasPermission } = useAuth()
const { sort, order, handleSort, activeSort } = useTableSort('created_at')
const [search, setSearch] = useState('')
// Data
const [invoices, setInvoices] = useState<ReceivedInvoice[]>([])
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<ReceivedStats | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
const hasLoadedOnce = useRef(false)
const slideDirection = useRef(0)
const [slideKey, setSlideKey] = useState(0)
const prevMonth = useRef(statsMonth)
const prevYear = useRef(statsYear)
// Modals
const [editOpen, setEditOpen] = useState(false)
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; invoice: ReceivedInvoice | null }>({ show: false, invoice: null })
const [deleting, setDeleting] = useState(false)
const [saving, setSaving] = useState(false)
// Upload state
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([])
const [uploadErrors, setUploadErrors] = useState<UploadErrors>({})
const fileInputRef = useRef<HTMLInputElement>(null)
useModalLock(uploadOpen || editOpen)
// Slide direction detection
useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current
const curr = statsYear * 12 + statsMonth
if (curr > prev) { slideDirection.current = 1 }
if (curr < prev) { slideDirection.current = -1 }
prevMonth.current = statsMonth
prevYear.current = statsYear
}, [statsMonth, statsYear])
// Fetch list
const fetchList = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
month: String(statsMonth),
year: String(statsYear),
})
if (search) { params.set('search', search) }
if (sort) { params.set('sort', sort) }
if (order) { params.set('order', order) }
const res = await apiFetch(`${API_BASE}/received-invoices?${params}`)
const data = await res.json()
if (data.success) {
setInvoices(Array.isArray(data.data) ? data.data : [])
}
} catch { /* ignore */ } finally {
setLoading(false)
}
}, [statsMonth, statsYear, search, sort, order])
useEffect(() => { fetchList() }, [fetchList])
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
}
} catch { /* ignore */ }
}, [statsMonth, statsYear])
// Fetch stats on month change (with slide animation)
useEffect(() => {
setStatsLoading(true)
const load = async () => {
try {
const res = await apiFetch(`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`)
const data = await res.json()
if (data.success) {
setStats(data.data)
hasLoadedOnce.current = true
setSlideKey(k => k + 1)
}
} catch { /* ignore */ } finally {
setStatsLoading(false)
}
}
load()
}, [statsMonth, statsYear])
// Upload handlers
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || [])
if (selected.length === 0) { return }
if (uploadFiles.length + selected.length > 20) {
alert.error('Maximálně 20 souborů najednou')
return
}
const valid = selected.filter(f => {
if (f.size > 10 * 1024 * 1024) {
alert.error(`Soubor "${f.name}" je větší než 10 MB`)
return false
}
const allowed = ['application/pdf', 'image/jpeg', 'image/png']
if (!allowed.includes(f.type)) {
alert.error(`Soubor "${f.name}": nepodporovaný formát`)
return false
}
return true
})
setUploadFiles(prev => [...prev, ...valid])
setUploadMeta(prev => [...prev, ...valid.map(() => emptyMeta())])
e.target.value = ''
}
const removeUploadFile = (idx: number) => {
setUploadFiles(prev => prev.filter((_, i) => i !== idx))
setUploadMeta(prev => prev.filter((_, i) => i !== idx))
const newErrors = { ...uploadErrors }
delete newErrors[idx]
setUploadErrors(newErrors)
}
const updateMeta = (idx: number, field: keyof UploadMeta, value: string) => {
setUploadMeta(prev => prev.map((m, i) => i === idx ? { ...m, [field]: value } : m))
if (uploadErrors[idx]) {
const newErrors = { ...uploadErrors }
if (newErrors[idx]?.[field]) {
delete newErrors[idx][field]
if (Object.keys(newErrors[idx]).length === 0) { delete newErrors[idx] }
}
setUploadErrors(newErrors)
}
}
const validateUpload = (): boolean => {
const errors: UploadErrors = {}
uploadMeta.forEach((m, i) => {
const e: Record<string, string> = {}
if (!m.supplier_name.trim()) { e.supplier_name = 'Povinné pole' }
if (!m.amount || parseFloat(m.amount) <= 0) { e.amount = 'Částka musí být větší než 0' }
if (Object.keys(e).length > 0) { errors[i] = e }
})
setUploadErrors(errors)
return Object.keys(errors).length === 0
}
const handleUploadSave = async () => {
if (uploadFiles.length === 0) {
alert.error('Vyberte alespoň jeden soubor')
return
}
if (!validateUpload()) { return }
setSaving(true)
try {
const formData = new FormData()
uploadFiles.forEach(f => formData.append('files[]', f))
formData.append('invoices', JSON.stringify(uploadMeta))
const res = await apiFetch(`${API_BASE}/received-invoices`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktury byly nahrány')
setUploadOpen(false)
setUploadFiles([])
setUploadMeta([])
setUploadErrors({})
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při nahrávání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
// Edit handlers
const openEdit = (inv: ReceivedInvoice) => {
setEditInvoice({
...inv,
amount: String(inv.amount),
vat_rate: String(inv.vat_rate),
_originalStatus: inv.status,
})
setEditOpen(true)
}
const handleEditSave = async () => {
if (!editInvoice) { return }
if (!editInvoice.supplier_name?.trim()) {
alert.error('Dodavatel je povinný')
return
}
if (!editInvoice.amount || parseFloat(editInvoice.amount) <= 0) {
alert.error('Částka musí být větší než 0')
return
}
setSaving(true)
try {
const payload = {
supplier_name: editInvoice.supplier_name,
invoice_number: editInvoice.invoice_number || '',
amount: parseFloat(editInvoice.amount),
currency: editInvoice.currency,
vat_rate: parseFloat(editInvoice.vat_rate),
issue_date: editInvoice.issue_date || '',
due_date: editInvoice.due_date || '',
notes: editInvoice.notes || '',
status: editInvoice.status,
}
const res = await apiFetch(`${API_BASE}/received-invoices/${editInvoice.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktura byla aktualizována')
setEditOpen(false)
setEditInvoice(null)
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při ukládání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
// Delete
const handleDelete = async () => {
if (!deleteConfirm.invoice) { return }
setDeleting(true)
try {
const res = await apiFetch(`${API_BASE}/received-invoices/${deleteConfirm.invoice.id}`, {
method: 'DELETE',
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Faktura byla smazána')
setDeleteConfirm({ show: false, invoice: null })
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Chyba při mazání')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
// View file
const openFile = async (inv: ReceivedInvoice) => {
const newWindow = window.open('', '_blank')
try {
const response = await apiFetch(`${API_BASE}/received-invoices/${inv.id}/file`)
if (!response.ok) {
newWindow?.close()
alert.error('Nepodařilo se načíst soubor')
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
if (newWindow) { newWindow.location.href = url }
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
newWindow?.close()
alert.error('Chyba připojení')
}
}
const toggleStatus = async (inv: ReceivedInvoice) => {
if (inv.status === 'paid') return
try {
const res = await apiFetch(`${API_BASE}/received-invoices/${inv.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'paid' }),
})
const data = await res.json()
if (data.success) {
alert.success('Faktura označena jako uhrazená')
fetchList()
refreshStats()
} else {
alert.error(data.error || 'Nepodařilo se změnit stav')
}
} catch {
alert.error('Chyba připojení')
}
}
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`
// KPI
const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) {
return (
<div className="dash-kpi-grid dash-kpi-4 mb-6">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</div>
))}
</div>
)
}
if (!stats) { return null }
const total = formatCzkWithDetail(stats.total_month, stats.total_month_czk)
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk)
const unpaid = formatCzkWithDetail(stats.unpaid, stats.unpaid_czk)
return (
<div style={{ overflow: 'hidden', marginBottom: '1.5rem' }}>
<AnimatePresence mode="popLayout" initial={false} custom={slideDirection.current}>
<motion.div
key={slideKey}
className="dash-kpi-grid dash-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir: number) => ({ x: `${(dir || 0) * 105}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (dir: number) => ({ x: `${(dir || 0) * -105}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<div className="admin-stat-card success">
<div className="admin-stat-label">Celkem ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{total.value}</div>
<div className="admin-stat-footer">
{total.detail || `${stats.month_count} ${czechPlural(stats.month_count, 'faktura', 'faktury', 'faktur')}`}
</div>
</div>
<div className="admin-stat-card info">
<div className="admin-stat-label">DPH k odpočtu ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{vat.value}</div>
<div className="admin-stat-footer">{vat.detail || 'z přijatých faktur'}</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">Neuhrazeno <span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span></div>
<div className="admin-stat-value admin-mono">{unpaid.value}</div>
<div className="admin-stat-footer">
{unpaid.detail || (stats.unpaid_count === 0
? 'vše uhrazeno'
: `${stats.unpaid_count} ${czechPlural(stats.unpaid_count, 'faktura', 'faktury', 'faktur')}`
)}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Počet ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{stats.month_count}</div>
<div className="admin-stat-footer">
{stats.month_count === 0 ? 'žádné faktury' : `přijatých faktur`}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
)
}
return (
<>
{renderKpi()}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat podle dodavatele nebo čísla faktury..."
/>
</div>
{loading && (
<div className="admin-skeleton" style={{ gap: '1rem' }}>
{[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/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && invoices.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné přijaté faktury v tomto měsíci.</p>
{hasPermission('invoices.create') && (
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
Nahrajte faktury tlačítkem výše.
</p>
)}
</div>
)}
{!loading && invoices.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('supplier_name')}>
Dodavatel <SortIcon column="supplier_name" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('invoice_number')}>
Č. faktury <SortIcon column="invoice_number" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('issue_date')}>
Vystaveno <SortIcon column="issue_date" sort={activeSort} order={order} />
</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('due_date')}>
Splatnost <SortIcon column="due_date" sort={activeSort} order={order} />
</th>
<th style={{ textAlign: 'right', cursor: 'pointer' }} onClick={() => handleSort('amount')}>
Částka <SortIcon column="amount" sort={activeSort} order={order} />
</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id}>
<td>{inv.supplier_name}</td>
<td className="admin-mono">
{inv.invoice_number ? (
<span className="link-accent" style={{ cursor: 'pointer' }} onClick={() => openFile(inv)}>
{inv.invoice_number}
</span>
) : '—'}
</td>
<td>
{inv.status === 'paid' ? (
<span className={`admin-badge ${STATUS_CLASSES[inv.status]}`}>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ''}`}
style={{ cursor: 'pointer' }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">{formatDate(inv.issue_date)}</td>
<td className="admin-mono">{formatDate(inv.due_date)}</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(inv.amount, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
{inv.file_name && (
<button className="admin-btn-icon" title="Zobrazit soubor" onClick={() => openFile(inv)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
)}
{hasPermission('invoices.edit') && (
<button className="admin-btn-icon" title="Upravit" onClick={() => openEdit(inv)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
{hasPermission('invoices.delete') && (
<button
className="admin-btn-icon danger"
title="Smazat"
onClick={() => setDeleteConfirm({ show: true, invoice: inv })}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Upload Modal */}
<AnimatePresence>
{uploadOpen && (
<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={() => !saving && setUploadOpen(false)} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Nahrát přijaté faktury</h2>
</div>
<div className="admin-modal-body">
<div className="mb-4">
<input
ref={fileInputRef}
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<button
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => fileInputRef.current?.click()}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubory
</button>
<span style={{ marginLeft: '0.75rem', fontSize: '0.8125rem', color: 'var(--text-tertiary)' }}>
PDF, JPEG, PNG · max 10 MB · max 20 souborů
</span>
</div>
{uploadFiles.length === 0 && (
<div className="admin-empty-state" style={{ padding: '2rem 0' }}>
<p style={{ color: 'var(--text-tertiary)' }}>Zatím nebyly vybrány žádné soubory.</p>
</div>
)}
<div className="received-upload-list">
{uploadFiles.map((file, idx) => (
<div key={`${file.name}-${idx}`} className="received-upload-card">
<div className="received-upload-card-header">
<div className="received-upload-file-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="received-upload-file-name">{file.name}</span>
<span className="received-upload-file-size">{Math.round(file.size / 1024)} KB</span>
</div>
<button className="admin-btn-icon danger" style={{ width: '24px', height: '24px' }} onClick={() => removeUploadFile(idx)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="received-upload-card-fields">
<FormField label="Dodavatel" error={uploadErrors[idx]?.supplier_name} required>
<input
type="text"
className={`admin-form-input${uploadErrors[idx]?.supplier_name ? ' has-error' : ''}`}
value={uploadMeta[idx]?.supplier_name || ''}
onChange={(e) => updateMeta(idx, 'supplier_name', e.target.value)}
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.invoice_number || ''}
onChange={(e) => updateMeta(idx, 'invoice_number', e.target.value)}
/>
</FormField>
<div className="received-upload-row">
<FormField label="Částka" error={uploadErrors[idx]?.amount} required style={{ flex: 1 }}>
<input
type="number"
step="0.01"
min="0"
className={`admin-form-input${uploadErrors[idx]?.amount ? ' has-error' : ''}`}
value={uploadMeta[idx]?.amount || ''}
onChange={(e) => updateMeta(idx, 'amount', e.target.value)}
/>
</FormField>
<FormField label="Měna" style={{ width: '90px' }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.currency || 'CZK'}
onChange={(e) => updateMeta(idx, 'currency', e.target.value)}
>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FormField>
<FormField label="DPH %" style={{ width: '90px' }}>
<select
className="admin-form-select"
value={uploadMeta[idx]?.vat_rate || '21'}
onChange={(e) => updateMeta(idx, 'vat_rate', e.target.value)}
>
{VAT_RATE_OPTIONS.map(r => <option key={r} value={String(r)}>{r}%</option>)}
</select>
</FormField>
</div>
{uploadMeta[idx]?.amount && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginTop: '-0.25rem', marginBottom: '0.5rem' }}>
DPH: {formatCurrency(
parseFloat(uploadMeta[idx].amount || '0') * parseFloat(uploadMeta[idx].vat_rate || '21') / 100,
uploadMeta[idx].currency || 'CZK'
)}
</div>
)}
<div className="received-upload-row">
<FormField label="Datum vystavení" style={{ flex: 1 }}>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.issue_date || ''}
onChange={(val: string) => updateMeta(idx, 'issue_date', val)}
/>
</FormField>
<FormField label="Datum splatnosti" style={{ flex: 1 }}>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.due_date || ''}
onChange={(val: string) => updateMeta(idx, 'due_date', val)}
/>
</FormField>
</div>
<FormField label="Poznámka">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.notes || ''}
onChange={(e) => updateMeta(idx, 'notes', e.target.value)}
/>
</FormField>
</div>
</div>
))}
</div>
</div>
<div className="admin-modal-footer">
<button className="admin-btn admin-btn-secondary" onClick={() => !saving && setUploadOpen(false)} disabled={saving}>
Zrušit
</button>
<button className="admin-btn admin-btn-primary" onClick={handleUploadSave} disabled={saving || uploadFiles.length === 0}>
{saving ? 'Nahrávání...' : 'Uložit vše'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Edit Modal */}
<AnimatePresence>
{editOpen && editInvoice && (
<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={() => !saving && setEditOpen(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 }}
>
{(() => {
const ro = editInvoice._originalStatus === 'paid'
return (
<>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{ro ? 'Detail přijaté faktury' : 'Upravit přijatou fakturu'}</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Dodavatel" required>
<input
type="text"
className="admin-form-input"
value={editInvoice.supplier_name}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, supplier_name: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={editInvoice.invoice_number || ''}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, invoice_number: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<div className="admin-form-row admin-form-row-3">
<FormField label="Částka" required>
<input
type="number"
step="0.01"
min="0"
className="admin-form-input"
value={editInvoice.amount}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, amount: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
<FormField label="Měna">
<select
className="admin-form-select"
value={editInvoice.currency}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, currency: e.target.value } : null)}
disabled={ro}
>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FormField>
<FormField label="DPH %">
<select
className="admin-form-select"
value={editInvoice.vat_rate}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, vat_rate: e.target.value } : null)}
disabled={ro}
>
{VAT_RATE_OPTIONS.map(r => <option key={r} value={String(r)}>{r}%</option>)}
</select>
</FormField>
</div>
{editInvoice.amount && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.75rem' }}>
DPH: {formatCurrency(
parseFloat(editInvoice.amount || '0') * parseFloat(editInvoice.vat_rate || '21') / 100,
editInvoice.currency || 'CZK'
)}
</div>
)}
<div className="admin-form-row">
<FormField label="Datum vystavení">
<AdminDatePicker
mode="date"
value={editInvoice.issue_date || ''}
onChange={(val: string) => setEditInvoice(prev => prev ? { ...prev, issue_date: val } : null)}
disabled={ro}
/>
</FormField>
<FormField label="Datum splatnosti">
<AdminDatePicker
mode="date"
value={editInvoice.due_date || ''}
onChange={(val: string) => setEditInvoice(prev => prev ? { ...prev, due_date: val } : null)}
disabled={ro}
/>
</FormField>
</div>
<FormField label="Stav">
<select
className="admin-form-select"
value={editInvoice.status}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, status: e.target.value } : null)}
disabled={ro}
>
<option value="unpaid">Neuhrazena</option>
<option value="paid">Uhrazena</option>
</select>
</FormField>
<FormField label="Poznámka">
<textarea
className="admin-form-input"
rows={3}
value={editInvoice.notes || ''}
onChange={(e) => setEditInvoice(prev => prev ? { ...prev, notes: e.target.value } : null)}
readOnly={ro}
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
{ro ? (
<button className="admin-btn admin-btn-secondary" onClick={() => setEditOpen(false)}>
Zavřít
</button>
) : (
<>
<button className="admin-btn admin-btn-secondary" onClick={() => !saving && setEditOpen(false)} disabled={saving}>
Zrušit
</button>
<button className="admin-btn admin-btn-primary" onClick={handleEditSave} disabled={saving}>
{saving ? 'Ukládání...' : 'Uložit'}
</button>
</>
)}
</div>
</>
)
})()}
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat přijatou fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.supplier_name || ''}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,643 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useNavigate, Navigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const MODULE_LABELS: Record<string, string> = {
attendance: 'Docházka',
trips: 'Kniha jízd',
offers: 'Nabídky',
orders: 'Objednávky',
projects: 'Projekty',
invoices: 'Faktury',
users: 'Uživatelé',
settings: 'Nastavení'
}
interface Permission {
id: number
name: string
display_name: string
description?: string
}
interface Role {
id: number
name: string
display_name: string
description: string | null
permissions: Permission[]
role_permissions?: unknown[]
}
interface RoleForm {
name: string
display_name: string
description: string
permissions: string[]
}
export default function Settings() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [roles, setRoles] = useState<Role[]>([])
const [, setAllPermissions] = useState<Permission[]>([])
const [permissionGroups, setPermissionGroups] = useState<Record<string, Permission[]>>({})
// 2FA requirement
const [require2FA, setRequire2FA] = useState(false)
const [require2FALoading, setRequire2FALoading] = useState(true)
const [require2FASaving, setRequire2FASaving] = useState(false)
const [showModal, setShowModal] = useState(false)
const [editingRole, setEditingRole] = useState<Role | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<RoleForm>({
name: '',
display_name: '',
description: '',
permissions: []
})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; role: Role | null }>({ show: false, role: null })
const [deleting, setDeleting] = useState(false)
const canRoles = hasPermission('settings.roles')
const canSecurity = hasPermission('settings.security')
if (!canRoles && !canSecurity) {
return <Navigate to="/" replace />
}
useModalLock(showModal)
const fetchData = useCallback(async () => {
if (!canRoles) {
setLoading(false)
return
}
try {
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
])
const rolesResult = await rolesRes.json()
const permsResult = await permsRes.json()
if (rolesResult.success) {
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : [])
} else {
alert.error(rolesResult.error || 'Nepodařilo se načíst role')
}
if (permsResult.success) {
const perms: Permission[] = Array.isArray(permsResult.data) ? permsResult.data : []
setAllPermissions(perms)
// Group by module (part before '.')
const groups: Record<string, Permission[]> = {}
for (const p of perms) {
const mod = p.name.split('.')[0] || 'other'
if (!groups[mod]) groups[mod] = []
groups[mod].push(p)
}
setPermissionGroups(groups)
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [alert, canRoles])
useEffect(() => {
fetchData()
}, [fetchData])
const fetch2FARequired = useCallback(async () => {
// TODO: Backend endpoint for 2FA requirement settings not yet implemented
setRequire2FALoading(false)
}, [])
useEffect(() => {
fetch2FARequired()
}, [fetch2FARequired])
const handleToggle2FARequired = async () => {
// TODO: Backend endpoint for 2FA requirement settings not yet implemented
alert.error('Tato funkce zatím není k dispozici')
}
const generateSlug = (text: string): string => {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const openCreateModal = () => {
setEditingRole(null)
setForm({ name: '', display_name: '', description: '', permissions: [] })
setShowModal(true)
}
const openEditModal = (role: Role) => {
setEditingRole(role)
setForm({
name: role.name,
display_name: role.display_name,
description: role.description || '',
permissions: (role.permissions || []).map(p => typeof p === 'string' ? p : p.name)
})
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingRole(null)
}
const handleDisplayNameChange = (value: string) => {
const updates: Partial<RoleForm> = { display_name: value }
if (!editingRole) {
updates.name = generateSlug(value)
}
setForm(prev => ({ ...prev, ...updates }))
}
const togglePermission = (permName: string) => {
setForm(prev => ({
...prev,
permissions: prev.permissions.includes(permName)
? prev.permissions.filter(p => p !== permName)
: [...prev.permissions, permName]
}))
}
const toggleModulePermissions = (moduleName: string) => {
const modulePerms = (permissionGroups[moduleName] || []).map(p => p.name)
const allChecked = modulePerms.every(p => form.permissions.includes(p))
setForm(prev => ({
...prev,
permissions: allChecked
? prev.permissions.filter(p => !modulePerms.includes(p))
: [...new Set([...prev.permissions, ...modulePerms])]
}))
}
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!form.display_name.trim()) {
alert.error('Zobrazovaný název je povinný')
return
}
if (!editingRole && !form.name.trim()) {
alert.error('Název role je povinný')
return
}
setSaving(true)
try {
const url = editingRole
? `${API_BASE}/roles/${editingRole.id}`
: `${API_BASE}/roles`
const response = await apiFetch(url, {
method: editingRole ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...form,
permission_ids: form.permissions.map(name => {
// Find permission ID by name from groups
for (const perms of Object.values(permissionGroups)) {
const found = perms.find(p => p.name === name)
if (found) return found.id
}
return null
}).filter(Boolean),
})
})
const result = await response.json()
if (result.success) {
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message || (editingRole ? 'Role byla aktualizována' : 'Role byla vytvořena'))
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se uložit roli')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteConfirm.role) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/roles/${deleteConfirm.role.id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, role: null })
alert.success(result.message || 'Role byla smazána')
fetchData()
} else {
alert.error(result.error || 'Nepodařilo se smazat roli')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<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 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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
const isAdminRole = (role: Role) => role.name === 'admin'
const get2FADescription = (): React.ReactNode => {
if (require2FALoading) {
return <div className="admin-skeleton-line" style={{ width: '200px', height: '12px' }} />
}
if (require2FA) return 'Všichni uživatelé musí mít aktivní 2FA pro přístup do systému'
return '2FA je volitelná - uživatelé si ji mohou aktivovat v profilu'
}
const get2FAButtonLabel = (): string => {
if (require2FASaving) return 'Ukládání...'
return require2FA ? 'Vypnout' : 'Zapnout'
}
const renderRoleButtonContent = (): React.ReactNode => {
if (saving) {
return <><div className="admin-spinner admin-spinner-sm" />Ukládání...</>
}
return editingRole ? 'Uložit změny' : 'Vytvořit roli'
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Nastavení</h1>
<p className="admin-page-subtitle">Zabezpečení a správa rolí</p>
</div>
{canRoles && (
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat roli
</button>
)}
</motion.div>
{/* Security Settings */}
{canSecurity && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Zabezpečení</h2>
</div>
<div className="admin-card-body">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
<div className="flex-row-gap">
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: require2FA ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
color: require2FA ? 'var(--success)' : 'var(--text-secondary)',
flexShrink: 0
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<div style={{ fontWeight: 500, color: 'var(--text-primary)', fontSize: '0.875rem' }}>
Povinné dvoufaktorové ověření (2FA)
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{get2FADescription()}
</div>
</div>
</div>
{!require2FALoading && (
<button
onClick={handleToggle2FARequired}
disabled={require2FASaving}
className={`admin-btn admin-btn-sm ${require2FA ? 'admin-btn-secondary' : 'admin-btn-primary'}`}
style={require2FA ? { color: 'var(--danger)' } : {}}
>
{get2FAButtonLabel()}
</button>
)}
</div>
</div>
</motion.div>
)}
{/* Roles Table */}
{canRoles && <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Popis</th>
<th>Oprávnění</th>
<th>Uživatelé</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{roles.map((role) => (
<tr key={role.id}>
<td>
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>
{role.display_name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{role.name}
</div>
</td>
<td style={{ color: 'var(--text-secondary)' }}>
{role.description || '\u2014'}
</td>
<td>
<span className="admin-badge admin-badge-info">
{isAdminRole(role) ? 'Vše' : (role.permissions?.length ?? 0)}
</span>
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{0}
</span>
</td>
<td>
{!isAdminRole(role) && (
<div className="flex-row gap-2">
<button
onClick={() => openEditModal(role)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, role })}
className="admin-btn-icon danger"
title={0 > 0 ? 'Nelze smazat roli s přiřazenými uživateli' : 'Smazat'}
aria-label={0 > 0 ? 'Nelze smazat roli s přiřazenými uživateli' : 'Smazat'}
disabled={0 > 0}
>
<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>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</motion.div>}
{/* Create/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={closeModal} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingRole ? 'Upravit roli' : 'Nová role'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
{editingRole && isAdminRole(editingRole) && (
<div className="admin-role-locked-notice">
<svg width="16" height="16" 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>
Administrátor vždy plný přístup ke všem funkcím
</div>
)}
<FormField label="Zobrazovaný název">
<input
type="text"
value={form.display_name}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="admin-form-input"
placeholder="např. Manažer"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<FormField label="Systémový název (slug)">
<input
type="text"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
className="admin-form-input"
placeholder="např. manager"
disabled={!!editingRole}
/>
{!editingRole && (
<small style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
Pouze malá písmena, čísla a pomlčky. Nelze později změnit.
</small>
)}
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}
className="admin-form-input"
rows={2}
placeholder="Volitelný popis role"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<div className="admin-form-group">
<label className="admin-form-label" style={{ marginBottom: '0.75rem' }}>Oprávnění</label>
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
if (a === 'settings') return 1
if (b === 'settings') return -1
const aMin = Math.min(...aPerms.map(p => p.id))
const bMin = Math.min(...bPerms.map(p => p.id))
return aMin - bMin
})
.map(([module, perms], index) => {
const modulePerms = perms.map(p => p.name)
const allChecked = modulePerms.every(p => form.permissions.includes(p))
const someChecked = modulePerms.some(p => form.permissions.includes(p))
const disabled = !!(editingRole && isAdminRole(editingRole))
return (
<div key={module}>
{index > 0 && <hr style={{ border: 'none', borderTop: '1px solid var(--border-color, #e0e0e0)', margin: '0.75rem 0' }} />}
<div className="admin-permission-group">
<div className="admin-permission-group-title">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el) el.indeterminate = someChecked && !allChecked
}}
onChange={() => toggleModulePermissions(module)}
disabled={disabled}
/>
<span>{MODULE_LABELS[module] || module}</span>
</label>
</div>
<div className="admin-permission-list">
{perms.map((perm) => (
<div key={perm.id} className="admin-permission-item">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.permissions.includes(perm.name)}
onChange={() => togglePermission(perm.name)}
disabled={disabled}
/>
<span>{perm.display_name}</span>
</label>
{perm.description && (
<div className="admin-permission-desc">{perm.description}</div>
)}
</div>
))}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary" disabled={saving}>
Zrušit
</button>
{!(editingRole && isAdminRole(editingRole)) && (
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary" disabled={saving}>
{renderRoleButtonContent()}
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirm Modal */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, role: null })}
onConfirm={handleDelete}
title="Smazat roli"
message={`Opravdu chcete smazat roli "${deleteConfirm.role?.display_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

653
src/admin/pages/Trips.tsx Normal file
View File

@@ -0,0 +1,653 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Vehicle {
id: number | string
spz: string
name: string
}
interface Trip {
id: number
vehicle_id: number | string
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 }
}
interface TripForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
}
export default function Trips() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [showModal, setShowModal] = useState(false)
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; tripId: number | null }>({ show: false, tripId: null })
const [form, setForm] = useState<TripForm>({
vehicle_id: '',
trip_date: new Date().toISOString().split('T')[0],
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [, setLastKm] = useState(0)
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`),
])
const tripsResult = await tripsRes.json()
const vehiclesResult = await vehiclesRes.json()
if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : [])
}
if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showModal)
if (!hasPermission('trips.record')) return <Forbidden />
const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) {
setLastKm(0)
return
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
const result = await response.json()
if (result.success) {
const km = result.data?.last_km || 0
setLastKm(km)
if (!editingTrip) {
setForm(prev => ({ ...prev, start_km: km }))
}
return
}
} catch { /* fallback below */ }
setLastKm(0)
}
const openCreateModal = () => {
setEditingTrip(null)
const today = new Date().toISOString().split('T')[0]
setForm({
vehicle_id: '',
trip_date: today,
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
setLastKm(0)
setErrors({})
setShowModal(true)
}
const openEditModal = (trip: Trip) => {
setEditingTrip(trip)
setForm({
vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date,
start_km: trip.start_km,
end_km: trip.end_km,
route_from: trip.route_from,
route_to: trip.route_to,
is_business: Number(trip.is_business),
notes: trip.notes || ''
})
setLastKm(trip.start_km)
setErrors({})
setShowModal(true)
}
const handleVehicleChange = (vehicleId: string) => {
setForm(prev => ({ ...prev, vehicle_id: vehicleId }))
fetchLastKm(vehicleId)
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!form.vehicle_id) newErrors.vehicle_id = 'Vyberte vozidlo'
if (!form.trip_date) newErrors.trip_date = 'Zadejte datum'
if (!form.start_km) newErrors.start_km = 'Zadejte počáteční km'
if (!form.end_km) newErrors.end_km = 'Zadejte konečný km'
if (form.start_km && form.end_km && parseInt(String(form.end_km)) <= parseInt(String(form.start_km))) {
newErrors.end_km = 'Musí být větší než počáteční'
}
if (!form.route_from) newErrors.route_from = 'Zadejte místo odjezdu'
if (!form.route_to) newErrors.route_to = 'Zadejte místo příjezdu'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async () => {
if (!validateForm()) return
setSubmitting(true)
try {
const url = editingTrip
? `${API_BASE}/trips/${editingTrip.id}`
: `${API_BASE}/trips`
const response = await apiFetch(url, {
method: editingTrip ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
const result = await response.json()
if (result.success) {
setShowModal(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í')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (tripId: number) => {
try {
const response = await apiFetch(`${API_BASE}/trips/${tripId}`, {
method: 'DELETE',
})
const result = await response.json()
if (result.success) {
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleteConfirm({ show: false, tripId: null })
}
}
const calculateDistance = (): number => {
const start = parseInt(String(form.start_km)) || 0
const end = parseInt(String(form.end_km)) || 0
return end > start ? end - start : 0
}
if (loading) {
return (
<div>
<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 className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
</div>
<div className="admin-grid admin-grid-4">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
</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>
</div>
)
}
const totals = trips.reduce(
(acc, t) => {
const dist = t.distance ?? (t.end_km - t.start_km)
acc.count++
acc.total += dist
if (t.is_business) acc.business += dist
else acc.private += dist
return acc
},
{ total: 0, business: 0, private: 0, count: 0 }
)
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Kniha jízd</h1>
<p className="admin-page-subtitle">
{new Date().toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })}
</p>
</div>
<div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat jízdu
</button>
</div>
</motion.div>
{/* Stats Cards */}
<motion.div
className="admin-grid admin-grid-4"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-label">Služební</span>
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-icon warning">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.private)} km</span>
<span className="admin-stat-label">Soukromé</span>
</div>
</div>
</motion.div>
{/* Recent Trips */}
<motion.div
className="admin-card mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední jízdy</h2>
<Link to="/trips/history" className="admin-btn admin-btn-secondary admin-btn-sm">
Zobrazit historii
</Link>
</div>
<div className="admin-card-body">
{trips.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Zatím nemáte žádné záznamy jízd.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
Přidat první jízdu
</button>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.slice(0, 10).map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>
<span className="admin-badge">{trip.vehicles?.spz ?? ''}</span>
</td>
<td>{trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : ''}</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance ?? (trip.end_km - trip.start_km))} km</strong></td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(false)} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingTrip ? 'Upravit jízdu' : 'Přidat jízdu'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo" error={errors.vehicle_id} required>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value)
setErrors(prev => ({ ...prev, vehicle_id: '' }))
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum jízdy" error={errors.trip_date} required>
<AdminDatePicker
mode="date"
value={form.trip_date}
onChange={(val: string) => {
setForm({ ...form, trip_date: val })
setErrors(prev => ({ ...prev, trip_date: '' }))
}}
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Počáteční stav km" error={errors.start_km} required>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value })
setErrors(prev => ({ ...prev, start_km: '' }))
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km" error={errors.end_km} required>
<input
type="number"
inputMode="numeric"
value={form.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value })
setErrors(prev => ({ ...prev, end_km: '' }))
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu" error={errors.route_from} required>
<input
type="text"
value={form.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value })
setErrors(prev => ({ ...prev, route_from: '' }))
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<FormField label="Místo příjezdu" error={errors.route_to} required>
<input
type="text"
value={form.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value })
setErrors(prev => ({ ...prev, route_to: '' }))
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) => setForm({ ...form, is_business: parseInt(e.target.value) })}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting}
>
{submitting ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
onConfirm={() => handleDelete(deleteConfirm.tripId!)}
title="Smazat jízdu"
message="Opravdu chcete smazat tento záznam?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
/>
</div>
)
}

View File

@@ -0,0 +1,831 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Vehicle {
id: number | string
spz: string
name: string
}
interface UserShort {
id: number | string
name: string
}
interface Trip {
id: number
vehicle_id: number | string
trip_date: string
start_km: number
end_km: number
distance: number
route_from: string
route_to: string
is_business: number | boolean
notes?: string
spz: string
driver_name: string
}
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 }
}
interface EditForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
}
function mapTrip(bt: BackendTrip): Trip {
const distance = bt.distance ?? (bt.end_km - bt.start_km)
return {
id: bt.id,
vehicle_id: bt.vehicle_id,
trip_date: bt.trip_date,
start_km: bt.start_km,
end_km: bt.end_km,
distance,
route_from: bt.route_from,
route_to: bt.route_to,
is_business: bt.is_business ? 1 : 0,
notes: bt.notes || undefined,
spz: bt.vehicles?.spz ?? '',
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : '',
}
}
export default function TripsAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [filterMonth, setFilterMonth] = useState(() => String(new Date().getMonth() + 1))
const [filterYear, setFilterYear] = useState(() => String(new Date().getFullYear()))
const [filterVehicleId, setFilterVehicleId] = useState('')
const [filterUserId, setFilterUserId] = useState('')
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [users, setUsers] = useState<UserShort[]>([])
const printRef = useRef<HTMLDivElement>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
const [editForm, setEditForm] = useState<EditForm>({
vehicle_id: '',
trip_date: '',
start_km: '',
end_km: '',
route_from: '',
route_to: '',
is_business: 1,
notes: ''
})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; trip: Trip | null }>({ show: false, trip: null })
// Fetch vehicles and users once on mount
useEffect(() => {
const fetchLookups = async () => {
try {
const [vRes, uRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/users?limit=1000`),
])
const vJson = await vRes.json()
const uJson = await uRes.json()
if (vJson.success) setVehicles(vJson.data)
if (uJson.success) {
setUsers(uJson.data.map((u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
})))
}
} catch {
// silently fail, filters will just be empty
}
}
fetchLookups()
}, [])
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`
if (filterUserId) url += `&user_id=${filterUserId}`
const response = await apiFetch(url)
const result = await response.json()
if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip)
setTrips(mapped)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [filterMonth, filterYear, filterVehicleId, filterUserId, alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showEditModal)
if (!hasPermission('trips.admin')) return <Forbidden />
const openEditModal = (trip: Trip) => {
setEditingTrip(trip)
setEditForm({
vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date,
start_km: trip.start_km,
end_km: trip.end_km,
route_from: trip.route_from,
route_to: trip.route_to,
is_business: Number(trip.is_business),
notes: trip.notes || ''
})
setShowEditModal(true)
}
const handleEditSubmit = async () => {
if (!editingTrip) return
if (parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))) {
alert.error('Konečný stav km musí být větší než počáteční')
return
}
try {
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(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 handleDelete = async () => {
if (!deleteConfirm.trip) return
try {
const response = await apiFetch(`${API_BASE}/trips/${deleteConfirm.trip.id}`, {
method: 'DELETE',
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, trip: null })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const getPeriodName = () => new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString('cs-CZ', { month: 'long', year: 'numeric' })
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null
const v = vehicles.find(v => String(v.id) === filterVehicleId)
return v ? `${v.spz} - ${v.name}` : null
}
const getSelectedUserName = () => {
if (!filterUserId) return null
const u = users.find(u => String(u.id) === filterUserId)
return u?.name || null
}
const handlePrint = () => {
const periodName = getPeriodName()
setTimeout(() => {
if (printRef.current) {
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${periodName}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
</style>
</head>
<body>
${content}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
}, 100)
}
const calculateDistance = (): number => {
const start = parseInt(String(editForm.start_km)) || 0
const end = parseInt(String(editForm.end_km)) || 0
return end > start ? end - start : 0
}
const totals = {
count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips.filter(t => Number(t.is_business)).reduce((sum, t) => sum + t.distance, 0),
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa knihy jízd</h1>
</div>
<div className="admin-page-actions">
{trips.length > 0 && (
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk knihy jízd"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
)}
<Link to="/vehicles" className="admin-btn admin-btn-secondary">
Vozidla
</Link>
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row admin-form-row-4">
<FormField label="Měsíc" style={{ marginBottom: 0 }}>
<select
value={filterMonth}
onChange={(e) => setFilterMonth(e.target.value)}
className="admin-form-select"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(2000, i).toLocaleString('cs-CZ', { month: 'long' })}
</option>
))}
</select>
</FormField>
<FormField label="Rok" style={{ marginBottom: 0 }}>
<select
value={filterYear}
onChange={(e) => setFilterYear(e.target.value)}
className="admin-form-select"
>
{Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i
return <option key={y} value={y}>{y}</option>
})}
</select>
</FormField>
<FormField label="Vozidlo" style={{ marginBottom: 0 }}>
<select
value={filterVehicleId}
onChange={(e) => setFilterVehicleId(e.target.value)}
className="admin-form-select"
>
<option value="">Všechna vozidla</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Řidič" style={{ marginBottom: 0 }}>
<select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni řidiči</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-grid admin-grid-3 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
</motion.div>
{/* Trips Table */}
<motion.div
className="admin-card mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{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 && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Řidič</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: 'nowrap' }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, trip })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingTrip && (
<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 admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit jízdu</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
{editingTrip.driver_name}
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) => setEditForm({ ...editForm, vehicle_id: e.target.value })}
className="admin-form-select"
>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) => setEditForm({ ...editForm, trip_date: val })}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) => setEditForm({ ...editForm, start_km: e.target.value })}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) => setEditForm({ ...editForm, end_km: e.target.value })}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) => setEditForm({ ...editForm, route_from: e.target.value })}
className="admin-form-input"
/>
</FormField>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) => setEditForm({ ...editForm, route_to: e.target.value })}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) => setEditForm({ ...editForm, is_business: parseInt(e.target.value) })}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
/>
</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>
)}
</AnimatePresence>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, trip: null })}
onConfirm={handleDelete}
title="Smazat záznam"
message={deleteConfirm.trip ? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?` : ''}
confirmText="Smazat"
confirmVariant="danger"
/>
{/* Hidden Print Content */}
{trips.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{getPeriodName()}</div>
{getSelectedVehicleName() && <div className="filters">Vozidlo: {getSelectedVehicleName()}</div>}
{getSelectedUserName() && <div className="filters">Řidič: {getSelectedUserName()}</div>}
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
<div className="summary">
<div className="summary-item">
<div className="summary-value">{totals.count}</div>
<div className="summary-label">Počet jízd</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total)} km</div>
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.business)} km</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total - totals.business)} km</div>
<div className="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '80px' }}>Řidič</th>
<th style={{ width: '70px' }}>Vozidlo</th>
<th>Trasa</th>
<th style={{ width: '70px' }} className="text-right">Stav km</th>
<th style={{ width: '60px' }} className="text-right">Vzdálenost</th>
<th style={{ width: '55px' }} className="text-center">Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>{trip.spz}</td>
<td>{trip.route_from} &rarr; {trip.route_to}</td>
<td className="text-right">{formatKm(trip.start_km)} - {formatKm(trip.end_km)}</td>
<td className="text-right"><strong>{formatKm(trip.distance)} km</strong></td>
<td className="text-center">
<span className={`badge ${trip.is_business ? 'badge-success' : 'badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td>{trip.notes || ''}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">Celkem:</td>
<td className="text-right"><strong>{formatKm(totals.total)} km</strong></td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,273 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
interface Vehicle {
id: number | string
spz: string
name: string
}
interface Trip {
id: number
trip_date: string
spz: string
driver_name: string
route_from: string
route_to: string
start_km: number
end_km: number
distance: number
is_business: number | boolean
notes?: string
}
export default function TripsHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [vehicleId, setVehicleId] = useState('')
const [trips, setTrips] = useState<Trip[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const totals = trips.reduce(
(acc, t) => ({
total: acc.total + (t.distance || 0),
business: acc.business + (t.is_business ? (t.distance || 0) : 0),
count: acc.count + 1,
}),
{ total: 0, business: 0, count: 0 }
)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ month })
if (user?.id) params.set('user_id', String(user.id))
if (vehicleId) params.set('vehicle_id', vehicleId)
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips?${params}`),
apiFetch(`${API_BASE}/vehicles`),
])
if (tripsRes.status === 401) return
const tripsResult = await tripsRes.json()
const vehiclesResult = await vehiclesRes.json()
if (tripsResult.success) {
const raw = Array.isArray(tripsResult.data) ? tripsResult.data : tripsResult.data?.items || []
setTrips(raw.map((t: Record<string, unknown>) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || '',
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ''} ${(t.users as Record<string, string>).last_name || ''}`.trim()
: '',
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})))
}
if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
setLoading(false)
}
}, [month, vehicleId, alert, user?.id])
useEffect(() => {
fetchData()
}, [fetchData])
if (!hasPermission('trips.history')) return <Forbidden />
const getMonthName = (monthStr: string): string => {
const [yearStr, monthNum] = monthStr.split('-')
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1)
return date.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Historie jízd</h1>
<p className="admin-page-subtitle">{getMonthName(month)}</p>
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
<FormField label="Vozidlo">
<select
value={vehicleId}
onChange={(e) => setVehicleId(e.target.value)}
className="admin-form-select"
>
<option value="">Všechna vozidla</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-grid admin-grid-3 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
</motion.div>
{/* Trips Table */}
<motion.div
className="admin-card mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton gap-5">
{[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 && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: 'var(--text-secondary)' }}>{trip.driver_name}</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
</span>
</td>
<td style={{ color: 'var(--text-secondary)', maxWidth: '200px' }}>
{trip.notes || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
</div>
)
}

495
src/admin/pages/Users.tsx Normal file
View File

@@ -0,0 +1,495 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
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
}
interface Role {
id: number
name: string
display_name: string
}
interface FormData {
username: string
email: string
password: string
first_name: string
last_name: string
role_id: number | string
is_active: boolean
}
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth()
const alert = useAlert()
const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user: User | null }>({ isOpen: false, user: null })
const [deleting, setDeleting] = useState(false)
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: '',
is_active: true
})
useModalLock(showModal)
const fetchUsers = useCallback(async () => {
try {
const usersRes = await apiFetch(`${API_BASE}/users`)
const usersData = await usersRes.json()
if (usersData.success) {
setUsers(Array.isArray(usersData.data) ? usersData.data : [])
} else {
alert.error(usersData.error || 'Nepodařilo se načíst uživatele')
}
// Roles fetch — gracefully handle 403 if user lacks settings.roles permission
try {
const rolesRes = await apiFetch(`${API_BASE}/roles`)
const rolesData = await rolesRes.json()
if (rolesData.success) {
setRoles(Array.isArray(rolesData.data) ? rolesData.data : [])
}
} catch { /* roles not accessible */ }
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers()
}, [fetchUsers])
if (!hasPermission('users.view')) return <Forbidden />
const openCreateModal = () => {
setEditingUser(null)
setFormData({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: roles[0]?.id || '',
is_active: true
})
setShowModal(true)
}
const openEditModal = (user: User) => {
setEditingUser(user)
setFormData({
username: user.username,
email: user.email,
password: '',
first_name: user.first_name,
last_name: user.last_name,
role_id: user.role_id,
is_active: user.is_active
})
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingUser(null)
}
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
const dataToSave = { ...formData }
const wasEditing = editingUser
const editingId = editingUser?.id
try {
const url = wasEditing
? `${API_BASE}/users/${editingId}`
: `${API_BASE}/users`
const method = wasEditing ? 'PUT' : 'POST'
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
const data = await response.json()
if (data.success) {
if (wasEditing && currentUser && Number(editingId) === Number(currentUser.id)) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
}
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(wasEditing ? 'Uživatel byl upraven' : 'Uživatel byl vytvořen')
fetchUsers()
} else {
alert.error(data.error || 'Nepodařilo se uložit uživatele')
}
} catch {
alert.error('Chyba připojení')
}
}
const openDeleteModal = (user: User) => {
setDeleteModal({ isOpen: true, user })
}
const closeDeleteModal = () => {
setDeleteModal({ isOpen: false, user: null })
}
const handleDelete = async () => {
if (!deleteModal.user) return
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/users/${deleteModal.user.id}`, {
method: 'DELETE',
})
const data = await response.json()
if (data.success) {
closeDeleteModal()
fetchUsers()
alert.success('Uživatel byl smazán')
} else {
alert.error(data.error || 'Nepodařilo se smazat uživatele')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const toggleActive = async (user: User) => {
try {
const response = await apiFetch(`${API_BASE}/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_active: !user.is_active
})
})
const data = await response.json()
if (data.success) {
fetchUsers()
alert.success(user.is_active ? 'Uživatel byl deaktivován' : 'Uživatel byl aktivován')
} else {
alert.error(data.error || 'Nepodařilo se změnit stav uživatele')
}
} catch {
alert.error('Chyba připojení')
}
}
const getRoleBadgeClass = (roleName: string): string => {
switch (roleName) {
case 'admin': return 'admin-badge admin-badge-admin'
default: return 'admin-badge admin-badge-viewer'
}
}
if (loading) {
return (
<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 className="admin-skeleton-line h-10" style={{ width: '160px', borderRadius: '8px' }} />
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">Správa uživatelských úč a oprávnění</p>
</div>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat uživatele
</button>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username).charAt(0).toUpperCase()}
</div>
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
</div>
<div className="admin-table-username">@{user.username}</div>
</div>
</div>
</td>
<td>{user.email}</td>
<td>
<span className={getRoleBadgeClass(user.roles?.name ?? '')}>
{user.roles?.display_name || user.roles?.name || '—'}
</span>
</td>
<td>
<button
onClick={() => user.id !== currentUser?.id && toggleActive(user)}
disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
style={{ cursor: user.id === currentUser?.id ? 'not-allowed' : 'pointer' }}
>
{user.is_active ? 'Aktivní' : 'Neaktivní'}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(user)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{user.id !== currentUser?.id && (
<button
onClick={() => openDeleteModal(user)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
<AnimatePresence>
{showModal && (
<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={closeModal} />
<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 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingUser ? 'Upravit uživatele' : 'Přidat nového uživatele'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
className="admin-form-input"
/>
</FormField>
<FormField label={`Heslo ${editingUser ? '(ponechte prázdné pro zachování stávajícího)' : ''}`}>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) => setFormData({ ...formData, role_id: e.target.value })}
required
className="admin-form-select"
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
<span>Účet je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary">
Zrušit
</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">
{editingUser ? 'Uložit změny' : 'Vytvořit uživatele'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={handleDelete}
title="Smazat uživatele"
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -0,0 +1,470 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
interface Vehicle {
id: number
spz: string
name: string
brand?: string
model?: string
initial_km: number
current_km: number
trip_count: number
is_active: boolean | number
}
interface VehicleForm {
spz: string
name: string
brand: string
model: string
initial_km: number
is_active: boolean
}
export default function Vehicles() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [showModal, setShowModal] = useState(false)
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
const [form, setForm] = useState<VehicleForm>({
spz: '',
name: '',
brand: '',
model: '',
initial_km: 0,
is_active: true
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; vehicle: Vehicle | null }>({ show: false, vehicle: null })
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/vehicles`)
const result = await response.json()
if (result.success) {
setVehicles(Array.isArray(result.data) ? result.data : [])
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showModal)
if (!hasPermission('trips.vehicles')) return <Forbidden />
const openCreateModal = () => {
setEditingVehicle(null)
setForm({
spz: '',
name: '',
brand: '',
model: '',
initial_km: 0,
is_active: true
})
setErrors({})
setShowModal(true)
}
const openEditModal = (vehicle: Vehicle) => {
setEditingVehicle(vehicle)
setForm({
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
initial_km: vehicle.initial_km,
is_active: Boolean(vehicle.is_active)
})
setErrors({})
setShowModal(true)
}
const handleSubmit = async () => {
const newErrors: Record<string, string> = {}
if (!form.spz) newErrors.spz = 'Zadejte SPZ'
if (!form.name) newErrors.name = 'Zadejte název'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
try {
const url = editingVehicle
? `${API_BASE}/vehicles/${editingVehicle.id}`
: `${API_BASE}/vehicles`
const method = editingVehicle ? 'PUT' : 'POST'
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
const result = await response.json()
if (result.success) {
setShowModal(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 handleDelete = async () => {
if (!deleteConfirm.vehicle) return
try {
const response = await apiFetch(`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`, {
method: 'DELETE',
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const toggleActive = async (vehicle: Vehicle) => {
try {
const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
initial_km: vehicle.initial_km,
is_active: !vehicle.is_active
})
})
const result = await response.json()
if (result.success) {
fetchData(false)
alert.success(vehicle.is_active ? 'Vozidlo bylo deaktivováno' : 'Vozidlo bylo aktivováno')
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
if (loading) {
return (
<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' }} />
</div>
<div className="admin-skeleton-line h-10" style={{ width: '150px', borderRadius: '8px' }} />
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
)
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa vozidel</h1>
</div>
<div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat vozidlo
</button>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
<p>Zatím nejsou žádná vozidla.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
Přidat první vozidlo
</button>
</div>
)}
{vehicles.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>SPZ</th>
<th>Název</th>
<th>Značka / Model</th>
<th>Počáteční km</th>
<th>Aktuální km</th>
<th>Počet jízd</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr key={vehicle.id} className={!vehicle.is_active ? 'admin-table-row-inactive' : ''}>
<td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ''} ${vehicle.model || ''}`.trim()
: '—'}
</td>
<td className="admin-mono">{formatKm(vehicle.initial_km)} km</td>
<td className="admin-mono fw-500">{formatKm(vehicle.current_km)} km</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
>
{vehicle.is_active ? 'Aktivní' : 'Neaktivní'}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(vehicle)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, vehicle })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(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 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingVehicle ? 'Upravit vozidlo' : 'Přidat vozidlo'}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="SPZ" error={errors.spz} required>
<input
type="text"
value={form.spz}
onChange={(e) => {
setForm({ ...form, spz: e.target.value.toUpperCase() })
setErrors(prev => ({ ...prev, spz: '' }))
}}
className="admin-form-input"
placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value })
setErrors(prev => ({ ...prev, name: '' }))
}}
className="admin-form-input"
placeholder="Služební #1"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Značka">
<input
type="text"
value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })}
className="admin-form-input"
placeholder="Škoda"
/>
</FormField>
<FormField label="Model">
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
className="admin-form-input"
placeholder="Octavia Combi"
/>
</FormField>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={form.initial_km}
onChange={(e) => setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Stav tachometru při přidání vozidla
</small>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
<span>Vozidlo je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={deleteConfirm.vehicle ? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?` : ''}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
)
}

64
src/admin/settings.css Normal file
View File

@@ -0,0 +1,64 @@
/* ============================================================================
Settings / Permissions
============================================================================ */
.admin-sidebar-settings {
border-top: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
margin-top: auto;
}
.admin-permission-group {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
}
.admin-permission-group-title {
padding: 0.625rem 0.75rem;
background: var(--bg-secondary);
}
.admin-permission-group-title .admin-form-checkbox span {
font-weight: 600;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.025em;
color: var(--text-secondary);
}
.admin-permission-list {
display: flex;
flex-direction: column;
}
.admin-permission-item {
padding: 0.5rem 0.75rem;
transition: background-color 0.15s;
}
.admin-permission-item:hover {
background: var(--bg-secondary);
}
.admin-permission-item + .admin-permission-item {
border-top: 1px solid var(--border-color);
}
.admin-permission-desc {
font-size: 0.75rem;
color: var(--text-tertiary);
line-height: 1.4;
padding-left: 2.75rem;
}
@media (max-width: 768px) {
.admin-permission-item {
padding: 0.75rem;
}
.admin-permission-item .admin-form-checkbox {
min-height: 44px;
}
}

102
src/admin/utils/api.ts Normal file
View File

@@ -0,0 +1,102 @@
let showSessionExpiredAlert = false
let showLogoutAlert = false
let getTokenFn: (() => string | null) | null = null
let refreshFn: (() => Promise<boolean>) | null = null
let refreshPromise: Promise<boolean> | null = null
export const shouldShowSessionExpiredAlert = (): boolean => {
if (showSessionExpiredAlert) {
showSessionExpiredAlert = false
return true
}
return false
}
export const setSessionExpired = (): void => {
showSessionExpiredAlert = true
}
export const shouldShowLogoutAlert = (): boolean => {
if (showLogoutAlert) {
showLogoutAlert = false
return true
}
return false
}
export const setLogoutAlert = (): void => {
showLogoutAlert = true
}
export const setTokenGetter = (fn: () => string | null): void => {
getTokenFn = fn
}
export const setRefreshFn = (fn: () => Promise<boolean>): void => {
refreshFn = fn
}
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
let token: string | null = null
try {
token = getTokenFn ? getTokenFn() : null
} catch {
// token retrieval failed
}
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
}
if (!headers['Content-Type'] && options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let response = await fetch(url, {
...options,
headers,
credentials: 'include',
})
if (response.status === 401 && refreshFn) {
try {
if (!refreshPromise) {
refreshPromise = refreshFn().finally(() => {
refreshPromise = null
})
}
const refreshed = await refreshPromise
if (refreshed) {
token = getTokenFn ? getTokenFn() : null
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
response = await fetch(url, {
...options,
headers,
credentials: 'include',
})
} else {
setSessionExpired()
}
} catch {
setSessionExpired()
}
}
return response
}
export const getAccessToken = (): string | null => {
try {
return getTokenFn ? getTokenFn() : null
} catch {
return null
}
}
export default apiFetch

View File

@@ -0,0 +1,151 @@
interface AttendanceRecord {
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
leave_type?: string
leave_hours?: number
shift_date?: string
notes?: string
project_logs?: Array<{
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
}>
}
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('cs-CZ')
}
export const formatDatetime = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}. ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`
}
export const formatTime = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
return new Date(datetime).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
export const calculateWorkMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime()
const breakEnd = new Date(record.break_end).getTime()
minutes -= (breakEnd - breakStart) / 60000
}
return Math.max(0, Math.floor(minutes))
}
export const formatMinutes = (minutes: number, withUnit = false): string => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${h}:${String(m).padStart(2, '0')}${withUnit ? ' h' : ''}`
}
export const getLeaveTypeName = (type: string): string => {
const types: Record<string, string> = {
work: 'Práce',
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno',
}
return types[type] || 'Práce'
}
export const getLeaveTypeBadgeClass = (type: string): string => {
const classes: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
holiday: 'badge-holiday',
unpaid: 'badge-unpaid',
}
return classes[type] || ''
}
export const getDatePart = (datetime: string | null | undefined): string => {
if (!datetime) return ''
if (datetime.includes('T')) {
return datetime.split('T')[0]
}
return datetime.split(' ')[0]
}
export const getTimePart = (datetime: string | null | undefined): string => {
if (!datetime) return ''
const d = new Date(datetime)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export const calcProjectMinutesTotal = (logs: Array<{ project_id?: number; hours?: string | number; minutes?: string | number }>): number => {
return logs.filter(l => l.project_id).reduce((sum, l) => {
return sum + (parseInt(String(l.hours)) || 0) * 60 + (parseInt(String(l.minutes)) || 0)
}, 0)
}
interface ShiftForm {
arrival_time?: string
departure_time?: string
arrival_date?: string
departure_date?: string
break_start_time?: string
break_end_time?: string
break_start_date?: string
break_end_date?: string
}
export const calcFormWorkMinutes = (form: ShiftForm): number => {
if (!form.arrival_time || !form.departure_time) return 0
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`
const departureStr = `${form.departure_date}T${form.departure_time}`
let mins = (new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000
if (form.break_start_time && form.break_end_time) {
const bsStr = `${form.break_start_date}T${form.break_start_time}`
const beStr = `${form.break_end_date}T${form.break_end_time}`
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000
}
return Math.max(0, Math.floor(mins))
}
export const formatTimeOrDatetimePrint = (datetime: string | null | undefined, shiftDate: string): string => {
if (!datetime) return '—'
const timeDate = new Date(datetime).toISOString().split('T')[0]
if (timeDate !== shiftDate) {
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}. ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`
}
return new Date(datetime).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
const leaveType = record.leave_type || 'work'
if (leaveType !== 'work') {
return (Number(record.leave_hours) || 8) * 60
}
if (!record.arrival_time || !record.departure_time) return 0
const arrival = new Date(record.arrival_time).getTime()
const departure = new Date(record.departure_time).getTime()
let minutes = (departure - arrival) / 60000
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime()
const breakEnd = new Date(record.break_end).getTime()
minutes -= (breakEnd - breakStart) / 60000
}
return Math.max(0, Math.floor(minutes))
}

View File

@@ -0,0 +1,79 @@
export const LEAVE_TYPE_LABELS: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno',
}
export const STATUS_DOT_CLASS: Record<string, string> = {
in: 'dash-status-in',
away: 'dash-status-away',
out: 'dash-status-out',
leave: 'dash-status-leave',
}
export const STATUS_LABELS: Record<string, string> = {
in: 'Přítomen',
away: 'Přestávka',
out: 'Nepřihlášen',
leave: 'Nepřítomen',
}
export const ENTITY_TYPE_LABELS: Record<string, string> = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
export const ACTION_LABELS: Record<string, string> = {
create: 'Vytvořil',
update: 'Upravil',
delete: 'Smazal',
login: 'Přihlášení',
}
export function getCzechDate(): string {
const now = new Date()
const days = ['Neděle', 'Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota']
const months = ['ledna', 'února', 'března', 'dubna', 'května', 'června', 'července', 'srpna', 'září', 'října', 'listopadu', 'prosince']
const day = days[now.getDay()]
const oneJan = new Date(now.getFullYear(), 0, 1)
const week = Math.ceil(((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7)
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`
}
export function getActivityIconClass(action: string): string {
const map: Record<string, string> = { create: 'success', update: 'info', delete: 'danger', login: 'accent' }
return map[action] || 'muted'
}
export function formatActivityTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return 'Právě teď'
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
return date.toLocaleDateString('cs-CZ', { day: '2-digit', month: '2-digit' })
}
export function formatSessionDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('cs-CZ', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}

View File

@@ -0,0 +1,26 @@
export function formatCurrency(amount: number | string, currency: string): string {
const num = Number(amount) || 0
switch (currency) {
case 'EUR': return `${num.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
case 'USD': return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
case 'CZK': return `${num.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
case 'GBP': return `£${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
default: return `${num.toFixed(2)} ${currency}`
}
}
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('cs-CZ')
}
export function formatKm(km: number | string): string {
return new Intl.NumberFormat('cs-CZ').format(Number(km) || 0)
}
export function czechPlural(n: number, one: string, few: string, many: string): string {
if (n === 1) return one
if (n >= 2 && n <= 4) return few
return many
}

7
src/config/database.ts Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
log: process.env.APP_ENV === 'local' ? ['warn', 'error'] : ['error'],
});
export default prisma;

51
src/config/env.ts Normal file
View File

@@ -0,0 +1,51 @@
import dotenv from 'dotenv';
dotenv.config();
function required(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing required env variable: ${key}`);
return val;
}
export const config = {
port: parseInt(process.env.PORT || '3001', 10),
host: process.env.HOST || '127.0.0.1',
appEnv: process.env.APP_ENV || 'local',
isProduction: process.env.APP_ENV === 'production',
db: {
url: required('DATABASE_URL'),
},
jwt: {
secret: required('JWT_SECRET'),
accessTokenExpiry: parseInt(process.env.ACCESS_TOKEN_EXPIRY || '900', 10),
refreshTokenSessionExpiry: parseInt(process.env.REFRESH_TOKEN_SESSION_EXPIRY || '3600', 10),
refreshTokenRememberExpiry: parseInt(process.env.REFRESH_TOKEN_REMEMBER_EXPIRY || '2592000', 10),
},
totp: {
encryptionKey: required('TOTP_ENCRYPTION_KEY'),
},
nas: {
path: process.env.NAS_PATH || 'Z:/02_PROJEKTY',
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || '52428800', 10),
},
email: {
contactTo: process.env.CONTACT_EMAIL_TO || '',
contactFrom: process.env.CONTACT_EMAIL_FROM || '',
smtpFrom: process.env.SMTP_FROM || '',
},
cors: {
origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean),
},
security: {
maxLoginAttempts: 5,
lockoutMinutes: 15,
bcryptCost: 12,
},
} as const;

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
interface ThemeContextValue {
theme: string
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('boha-theme') || 'dark'
}
return 'dark'
})
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('boha-theme', theme)
const themeColor = theme === 'dark' ? '#12121a' : '#ffffff'
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor)
}, [theme])
const toggleTheme = () => {
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within a ThemeProvider')
return context
}

15
src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { ThemeProvider } from './context/ThemeContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
)

51
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,51 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { verifyAccessToken } from '../services/auth';
import { error } from '../utils/response';
import { AuthData } from '../types';
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return error(reply, 'Vyžadována autentizace', 401);
}
const token = authHeader.slice(7);
const authData = await verifyAccessToken(token);
if (!authData) {
return error(reply, 'Neplatný nebo expirovaný token', 401);
}
request.authData = authData;
}
export async function optionalAuth(
request: FastifyRequest,
_reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) return;
const token = authHeader.slice(7);
request.authData = (await verifyAccessToken(token)) ?? undefined;
}
export function requirePermission(...permissionNames: string[]) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
await requireAuth(request, reply);
if (reply.sent) return;
const authData = request.authData!;
// Admin has all permissions
if (authData.roleName === 'admin') return;
const hasAll = permissionNames.every((p) => authData.permissions.includes(p));
if (!hasAll) {
return error(reply, 'Nedostatečná oprávnění', 403);
}
};
}

View File

@@ -0,0 +1,15 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../config/env';
export async function securityHeaders(
_request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('X-Frame-Options', 'DENY');
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
if (config.isProduction) {
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { success, paginated, error } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
export default async function auditLogRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const where: Record<string, unknown> = {};
if (query.action) where.action = String(query.action);
if (query.entity_type) where.entity_type = String(query.entity_type);
if (query.user_id) where.user_id = Number(query.user_id);
if (search) where.description = { contains: search };
if (query.date_from || query.date_to) {
const dateFilter: Record<string, Date> = {};
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
if (query.date_to) dateFilter.lte = new Date(String(query.date_to) + 'T23:59:59');
where.created_at = dateFilter;
}
const [logs, total] = await Promise.all([
prisma.audit_logs.findMany({ where, skip, take: limit, orderBy: { created_at: order } }),
prisma.audit_logs.count({ where }),
]);
return paginated(reply, logs, buildPaginationMeta(total, page, limit));
});
// POST /api/admin/audit-log/cleanup — delete old audit logs
fastify.post('/cleanup', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const days = body.days !== undefined ? Number(body.days) : null;
// days === 0 means "delete all" (from frontend "Vše" option)
if (days === 0 || body.action === 'all') {
const result = await prisma.audit_logs.deleteMany({});
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
}
if (days && days > 0) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const result = await prisma.audit_logs.deleteMany({ where: { created_at: { lt: cutoff } } });
return success(reply, null, 200, `Smazáno ${result.count} záznamů starších než ${days} dní`);
}
return error(reply, 'Zadejte počet dní', 400);
});
}

188
src/routes/admin/auth.ts Normal file
View File

@@ -0,0 +1,188 @@
import { FastifyInstance } from 'fastify';
import { login, refreshAccessToken, logout, verifyAccessToken } from '../../services/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import { config } from '../../config/env';
import { LoginRequest, TotpVerifyRequest } from '../../types';
import prisma from '../../config/database';
import crypto from 'crypto';
import { OTPAuth } from '../../utils/totp';
function setRefreshCookie(reply: import('fastify').FastifyReply, token: string, rememberMe: boolean) {
const maxAge = rememberMe
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
reply.setCookie('refresh_token', token, {
httpOnly: true,
secure: config.isProduction,
sameSite: 'strict',
path: '/api/admin',
maxAge,
});
}
export default async function authRoutes(fastify: FastifyInstance): Promise<void> {
// POST /api/admin/login
fastify.post<{ Body: LoginRequest }>('/login', async (request, reply) => {
const { username, password, remember_me } = request.body;
if (!username || !password) {
return error(reply, 'Uživatelské jméno a heslo jsou povinné', 400);
}
const result = await login(username, password, !!remember_me, request);
if (result.type === 'error') {
await logAudit({
request,
action: 'login_failed',
entityType: 'user',
description: `Neúspěšný pokus o přihlášení: ${username}`,
});
return error(reply, result.message, result.status);
}
if (result.type === 'totp_required') {
return success(reply, { totp_required: true, login_token: result.loginToken });
}
await logAudit({
request,
authData: result.user,
action: 'login',
entityType: 'user',
entityId: result.user.userId,
description: `Přihlášení uživatele ${result.user.username}`,
});
setRefreshCookie(reply, result.refreshToken, !!remember_me);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
// POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>('/login/totp', async (request, reply) => {
const { login_token, totp_code } = request.body;
if (!login_token || !totp_code) {
return error(reply, 'Login token a TOTP kód jsou povinné', 400);
}
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex');
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, 'Neplatný nebo expirovaný login token', 401);
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_secret) {
return error(reply, 'Uživatel nenalezen', 401);
}
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
if (!isValid) {
return error(reply, 'Neplatný TOTP kód', 401);
}
// Delete used login token
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
// Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({
where: { id: user.id },
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() },
});
// Create tokens directly — password was already verified before TOTP was requested
const authData = await (await import('../../services/auth')).loadAuthData(user.id);
if (!authData) {
return error(reply, 'Chyba načítání uživatele', 500);
}
// Create tokens manually since password was already verified
const jwt = await import('jsonwebtoken');
const accessToken = jwt.default.sign(
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
await prisma.refresh_tokens.create({
data: {
user_id: user.id,
token_hash: refreshTokenHash,
expires_at: new Date(Date.now() + config.jwt.refreshTokenSessionExpiry * 1000),
remember_me: false,
ip_address: request.ip,
user_agent: request.headers['user-agent'] ?? null,
},
});
setRefreshCookie(reply, refreshTokenRaw, false);
return success(reply, { access_token: accessToken, user: authData });
});
// POST /api/admin/refresh
fastify.post('/refresh', async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (!refreshTokenRaw) {
return error(reply, 'Refresh token chybí', 401);
}
const result = await refreshAccessToken(refreshTokenRaw, request);
if (result.type === 'error') {
reply.clearCookie('refresh_token', { path: '/api/admin' });
return error(reply, result.message, result.status);
}
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
// POST /api/admin/logout
fastify.post('/logout', async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) {
await logout(refreshTokenRaw);
}
reply.clearCookie('refresh_token', { path: '/api/admin' });
return success(reply, null, 200, 'Odhlášení úspěšné');
});
// GET /api/admin/session
fastify.get('/session', async (request, reply) => {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return error(reply, 'Vyžadována autentizace', 401);
}
const token = authHeader.slice(7);
const authData = await verifyAccessToken(token);
if (!authData) {
return error(reply, 'Neplatný token', 401);
}
return success(reply, { user: authData });
});
}

View File

@@ -0,0 +1,68 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
export default async function bankAccountsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (_request, reply) => {
const accounts = await prisma.bank_accounts.findMany({ orderBy: { position: 'asc' } });
return success(reply, accounts);
});
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const account = await prisma.bank_accounts.create({
data: {
account_name: body.account_name ? String(body.account_name) : null,
bank_name: body.bank_name ? String(body.bank_name) : null,
account_number: body.account_number ? String(body.account_number) : null,
iban: body.iban ? String(body.iban) : null,
bic: body.bic ? String(body.bic) : null,
currency: body.currency ? String(body.currency) : 'CZK',
is_default: body.is_default === true || body.is_default === 1 || body.is_default === '1',
position: body.position ? Number(body.position) : 0,
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'bank_account', entityId: account.id, description: `Vytvořen bankovní účet ${account.account_name}` });
return success(reply, { id: account.id }, 201, 'Bankovní účet vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
if (!existing) return error(reply, 'Účet nenalezen', 404);
await prisma.bank_accounts.update({
where: { id },
data: {
account_name: body.account_name !== undefined ? (body.account_name ? String(body.account_name) : null) : undefined,
bank_name: body.bank_name !== undefined ? (body.bank_name ? String(body.bank_name) : null) : undefined,
account_number: body.account_number !== undefined ? (body.account_number ? String(body.account_number) : null) : undefined,
iban: body.iban !== undefined ? (body.iban ? String(body.iban) : null) : undefined,
bic: body.bic !== undefined ? (body.bic ? String(body.bic) : null) : undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
is_default: body.is_default !== undefined ? (body.is_default === true || body.is_default === 1 || body.is_default === '1') : undefined,
position: body.position !== undefined ? Number(body.position) : undefined,
modified_at: new Date(),
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'bank_account', entityId: id, description: `Upraven bankovní účet` });
return success(reply, { id }, 200, 'Bankovní účet uložen');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
if (!existing) return error(reply, 'Účet nenalezen', 404);
await prisma.bank_accounts.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'bank_account', entityId: id, description: `Smazán bankovní účet` });
return success(reply, null, 200, 'Účet smazán');
});
}

View File

@@ -0,0 +1,179 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import multipart from '@fastify/multipart';
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; supplier_field_order: string[] } {
if (!raw) return { custom_fields: [], supplier_field_order: [] };
try {
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
return { custom_fields: parsed.fields || [], supplier_field_order: parsed.field_order || [] };
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, supplier_field_order: [] };
}
return { custom_fields: [], supplier_field_order: [] };
} catch {
return { custom_fields: [], supplier_field_order: [] };
}
}
export default async function companySettingsRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
// GET /api/admin/company-settings/logo
fastify.get('/logo', { preHandler: requireAuth }, async (_request, reply) => {
const settings = await prisma.company_settings.findFirst({ select: { logo_data: true } });
if (!settings?.logo_data) return error(reply, 'Logo nenalezeno', 404);
// Detect image type from magic bytes
const buf = settings.logo_data;
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
return reply.type(mime).send(buf);
});
// POST /api/admin/company-settings/logo
fastify.post('/logo', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const file = await request.file();
if (!file) return error(reply, 'Nebyl nahrán žádný soubor', 400);
const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return error(reply, 'Nepodporovaný formát. Povoleno: PNG, JPG, GIF, WebP', 400);
}
const buffer = await file.toBuffer();
const existing = await prisma.company_settings.findFirst();
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
await prisma.company_settings.update({
where: { id: existing.id },
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Nahráno logo' });
return success(reply, null, 200, 'Logo nahráno');
});
fastify.get('/', { preHandler: requireAuth }, async (_request, reply) => {
let settings = await prisma.company_settings.findFirst({
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
},
});
if (!settings) {
settings = await prisma.company_settings.create({
data: {
company_name: '',
quotation_prefix: 'N',
default_currency: 'EUR',
default_vat_rate: 21.0,
},
select: {
id: true,
company_name: true,
street: true,
city: true,
postal_code: true,
country: true,
company_id: true,
vat_id: true,
custom_fields: true,
quotation_prefix: true,
default_currency: true,
default_vat_rate: true,
uuid: true,
modified_at: true,
is_deleted: true,
sync_version: true,
order_type_code: true,
invoice_type_code: true,
require_2fa: true,
},
});
}
// Check if logo exists
const logoCheck = await prisma.company_settings.findFirst({
where: { id: settings.id },
select: { logo_data: true },
});
const has_logo = !!(logoCheck?.logo_data);
const { custom_fields, supplier_field_order } = decodeCustomFields(settings.custom_fields as string | null);
return success(reply, { ...settings, custom_fields, supplier_field_order, has_logo });
});
fastify.put('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const existing = await prisma.company_settings.findFirst();
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = ['company_name', 'street', 'city', 'postal_code', 'country', 'company_id', 'vat_id', 'quotation_prefix', 'default_currency', 'order_type_code', 'invoice_type_code'];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.default_vat_rate !== undefined) data.default_vat_rate = Number(body.default_vat_rate);
if (body.require_2fa !== undefined) data.require_2fa = body.require_2fa === true || body.require_2fa === 1 || body.require_2fa === '1';
if (body.custom_fields !== undefined || body.supplier_field_order !== undefined) {
let existingFields: unknown[] = [];
let existingOrder: unknown[] = [];
if (existing.custom_fields) {
try {
const parsed = JSON.parse(existing.custom_fields);
existingFields = parsed?.fields || [];
existingOrder = parsed?.field_order || [];
} catch { /* invalid JSON, use defaults */ }
}
data.custom_fields = encodeCustomFields(
body.custom_fields !== undefined ? body.custom_fields : existingFields,
body.supplier_field_order !== undefined ? body.supplier_field_order : existingOrder,
);
}
data.sync_version = (existing.sync_version ?? 0) + 1;
await prisma.company_settings.update({ where: { id: existing.id }, data });
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Upraveno firemní nastavení' });
return success(reply, { id: existing.id }, 200, 'Nastavení bylo uloženo');
});
}

View File

@@ -0,0 +1,141 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth, requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
const ALLOWED_SORT_FIELDS = ['id', 'name', 'company_id', 'city', 'country'];
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; customer_field_order: string[] } {
if (!raw) return { custom_fields: [], customer_field_order: [] };
try {
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
return { custom_fields: parsed.fields || [], customer_field_order: parsed.field_order || [] };
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, customer_field_order: [] };
}
return { custom_fields: [], customer_field_order: [] };
} catch {
return { custom_fields: [], customer_field_order: [] };
}
}
export default async function customersRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(request.query as Record<string, unknown>);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'name';
const where = search
? { OR: [{ name: { contains: search } }, { company_id: { contains: search } }] }
: {};
const [customers, total] = await Promise.all([
prisma.customers.findMany({
where, skip, take: limit, orderBy: { [sortField]: order },
include: { _count: { select: { quotations: true } } },
}),
prisma.customers.count({ where }),
]);
const enriched = customers.map(c => {
const { custom_fields, customer_field_order } = decodeCustomFields(c.custom_fields);
return { ...c, custom_fields, customer_field_order, quotation_count: c._count?.quotations ?? 0 };
});
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
});
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const customer = await prisma.customers.findUnique({ where: { id } });
if (!customer) return error(reply, 'Zákazník nenalezen', 404);
const { custom_fields, customer_field_order } = decodeCustomFields(customer.custom_fields);
return success(reply, { ...customer, custom_fields, customer_field_order });
});
fastify.post('/', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const name = body.name ? String(body.name).trim() : '';
if (!name) return error(reply, 'Název zákazníka je povinný', 400);
const customer = await prisma.customers.create({
data: {
name,
street: body.street ? String(body.street) : null,
city: body.city ? String(body.city) : null,
postal_code: body.postal_code ? String(body.postal_code) : null,
country: body.country ? String(body.country) : null,
company_id: body.company_id ? String(body.company_id) : null,
vat_id: body.vat_id ? String(body.vat_id) : null,
custom_fields: encodeCustomFields(body.custom_fields, body.customer_field_order),
},
});
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'customer', entityId: customer.id, description: `Vytvořen zákazník ${customer.name}` });
return success(reply, { id: customer.id }, 201, 'Zákazník byl vytvořen');
});
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const existing = await prisma.customers.findUnique({ where: { id } });
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
await prisma.customers.update({
where: { id },
data: {
name: body.name !== undefined ? String(body.name) : undefined,
street: body.street !== undefined ? (body.street ? String(body.street) : null) : undefined,
city: body.city !== undefined ? (body.city ? String(body.city) : null) : undefined,
postal_code: body.postal_code !== undefined ? (body.postal_code ? String(body.postal_code) : null) : undefined,
country: body.country !== undefined ? (body.country ? String(body.country) : null) : undefined,
company_id: body.company_id !== undefined ? (body.company_id ? String(body.company_id) : null) : undefined,
vat_id: body.vat_id !== undefined ? (body.vat_id ? String(body.vat_id) : null) : undefined,
custom_fields: body.custom_fields !== undefined ? encodeCustomFields(body.custom_fields, body.customer_field_order) : undefined,
},
});
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'customer', entityId: id, description: `Upraven zákazník ${existing.name}` });
return success(reply, { id }, 200, 'Zákazník byl uložen');
});
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.customers.findUnique({ where: { id } });
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
// Check for FK references before deleting
const [quotCount, orderCount, invoiceCount, projectCount] = await Promise.all([
prisma.quotations.count({ where: { customer_id: id } }),
prisma.orders.count({ where: { customer_id: id } }),
prisma.invoices.count({ where: { customer_id: id } }),
prisma.projects.count({ where: { customer_id: id } }),
]);
if (quotCount + orderCount + invoiceCount + projectCount > 0) {
return error(reply, 'Zákazníka nelze smazat — existují propojené nabídky, objednávky, faktury nebo projekty', 400);
}
await prisma.customers.delete({ where: { id } });
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'customer', entityId: id, description: `Smazán zákazník ${existing.name}` });
return success(reply, null, 200, 'Zákazník smazán');
});
}

View File

@@ -0,0 +1,252 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requireAuth } from '../../middleware/auth';
import { success } from '../../utils/response';
export default async function dashboardRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const userId = request.authData!.userId;
const [
usersCount,
activeProjectsCount,
pendingOrdersCount,
unpaidInvoicesCount,
pendingLeaveRequests,
// Attendance
todayAttendance,
onLeaveToday,
// Offers / quotations
openQuotations,
convertedQuotations,
expiredQuotations,
quotationsThisMonth,
// Invoices
issuedInvoicesThisMonth,
// My shift
myShiftToday,
// Recent activity
recentActivity,
// Active projects list
activeProjectsList,
] = await Promise.all([
// Existing counts
prisma.users.count({ where: { is_active: true } }),
prisma.projects.count({ where: { status: 'aktivni' } }),
prisma.orders.count({ where: { status: 'prijata' } }),
prisma.invoices.count({ where: { status: 'issued' } }),
prisma.leave_requests.count({ where: { status: 'pending' } }),
// Attendance: today's WORK records with user info
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
OR: [{ leave_type: null }, { leave_type: 'work' }],
},
include: { users: { select: { id: true, first_name: true, last_name: true } } },
orderBy: { arrival_time: 'asc' },
}),
// Users on leave today (attendance records with leave type)
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
leave_type: { in: ['vacation', 'sick', 'holiday', 'unpaid'] },
},
include: { users: { select: { id: true, first_name: true, last_name: true } } },
}),
// Quotation stats
prisma.quotations.count({ where: { status: 'active' } }),
prisma.quotations.count({ where: { status: 'converted' } }),
prisma.quotations.count({ where: { status: 'expired' } }),
prisma.quotations.count({
where: { created_at: { gte: monthStart, lt: monthEnd } },
}),
// Invoice stats — this month's invoices
prisma.invoices.findMany({
where: {
issue_date: { gte: monthStart, lt: monthEnd },
},
include: { invoice_items: true },
}),
// My active (ongoing) shift — any unclosed shift, not just today
prisma.attendance.findFirst({
where: {
user_id: userId,
arrival_time: { not: null },
departure_time: null,
},
orderBy: { created_at: 'desc' },
}),
// Recent audit log activity (last 10)
prisma.audit_logs.findMany({
orderBy: { created_at: 'desc' },
take: 10,
select: {
id: true,
action: true,
entity_type: true,
description: true,
username: true,
created_at: true,
},
}),
// Active projects with customer
prisma.projects.findMany({
where: { status: 'aktivni' },
include: { customers: { select: { name: true } } },
orderBy: { created_at: 'desc' },
}),
]);
// Build attendance users list — deduplicate by user_id, keep latest record per user
// Match PHP status logic: in = working, away = on break, out = departed
const userAttendanceMap = new Map<number, typeof todayAttendance[0]>();
for (const a of todayAttendance) {
const existing = userAttendanceMap.get(a.users.id);
if (!existing || (a.arrival_time && existing.arrival_time && a.arrival_time > existing.arrival_time)) {
userAttendanceMap.set(a.users.id, a);
}
}
let presentCount = 0;
const attendanceUsers: Array<{
user_id: number; name: string; initials: string;
status: string; arrived_at: string | null; leave_type?: string;
}> = [];
// Work records — deduplicate by user, determine status
for (const a of userAttendanceMap.values()) {
const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? '';
const lastInitial = user.last_name?.charAt(0) ?? '';
let status: string = 'out';
if (a.arrival_time) {
if (a.departure_time) {
status = 'out';
} else if (a.break_start && !a.break_end) {
status = 'away';
} else {
status = 'in';
presentCount++;
}
}
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status,
arrived_at: a.arrival_time ? a.arrival_time.toISOString() : null,
});
}
// Leave records — add users on leave with status 'leave' + leave_type (matching PHP)
const leaveUserIds = new Set<number>();
for (const a of onLeaveToday) {
if (leaveUserIds.has(a.users.id)) continue; // deduplicate
leaveUserIds.add(a.users.id);
const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? '';
const lastInitial = user.last_name?.charAt(0) ?? '';
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status: 'leave',
arrived_at: null,
leave_type: (a.leave_type as string) || 'vacation',
});
}
// Compute invoice revenue this month grouped by currency
const revenueByCurrency: Record<string, number> = {};
for (const inv of issuedInvoicesThisMonth) {
const currency = inv.currency ?? 'CZK';
let total = 0;
for (const item of inv.invoice_items) {
const qty = item.quantity ? Number(item.quantity) : 0;
const price = item.unit_price ? Number(item.unit_price) : 0;
total += qty * price;
}
revenueByCurrency[currency] = (revenueByCurrency[currency] ?? 0) + total;
}
const revenueThisMonth = Object.entries(revenueByCurrency).map(([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}));
const revenueCzk = revenueByCurrency['CZK'] != null
? Math.round(revenueByCurrency['CZK'] * 100) / 100
: null;
return success(reply, {
// Existing counts
users_count: usersCount,
active_projects: activeProjectsCount,
pending_orders: pendingOrdersCount,
unpaid_invoices: unpaidInvoicesCount,
pending_leave_requests: pendingLeaveRequests,
// Attendance data
attendance: {
present_today: presentCount,
total_active: usersCount,
on_leave: leaveUserIds.size,
users: attendanceUsers,
},
// Offers/quotations stats
offers: {
open_count: openQuotations,
converted_count: convertedQuotations,
expired_count: expiredQuotations,
created_this_month: quotationsThisMonth,
},
// Invoice revenue
invoices: {
revenue_this_month: revenueThisMonth,
unpaid_count: unpaidInvoicesCount,
revenue_czk: revenueCzk,
},
// Leave pending
leave_pending: { count: pendingLeaveRequests },
// Current user's shift status
my_shift: {
has_ongoing: myShiftToday !== null,
},
// Recent audit log activity
recent_activity: recentActivity.map((log) => ({
id: log.id,
action: log.action,
entity_type: log.entity_type ?? '',
description: log.description ?? '',
username: log.username ?? null,
created_at: log.created_at ? log.created_at.toISOString() : '',
})),
// Active projects list
projects: {
active_projects: activeProjectsList.map((p) => ({
id: p.id,
name: p.name ?? '',
customer_name: p.customers?.name ?? null,
})),
},
});
});
}

View File

@@ -0,0 +1,266 @@
import { FastifyInstance } from 'fastify';
import prisma from '../../config/database';
import { requirePermission } from '../../middleware/auth';
function formatDate(date: Date | string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`;
}
function formatNumber(n: number): string {
return n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
const LABELS: Record<string, Record<string, string>> = {
cs: {
invoice: 'Faktura',
invoice_number: 'Číslo faktury',
issue_date: 'Datum vystavení',
due_date: 'Datum splatnosti',
tax_date: 'Datum zdanitelného plnění',
payment_method: 'Způsob platby',
variable_symbol: 'Variabilní symbol',
constant_symbol: 'Konstantní symbol',
bank: 'Banka',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Číslo účtu',
supplier: 'Dodavatel',
customer: 'Odběratel',
ico: 'IČO',
dic: 'DIČ',
description: 'Popis',
qty: 'Množství',
unit: 'Jednotka',
unit_price: 'Cena/ks',
vat: 'DPH %',
total: 'Celkem',
subtotal: 'Základ',
vat_total: 'DPH',
grand_total: 'Celkem k úhradě',
paid_date: 'Datum úhrady',
issued_by: 'Vystavil',
notes: 'Poznámky',
order_number: 'Objednávka',
currency: 'Měna',
},
en: {
invoice: 'Invoice',
invoice_number: 'Invoice number',
issue_date: 'Issue date',
due_date: 'Due date',
tax_date: 'Tax date',
payment_method: 'Payment method',
variable_symbol: 'Variable symbol',
constant_symbol: 'Constant symbol',
bank: 'Bank',
iban: 'IBAN',
swift: 'SWIFT',
account: 'Account number',
supplier: 'Supplier',
customer: 'Customer',
ico: 'Company ID',
dic: 'VAT ID',
description: 'Description',
qty: 'Qty',
unit: 'Unit',
unit_price: 'Unit price',
vat: 'VAT %',
total: 'Total',
subtotal: 'Subtotal',
vat_total: 'VAT',
grand_total: 'Total due',
paid_date: 'Paid date',
issued_by: 'Issued by',
notes: 'Notes',
order_number: 'Order',
currency: 'Currency',
},
};
export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
const id = parseInt(request.params.id, 10);
const query = request.query as Record<string, string>;
const lang = query.lang === 'en' ? 'en' : 'cs';
const L = LABELS[lang];
const invoice = await prisma.invoices.findUnique({
where: { id },
include: {
customers: true,
invoice_items: { orderBy: { position: 'asc' } },
orders: { select: { order_number: true } },
},
});
if (!invoice) {
return reply.status(404).type('text/html').send('<html><body><h1>Faktura nenalezena</h1></body></html>');
}
const settings = await prisma.company_settings.findFirst();
// Compute totals
const items = invoice.invoice_items.map(item => {
const qty = Number(item.quantity) || 0;
const price = Number(item.unit_price) || 0;
const vatRate = Number(item.vat_rate) || Number(invoice.vat_rate) || 21;
const lineTotal = qty * price;
const lineVat = invoice.apply_vat ? lineTotal * (vatRate / 100) : 0;
return { ...item, qty, price, vatRate, lineTotal, lineVat };
});
const subtotal = items.reduce((s, i) => s + i.lineTotal, 0);
const vatTotal = items.reduce((s, i) => s + i.lineVat, 0);
const grandTotal = subtotal + vatTotal;
// Logo as base64
let logoHtml = '';
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
let mime = 'image/png';
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
const b64 = buf.toString('base64');
logoHtml = `<img src="data:${mime};base64,${b64}" style="max-height:60px;max-width:200px;" />`;
}
const cust = invoice.customers;
const html = `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<title>${L.invoice} ${escapeHtml(invoice.invoice_number)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 12px; color: #333; padding: 20px; }
@page { size: A4; margin: 15mm; }
@media print { body { padding: 0; } .no-print { display: none; } }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; border-bottom: 2px solid #2563eb; padding-bottom: 15px; }
.header-left { flex: 1; }
.header-right { text-align: right; }
.company-name { font-size: 18px; font-weight: 700; color: #1e40af; }
.invoice-title { font-size: 22px; font-weight: 700; color: #1e40af; margin-bottom: 5px; }
.invoice-number { font-size: 14px; color: #666; }
.parties { display: flex; gap: 40px; margin: 20px 0; }
.party { flex: 1; padding: 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
.party-title { font-weight: 700; font-size: 11px; text-transform: uppercase; color: #64748b; margin-bottom: 8px; letter-spacing: 0.5px; }
.party-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.meta-item { display: flex; gap: 8px; }
.meta-label { font-weight: 600; color: #64748b; min-width: 160px; }
.meta-value { color: #1e293b; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
thead th { background: #1e40af; color: white; padding: 8px 10px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; }
thead th:last-child, thead th.num { text-align: right; }
tbody td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; }
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:nth-child(even) { background: #f8fafc; }
.totals { margin-left: auto; width: 280px; margin-top: 10px; }
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e2e8f0; }
.totals-row.grand { font-weight: 700; font-size: 16px; color: #1e40af; border-top: 2px solid #1e40af; border-bottom: none; padding-top: 10px; }
.notes { margin-top: 20px; padding: 12px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 6px; }
.notes-title { font-weight: 700; margin-bottom: 4px; }
.footer { margin-top: 30px; font-size: 10px; color: #94a3b8; text-align: center; border-top: 1px solid #e2e8f0; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
${logoHtml}
<div class="company-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="header-right">
<div class="invoice-title">${L.invoice}</div>
<div class="invoice-number">${escapeHtml(invoice.invoice_number)}</div>
</div>
</div>
<div class="parties">
<div class="party">
<div class="party-title">${L.supplier}</div>
<div class="party-name">${escapeHtml(settings?.company_name)}</div>
<div>${escapeHtml(settings?.street)}</div>
<div>${escapeHtml(settings?.city)} ${escapeHtml(settings?.postal_code)}</div>
${settings?.company_id ? `<div>${L.ico}: ${escapeHtml(settings.company_id)}</div>` : ''}
${settings?.vat_id ? `<div>${L.dic}: ${escapeHtml(settings.vat_id)}</div>` : ''}
</div>
<div class="party">
<div class="party-title">${L.customer}</div>
<div class="party-name">${escapeHtml(cust?.name)}</div>
<div>${escapeHtml(cust?.street)}</div>
<div>${escapeHtml(cust?.city)} ${escapeHtml(cust?.postal_code)}</div>
${cust?.company_id ? `<div>${L.ico}: ${escapeHtml(cust.company_id)}</div>` : ''}
${cust?.vat_id ? `<div>${L.dic}: ${escapeHtml(cust.vat_id)}</div>` : ''}
</div>
</div>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">${L.invoice_number}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
<div class="meta-item"><span class="meta-label">${L.issue_date}:</span><span class="meta-value">${formatDate(invoice.issue_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.due_date}:</span><span class="meta-value">${formatDate(invoice.due_date)}</span></div>
<div class="meta-item"><span class="meta-label">${L.tax_date}:</span><span class="meta-value">${formatDate(invoice.tax_date)}</span></div>
${invoice.payment_method ? `<div class="meta-item"><span class="meta-label">${L.payment_method}:</span><span class="meta-value">${escapeHtml(invoice.payment_method)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.variable_symbol}:</span><span class="meta-value">${escapeHtml(invoice.invoice_number)}</span></div>
${invoice.constant_symbol ? `<div class="meta-item"><span class="meta-label">${L.constant_symbol}:</span><span class="meta-value">${escapeHtml(invoice.constant_symbol)}</span></div>` : ''}
${invoice.bank_name ? `<div class="meta-item"><span class="meta-label">${L.bank}:</span><span class="meta-value">${escapeHtml(invoice.bank_name)}</span></div>` : ''}
${invoice.bank_iban ? `<div class="meta-item"><span class="meta-label">${L.iban}:</span><span class="meta-value">${escapeHtml(invoice.bank_iban)}</span></div>` : ''}
${invoice.bank_swift ? `<div class="meta-item"><span class="meta-label">${L.swift}:</span><span class="meta-value">${escapeHtml(invoice.bank_swift)}</span></div>` : ''}
${invoice.bank_account ? `<div class="meta-item"><span class="meta-label">${L.account}:</span><span class="meta-value">${escapeHtml(invoice.bank_account)}</span></div>` : ''}
<div class="meta-item"><span class="meta-label">${L.currency}:</span><span class="meta-value">${escapeHtml(invoice.currency)}</span></div>
${invoice.orders?.order_number ? `<div class="meta-item"><span class="meta-label">${L.order_number}:</span><span class="meta-value">${escapeHtml(invoice.orders.order_number)}</span></div>` : ''}
${invoice.issued_by ? `<div class="meta-item"><span class="meta-label">${L.issued_by}:</span><span class="meta-value">${escapeHtml(invoice.issued_by)}</span></div>` : ''}
${invoice.paid_date ? `<div class="meta-item"><span class="meta-label">${L.paid_date}:</span><span class="meta-value">${formatDate(invoice.paid_date)}</span></div>` : ''}
</div>
<table>
<thead>
<tr>
<th style="width:40px;">#</th>
<th>${L.description}</th>
<th class="num" style="width:70px;">${L.qty}</th>
<th style="width:60px;">${L.unit}</th>
<th class="num" style="width:100px;">${L.unit_price}</th>
${invoice.apply_vat ? `<th class="num" style="width:60px;">${L.vat}</th>` : ''}
<th class="num" style="width:110px;">${L.total}</th>
</tr>
</thead>
<tbody>
${items.map((item, i) => `
<tr>
<td>${i + 1}</td>
<td>${escapeHtml(item.description)}</td>
<td class="num">${formatNumber(item.qty)}</td>
<td>${escapeHtml(item.unit)}</td>
<td class="num">${formatNumber(item.price)}</td>
${invoice.apply_vat ? `<td class="num">${item.vatRate}%</td>` : ''}
<td class="num">${formatNumber(item.lineTotal)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="totals">
<div class="totals-row"><span>${L.subtotal}:</span><span>${formatNumber(subtotal)} ${invoice.currency || 'CZK'}</span></div>
${invoice.apply_vat ? `<div class="totals-row"><span>${L.vat_total}:</span><span>${formatNumber(vatTotal)} ${invoice.currency || 'CZK'}</span></div>` : ''}
<div class="totals-row grand"><span>${L.grand_total}:</span><span>${formatNumber(grandTotal)} ${invoice.currency || 'CZK'}</span></div>
</div>
${invoice.notes ? `<div class="notes"><div class="notes-title">${L.notes}:</div><div>${escapeHtml(invoice.notes)}</div></div>` : ''}
</body>
</html>`;
return reply.type('text/html').send(html);
});
}

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