style: run prettier on entire codebase

This commit is contained in:
BOHA
2026-03-24 19:59:14 +01:00
parent 872be42107
commit 3c167cf5c4
148 changed files with 26740 additions and 13990 deletions

View File

@@ -0,0 +1,14 @@
// Block direct .env file edits (not .env.example, .env.production, .env.test)
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path || '';
const basename = filePath.split('/').pop().split('\\').pop();
if (basename === '.env') {
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' }));
process.exit(1);
}
} catch {}
});

View File

@@ -0,0 +1,13 @@
// Auto-format edited files with prettier
const { execSync } = require('child_process');
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_response?.filePath || input.tool_input?.file_path || '';
if (filePath) {
execSync(`npx prettier --write "${filePath}" --ignore-unknown`, { stdio: 'ignore', timeout: 10000 });
}
} catch {}
});

30
.claude/settings.json Normal file
View File

@@ -0,0 +1,30 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/block-env.js",
"timeout": 5,
"statusMessage": "Checking file permissions..."
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/format-on-save.js",
"timeout": 15,
"statusMessage": "Formatting..."
}
]
}
]
}
}

View File

@@ -0,0 +1,43 @@
---
name: compare-php
description: Compare a feature between PHP boha-app and TS boha-app-ts to verify migration parity
---
# Compare PHP vs TS Implementation
Compare a specific feature, component, or endpoint between the original PHP project (D:\cortex\boha-app) and the TypeScript migration (D:\cortex\boha-app-ts).
## Usage
The user will specify what to compare. Examples:
- `/compare-php attendance print`
- `/compare-php invoice PDF generation`
- `/compare-php offer numbering logic`
## Process
1. Search the PHP codebase (D:\cortex\boha-app) for the relevant implementation
2. Search the TS codebase (D:\cortex\boha-app-ts) for the same feature
3. Compare:
- API endpoints and request/response shape
- Business logic and calculations
- Database queries
- Frontend behavior
4. Report differences found — what's missing, what's different, what's extra
## Key directories
**PHP project:**
- API handlers: `D:\cortex\boha-app\api\admin\handlers\`
- API routes: `D:\cortex\boha-app\api\admin\`
- Frontend pages: `D:\cortex\boha-app\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app\src\admin\hooks\`
- Includes/utils: `D:\cortex\boha-app\api\includes\`
**TS project:**
- API routes: `D:\cortex\boha-app-ts\src\routes\admin\`
- Services: `D:\cortex\boha-app-ts\src\services\`
- Frontend pages: `D:\cortex\boha-app-ts\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app-ts\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app-ts\src\admin\hooks\`

View File

@@ -1,6 +0,0 @@
DATABASE_URL=mysql://user:password@127.0.0.1:3306/app_test
JWT_SECRET=test-jwt-secret-do-not-use-in-production
TOTP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
APP_ENV=local
PORT=3099
HOST=127.0.0.1

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ node_modules/
dist/ dist/
.env .env
.env.test .env.test
.env.production
*.log *.log
dist-client/ dist-client/
*.css.map *.css.map

385
docs/deployment-guide.md Normal file
View File

@@ -0,0 +1,385 @@
# Deployment Guide — boha-app-ts (Ubuntu Server)
Migration from PHP boha-app to TypeScript boha-app-ts on the same Ubuntu server.
Both apps share the same MySQL database. The PHP app stays running during migration.
---
## Prerequisites
- Ubuntu server with the PHP boha-app already running
- nginx with SSL (Let's Encrypt) already configured
- MySQL database already running with production data
- NAS storage mounted (e.g., `/mnt/nas/02_PROJEKTY`)
- SSH access to the server
---
## Phase 1: Prepare on Dev Machine (Windows)
### 1.1 Create Prisma migration baseline
```bash
cd D:\cortex\boha-app-ts
mkdir -p prisma/migrations/0_init
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
npx prisma migrate resolve --applied 0_init
git add prisma/migrations/
git commit -m "chore: create Prisma migration baseline"
```
### 1.2 Build the application
```bash
npm run build
```
This creates:
- `dist/` — compiled server (Node.js)
- `dist-client/` — compiled frontend (static files)
### 1.3 Generate new production secrets
```bash
openssl rand -hex 32
# Save this as JWT_SECRET
openssl rand -hex 32
# Save this as TOTP_ENCRYPTION_KEY (only if you want a new key)
```
### 1.4 Test the production build locally (optional)
```bash
APP_ENV=production JWT_SECRET=<your-dev-key> TOTP_ENCRYPTION_KEY=<your-dev-key> DATABASE_URL=<your-dev-db> node dist/server.js
```
Verify it starts and responds at `http://localhost:3001/api/health`.
---
## Phase 2: Prepare Ubuntu Server
### 2.1 Install Node.js 22
```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v
npm -v
```
### 2.2 Install PM2
```bash
sudo npm install -g pm2
```
### 2.3 Create application directory
```bash
sudo mkdir -p /var/www/boha-app-ts
sudo chown $USER:$USER /var/www/boha-app-ts
```
---
## Phase 3: Transfer Files to Server
### 3.1 Copy built files
From your Windows machine (Git Bash or PowerShell with SCP):
```bash
scp -r dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
```
Or use rsync if available:
```bash
rsync -avz --exclude node_modules --exclude .env dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
```
### 3.2 Install production dependencies on server
```bash
ssh user@server
cd /var/www/boha-app-ts
npm install --production
npx prisma generate
```
---
## Phase 4: Configure Environment
### 4.1 Create production .env
```bash
cd /var/www/boha-app-ts
cp .env.example .env
nano .env
```
Fill in:
```env
# Database — same as the PHP app
DATABASE_URL=mysql://user:password@localhost:3306/your_db_name
# Server
PORT=3001
HOST=127.0.0.1
APP_ENV=production
# Auth — use the NEW secrets generated in step 1.3
JWT_SECRET=<paste-new-jwt-secret>
ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — use SAME key as PHP app (unless you want to re-encrypt)
TOTP_ENCRYPTION_KEY=<same-key-as-php-app>
# NAS — Linux mount point
NAS_PATH=/mnt/nas/02_PROJEKTY
MAX_UPLOAD_SIZE=52428800
# Email
CONTACT_EMAIL_TO=manager@boha-automation.cz
CONTACT_EMAIL_FROM=web@boha-automation.cz
SMTP_FROM=noreply@boha-automation.cz
# CORS — your production domain(s)
CORS_ORIGINS=https://app.boha-automation.cz,https://www.boha-automation.cz
```
**Important decisions:**
| Setting | Recommendation |
|---------|---------------|
| `JWT_SECRET` | **New key.** All PHP sessions will be invalid — users re-login. This is expected. |
| `TOTP_ENCRYPTION_KEY` | **Same key as PHP app.** Avoids re-encrypting all TOTP secrets. |
| `DATABASE_URL` | **Same database as PHP app.** Both apps share it. |
| `NAS_PATH` | **Linux mount point** instead of Windows drive letter. |
---
## Phase 5: Database Setup
### 5.1 Mark Prisma baseline as applied
```bash
cd /var/www/boha-app-ts
npx prisma migrate resolve --applied 0_init
```
This tells Prisma the database already has all tables. No SQL is executed.
### 5.2 Verify database connection
```bash
npx prisma db pull --print | head -20
```
Should show your existing tables.
---
## Phase 6: TOTP Key Rotation (only if using new encryption key)
**Skip this section if you're using the same `TOTP_ENCRYPTION_KEY` as the PHP app.**
If you generated a new encryption key:
```bash
cd /var/www/boha-app-ts
# Dry run — verify all secrets can be decrypted and re-encrypted
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key> --dry-run
# If all [OK], run for real
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key>
```
After this, the PHP app's TOTP verification will break (secrets are now encrypted with the new key). Only do this when you're ready to cut over.
---
## Phase 7: Start Application with PM2
### 7.1 Create PM2 config
```bash
cd /var/www/boha-app-ts
cat > ecosystem.config.js << 'EOF'
module.exports = {
apps: [{
name: 'boha-app-ts',
script: 'dist/server.js',
cwd: '/var/www/boha-app-ts',
instances: 1,
env: {
NODE_ENV: 'production',
},
}]
};
EOF
```
### 7.2 Start the app
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup
# Follow the printed command to enable auto-start on boot
```
### 7.3 Verify
```bash
pm2 status
pm2 logs boha-app-ts --lines 20
curl http://localhost:3001/api/health
```
Expected: `{"status":"ok","timestamp":"..."}`
---
## Phase 8: Nginx Configuration
### 8.1 Create nginx config
```bash
sudo nano /etc/nginx/sites-available/boha-app-ts
```
Paste:
```nginx
server {
listen 443 ssl http2;
server_name app.boha-automation.cz;
ssl_certificate /etc/letsencrypt/live/app.boha-automation.cz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.boha-automation.cz/privkey.pem;
client_max_body_size 55M;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
server {
listen 80;
server_name app.boha-automation.cz;
return 301 https://$host$request_uri;
}
```
Adjust `server_name` and SSL paths to match your setup.
### 8.2 Enable and reload
```bash
sudo ln -s /etc/nginx/sites-available/boha-app-ts /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## Phase 9: Testing
### 9.1 Verify in browser
Open `https://app.boha-automation.cz` and test:
- [ ] Login page loads
- [ ] Login works (users will need to re-login due to new JWT_SECRET)
- [ ] TOTP verification works (if using same encryption key)
- [ ] Dashboard loads with data
- [ ] Offers — list, create, edit, PDF export
- [ ] Orders — list, create from offer, status transitions
- [ ] Invoices — list, create, PDF with QR code
- [ ] Projects — list, create, file manager (upload/download)
- [ ] Attendance — clock in/out, admin view, print
- [ ] Trips — list, history
- [ ] Settings — company settings, users, roles
### 9.2 Check logs for errors
```bash
pm2 logs boha-app-ts --lines 50
```
---
## Phase 10: Cutover Strategy
Both apps run simultaneously on the same database. Recommended approach:
1. **Week 1:** Run both apps. Use the TS app for daily work. Fall back to PHP if issues arise.
2. **Week 2:** If stable, redirect the main domain to the TS app.
3. **Week 3:** Stop the PHP app.
To redirect the PHP domain to the TS app, update the nginx config for the PHP domain to proxy to port 3001 instead.
---
## Future Updates
When you push code changes:
```bash
# On dev machine
npm run build
git push
# On server
cd /var/www/boha-app-ts
git pull
npm install --production
npx prisma generate
npx prisma migrate deploy # runs any new migrations
pm2 restart boha-app-ts
```
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `EADDRINUSE: port 3001` | `pm2 stop boha-app-ts` then `pm2 start` |
| Prisma connection error | Check `DATABASE_URL` in `.env` |
| TOTP verification fails | Verify `TOTP_ENCRYPTION_KEY` matches the key used to encrypt secrets |
| NAS files not accessible | Check mount: `ls /mnt/nas/02_PROJEKTY`, verify permissions |
| 502 Bad Gateway | App not running: `pm2 status`, check logs: `pm2 logs` |
| CSS/JS not loading | Verify `dist-client/` was copied, check `APP_ENV=production` |
| CORS errors | Check `CORS_ORIGINS` in `.env` matches your domain exactly |
---
## Quick Reference
| Command | Purpose |
|---------|---------|
| `pm2 start ecosystem.config.js` | Start the app |
| `pm2 restart boha-app-ts` | Restart after update |
| `pm2 stop boha-app-ts` | Stop the app |
| `pm2 logs boha-app-ts` | View logs |
| `pm2 monit` | Live monitoring |
| `npx prisma migrate deploy` | Apply database migrations |
| `npx prisma studio` | Database GUI (dev only) |

View File

@@ -0,0 +1,517 @@
# Project File Sharing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add NAS-based file management to projects — list, upload, download, delete, rename, create folders — with security hardening over the PHP original.
**Architecture:** `NasFileManager` service handles all filesystem operations with path traversal prevention and symlink rejection. `project-files` route exposes REST endpoints with permission checks and audit logging. `ProjectFileManager` React component provides the UI with drag-drop upload, breadcrumbs, and inline rename.
**Tech Stack:** Node.js `fs` module, `file-type@16` (CJS), `@fastify/multipart`, Fastify 5, React
**Spec:** `docs/superpowers/specs/2026-03-23-project-files-design.md`
---
## Task 1: Install Dependencies & Create NasFileManager Service
**Files:**
- Create: `src/services/nas-file-manager.ts`
- [ ] **Step 1: Install file-type v16**
```bash
npm install file-type@16
```
- [ ] **Step 2: Create NasFileManager service**
Create `src/services/nas-file-manager.ts`. This is a port of the PHP `NasFileManager.php` with security improvements (no symlink following, stricter path resolution).
The service must implement:
**Constants:**
```typescript
const BLOCKED_EXTENSIONS = new Set([
'exe', 'bat', 'sh', 'php', 'htaccess', 'env', 'cmd', 'com', 'msi', 'ps1',
'vbs', 'vbe', 'js', 'ws', 'wsf', 'scr', 'pif', 'jar', 'reg',
]);
const SUSPICIOUS_MIMES = [
'application/x-executable',
'application/x-msdos-program',
'application/x-dosexec',
'application/x-msdownload',
];
```
**Core methods to port from PHP (read `D:\cortex\boha-app\api\includes\NasFileManager.php` for exact logic):**
- `constructor()` — read `config.nas.path`, normalize separators
- `isConfigured()` — check basePath exists and is a directory
- `createProjectFolder(projectNumber, projectName)` — build folder name, `fs.mkdirSync(path, { recursive: true })`
- `deleteProjectFolder(projectNumber)` — find folder, `fs.promises.rm(path, { recursive: true, force: true })`
- `projectFolderExists(projectNumber)` — call `findProjectFolder()`, return boolean
- `renameProjectFolder(projectNumber, newName)` — find folder, `fs.renameSync()`
- `listFiles(projectNumber, subPath)` — resolve path, `fs.readdirSync()`, build items array with type/size/modified/extension, sort folders first then alpha, build breadcrumb
- `uploadFile(projectNumber, subPath, fileBuffer, fileName)` — validate extension, validate MIME via `file-type`, sanitize filename, handle duplicates with `_1`, `_2` suffix, `fs.writeFileSync()`
- `downloadFile(projectNumber, filePath)` — resolve path, return `{ filePath, fileName, mime }` for the route to stream
- `deleteItem(projectNumber, filePath)` — prevent root deletion, `fs.promises.rm()` for dirs, `fs.unlinkSync()` for files
- `moveItem(projectNumber, fromPath, toPath)` — validate both paths, check target doesn't exist (case-insensitive rename allowed), validate target filename, `fs.renameSync()`. Wrap in try-catch: if error code is `EXDEV` (cross-device), return `'Přesun mezi různými disky není podporován'`
- `createFolder(projectNumber, subPath, folderName)` — sanitize name, max 100 chars, `fs.mkdirSync()`
**Security-critical method — `resolveProjectPath(projectNumber, subPath)`:**
```typescript
private resolveProjectPath(projectNumber: string, subPath: string): string | null {
const folderPath = this.findProjectFolder(projectNumber);
if (!folderPath) return null;
if (!subPath || subPath === '/') return folderPath;
// Block null bytes and parent traversal
if (subPath.includes('\0') || subPath.includes('..')) return null;
// Normalize separators
subPath = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const candidate = path.resolve(folderPath, subPath);
// Verify resolved path starts with project folder (prevents traversal)
if (!candidate.startsWith(folderPath)) return null;
// Walk each component — reject symlinks
if (!this.walkAndRejectSymlinks(candidate, folderPath)) return null;
return candidate;
}
```
**Symlink rejection — `walkAndRejectSymlinks(fullPath, basePath)`:**
```typescript
private walkAndRejectSymlinks(fullPath: string, basePath: string): boolean {
// Walk from basePath down to fullPath, checking each existing segment
const relative = path.relative(basePath, fullPath);
const parts = relative.split(path.sep);
let current = basePath;
for (const part of parts) {
current = path.join(current, part);
try {
const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) return false;
} catch {
// Path doesn't exist yet (for new files) — that's OK
break;
}
}
return true;
}
```
**Helper methods:**
- `findProjectFolder(projectNumber)` — scan basePath for folders starting with `{number}_`
- `buildFolderName(projectNumber, name)``{number}_{sanitized_name}` (strip invalid chars, replace spaces with `_`, max 200 chars)
- `sanitizeFilename(name)``path.basename()`, strip control chars and `<>:"/\|?*`, trim dots/spaces, max 255 chars
- `formatFileSize(bytes)` — human-readable (B, KB, MB, GB)
- `isSuspiciousMime(mime, ext)` — check against SUSPICIOUS_MIMES and PHP-related MIME types
- `countItems(dirPath)` — count directory entries minus `.` and `..`
**MIME detection for upload:**
```typescript
import FileType from 'file-type';
// In uploadFile():
const typeResult = await FileType.fromBuffer(fileBuffer);
const detectedMime = typeResult?.mime || 'application/octet-stream';
if (this.isSuspiciousMime(detectedMime, ext)) {
return 'Obsah souboru neodpovídá jeho příponě';
}
```
- [ ] **Step 3: Verify TypeScript compiles**
```bash
npx tsc -p tsconfig.server.json --noEmit
```
- [ ] **Step 4: Commit**
```bash
git add src/services/nas-file-manager.ts package.json package-lock.json
git commit -m "feat: add NasFileManager service with security-hardened file operations"
```
---
## Task 2: Create Project Files Route
**Files:**
- Create: `src/routes/admin/project-files.ts`
- Modify: `src/server.ts`
- [ ] **Step 1: Create the route file**
Create `src/routes/admin/project-files.ts`. Port from `D:\cortex\boha-app\api\admin\handlers\project-files-handlers.php`.
The route registers `@fastify/multipart` within its own plugin scope (same pattern as orders route) with `limits: { fileSize: config.nas.maxUploadSize }`.
Set `bodyLimit: config.nas.maxUploadSize` on the upload POST route to override the global 1MB limit.
**Endpoints:**
First, add `'project_file'` to the `EntityType` union in `src/types/index.ts` (find the `EntityType` type and add it to the list).
```typescript
import fs from 'fs';
import { FastifyInstance } from 'fastify';
import multipart from '@fastify/multipart';
import prisma from '../../config/database';
import { config } from '../../config/env';
import { requirePermission } from '../../middleware/auth';
import { logAudit } from '../../services/audit';
import { success, error } from '../../utils/response';
import { NasFileManager } from '../../services/nas-file-manager';
export default async function projectFilesRoutes(fastify: FastifyInstance): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: config.nas.maxUploadSize } });
const fm = new NasFileManager();
// Helper: get project from DB
async function getProject(projectId: number) {
return prisma.projects.findUnique({
where: { id: projectId },
select: { id: true, project_number: true, name: true },
});
}
// GET — list files or download
fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
const project = await getProject(projectId);
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
if (!project.project_number) return error(reply, 'Projekt nemá číslo', 400);
// Download action
if (query.action === 'download') {
const filePath = query.path || '';
if (!filePath) return error(reply, 'Cesta k souboru je povinná', 400);
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
const result = fm.downloadFile(project.project_number, filePath);
if (!result) return error(reply, 'Soubor nebyl nalezen', 404);
reply.header('Content-Disposition', `attachment; filename="${fm.sanitizeFilename(result.fileName)}"`);
reply.header('Content-Type', result.mime);
reply.header('X-Content-Type-Options', 'nosniff');
return reply.send(fs.createReadStream(result.filePath));
}
// List files
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
const result = fm.listFiles(project.project_number, query.path || '');
if (!result) return error(reply, 'Složka nebyla nalezena', 404);
return success(reply, { ...result, project_number: project.project_number, folder_exists: true });
});
// POST — create folder (JSON body, no multipart)
fastify.post('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
const project = await getProject(projectId);
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
if (!fm.projectFolderExists(project.project_number)) {
fm.createProjectFolder(project.project_number, project.name || '');
}
const body = request.body as Record<string, string>;
const folderName = (body.folder_name || '').trim();
if (!folderName) return error(reply, 'Název složky je povinný', 400);
if (folderName.length > 100) return error(reply, 'Název složky je příliš dlouhý (max 100 znaků)', 400);
const folderError = fm.createFolder(project.project_number, body.path || '', folderName);
if (folderError) return error(reply, folderError, 400);
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'project_file', entityId: projectId, description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'` });
return success(reply, null, 200, 'Složka byla vytvořena');
});
// POST upload — separate route with multipart parsing
fastify.post('/upload', {
preHandler: requirePermission('projects.files'),
bodyLimit: config.nas.maxUploadSize,
}, async (request, reply) => {
// Register multipart for this request
const data = await request.file();
if (!data) return error(reply, 'Nebyl nahrán žádný soubor', 400);
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
const project = await getProject(projectId);
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
if (!fm.projectFolderExists(project.project_number)) {
fm.createProjectFolder(project.project_number, project.name || '');
}
const buffer = await data.toBuffer();
const uploadError = await fm.uploadFile(project.project_number, query.path || '', buffer, data.filename);
if (uploadError) return error(reply, uploadError, 400);
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'project_file', entityId: projectId, description: `Nahrán soubor do projektu '${project.project_number}'` });
return success(reply, null, 200, 'Soubor byl nahrán');
});
// PUT — move/rename
fastify.put('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
const project = await getProject(projectId);
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
const body = request.body as Record<string, string>;
if (!body.from_path || !body.to_path) return error(reply, 'Zdrojová i cílová cesta jsou povinné', 400);
const moveError = fm.moveItem(project.project_number, body.from_path, body.to_path);
if (moveError) return error(reply, moveError, 400);
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'project_file', entityId: projectId, description: `Přesun/přejmenování v projektu '${project.project_number}'` });
return success(reply, null, 200, 'Soubor byl přesunut');
});
// DELETE
fastify.delete('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
const project = await getProject(projectId);
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
const filePath = query.path || '';
if (!filePath) return error(reply, 'Cesta k souboru je povinná', 400);
const deleteError = await fm.deleteItem(project.project_number, filePath);
if (deleteError) return error(reply, deleteError, 400);
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project_file', entityId: projectId, description: `Smazán soubor/složka v projektu '${project.project_number}'` });
return success(reply, null, 200, 'Soubor byl smazán');
});
}
```
- [ ] **Step 2: Register route in server.ts**
Add import and registration in `src/server.ts`:
```typescript
import projectFilesRoutes from './routes/admin/project-files';
```
And in the routes section:
```typescript
await app.register(projectFilesRoutes, { prefix: '/api/admin/project-files' });
```
- [ ] **Step 3: Verify TypeScript compiles**
```bash
npx tsc -p tsconfig.server.json --noEmit
```
- [ ] **Step 4: Commit**
```bash
git add src/routes/admin/project-files.ts src/server.ts
git commit -m "feat: add project files REST endpoints with auth and audit logging"
```
---
## Task 3: Integrate File Operations with Project CRUD
**Files:**
- Modify: `src/services/projects.service.ts`
- Modify: `src/routes/admin/projects.ts`
- [ ] **Step 1: Update projects service**
In `src/services/projects.service.ts`:
Add import:
```typescript
import { NasFileManager } from './nas-file-manager';
const nasFileManager = new NasFileManager();
```
Update `createProject()` — after DB insert, create NAS folder:
```typescript
if (project.project_number && nasFileManager.isConfigured()) {
nasFileManager.createProjectFolder(project.project_number, project.name || '');
}
```
Update `updateProject()` — if name changed, rename folder:
```typescript
if (existing.name !== data.name && existing.project_number && nasFileManager.isConfigured()) {
nasFileManager.renameProjectFolder(existing.project_number, data.name || '');
}
```
Update `deleteProject()` — accept `deleteFiles` param, delete folder if true:
```typescript
if (deleteFiles && project.project_number && nasFileManager.isConfigured()) {
await nasFileManager.deleteProjectFolder(project.project_number);
}
```
Update `getProject()` — add `has_nas_folder` to response:
```typescript
const result = {
...project,
has_nas_folder: project.project_number ? nasFileManager.projectFolderExists(project.project_number) : false,
};
```
- [ ] **Step 2: Update projects route for delete_files**
In the DELETE handler in `src/routes/admin/projects.ts`, extract `delete_files` from the request body and pass it to the service:
```typescript
const body = request.body as Record<string, unknown>;
const deleteFiles = !!body?.delete_files;
const result = await deleteProject(id, deleteFiles);
```
Update the `deleteProject` service function signature to `deleteProject(id: number, deleteFiles: boolean = false)`.
- [ ] **Step 3: Verify TypeScript compiles**
```bash
npx tsc -p tsconfig.server.json --noEmit
```
- [ ] **Step 4: Commit**
```bash
git add src/services/projects.service.ts src/routes/admin/projects.ts src/types/index.ts
git commit -m "feat: integrate NAS file operations with project CRUD"
```
---
## Task 4: Create ProjectFileManager Frontend Component
**Files:**
- Create: `src/admin/components/ProjectFileManager.tsx`
- Modify: `src/admin/admin.css`
- [ ] **Step 1: Create the component**
Create `src/admin/components/ProjectFileManager.tsx`. This is a direct TypeScript port of `D:\cortex\boha-app\src\admin\components\ProjectFileManager.jsx` (657 lines).
Read the PHP JSX file completely and port it to TypeScript with these changes:
- All API URLs use `/api/admin/project-files` instead of `/api/admin/project-files.php`
- Add TypeScript interfaces for props, items, etc.
- Use `apiFetch` from `../utils/api`
- Use `ConfirmModal` from `./ConfirmModal`
- Use `useAlert` from `../context/AlertContext`
- Download uses blob URL approach (same as PHP frontend — the spec suggestion for direct links is nice-to-have but the PHP uses blob and it works fine for typical project files)
The component includes:
- `getFileIcon()` helper with SVG icons by extension
- `FileNameCell` sub-component for folder links and file names
- State management for items, loading, path, breadcrumb, upload, create folder, rename, delete
- `fetchFiles()`, `handleUpload()`, `handleDownload()`, `handleDelete()`, `handleRename()`, `handleCreateFolder()` handlers
- Drag-and-drop upload zone
- Toolbar with breadcrumb, full path display, folder/upload buttons
- File table with icon, name, size, modified, actions columns
- ConfirmModal for delete confirmation
- [ ] **Step 2: Add CSS styles**
Append the file manager CSS to `src/admin/admin.css`. Copy from `D:\cortex\boha-app\src\admin\admin.css` lines 2508-2674 (the `.fm-*` classes).
- [ ] **Step 3: Commit**
```bash
git add src/admin/components/ProjectFileManager.tsx src/admin/admin.css
git commit -m "feat: add ProjectFileManager component with file browser UI"
```
---
## Task 5: Integrate FileManager into ProjectDetail
**Files:**
- Modify: `src/admin/pages/ProjectDetail.tsx`
- [ ] **Step 1: Replace placeholder with ProjectFileManager**
In `src/admin/pages/ProjectDetail.tsx`:
Add import:
```typescript
import ProjectFileManager from '../components/ProjectFileManager'
```
Find the files placeholder section (the `admin-card` with "Správa souborů projektu bude dostupná v příští verzi") and replace it with:
```tsx
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<ProjectFileManager
projectId={project.id}
projectNumber={project.project_number}
hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder}
/>
</motion.div>
```
- [ ] **Step 2: Update delete dialog**
The delete dialog should already send `delete_files` — verify the existing `deleteFiles` state and checkbox are wired up correctly. If not, add a checkbox:
```tsx
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
<input type="checkbox" checked={deleteFiles} onChange={e => setDeleteFiles(e.target.checked)} />
Smazat i soubory na disku
</label>
```
- [ ] **Step 3: Verify everything works**
Start the dev server manually and test:
1. Navigate to a project detail page
2. Verify the file manager loads (may show empty folder message)
3. Test upload, create folder, rename, delete, download
4. Verify permissions (non-admin without `projects.files` should not see write buttons)
- [ ] **Step 4: Commit**
```bash
git add src/admin/pages/ProjectDetail.tsx
git commit -m "feat: integrate ProjectFileManager into project detail page"
```

View File

@@ -0,0 +1,177 @@
# Project File Sharing — boha-app-ts
**Date:** 2026-03-23
**Status:** Approved
**Scope:** NAS-based file management for projects — port of PHP NasFileManager with security improvements
---
## Context
The PHP boha-app has a complete project file sharing system via NAS-mounted storage. The TS version has only a placeholder UI and the `NAS_PATH` env var configured. This spec covers porting the full functionality with security improvements identified during audit.
---
## 1. Backend — NasFileManager Service
**File:** `src/services/nas-file-manager.ts`
Port of `NasFileManager.php` (622 lines) with security improvements.
### Security Improvements Over PHP
- **No symlink/junction following** — use `fs.lstatSync()` everywhere. Walk each path component with `lstat()` and reject if any component is a symlink. Do NOT use `fs.realpathSync()` (it follows symlinks by design). Instead, use `path.resolve()` for prefix validation after confirming no symlinks exist.
- **Extended blocked extensions** — add `vbs, vbe, js, ws, wsf, scr, pif, jar, reg` to the existing list (Windows Script Host executables).
- **Download headers** — `X-Content-Type-Options: nosniff` is already set globally by security middleware, but also set on download responses explicitly.
- **MIME validation** via `file-type` package **version 16.x** (last CJS-compatible version — the project uses CommonJS modules). Install as `file-type@16`.
- **Use `fs.promises.rm()` with `recursive: true`** instead of hand-rolled recursive delete. Pre-check for symlinks in the tree before calling `rm` to prevent junction traversal on Windows.
### Path Resolution Algorithm (`resolveProjectPath`)
```
1. Find project folder by prefix scan
2. If subPath is empty, return project folder
3. Reject if subPath contains null bytes or ".."
4. Normalize separators (backslash → forward slash), trim slashes
5. Build candidate = projectFolder + "/" + subPath
6. Walk each path component with lstatSync():
- If component is a symlink → return null (reject)
7. Verify path.resolve(candidate) starts with projectFolder
8. Return candidate (or null if validation fails)
```
### Retained Security (same as PHP)
- Null byte detection in paths
- `..` traversal blocking
- Filename sanitization: strip control chars (0x00-0x1f, 0x7f), invalid chars (`<>:"/\|?*`), trim dots/spaces, max 255 chars, `path.basename()` extraction
- Blocked extensions: `exe, bat, sh, php, htaccess, env, cmd, com, msi, ps1, vbs, vbe, js, ws, wsf, scr, pif, jar, reg`
- Executable MIME type detection (application/x-executable, x-msdos-program, x-dosexec, x-msdownload)
- PHP MIME detection (any MIME containing "php" or "x-httpd")
- Root folder deletion prevention
- Duplicate file naming (append `_1`, `_2`, etc.) — note: TOCTOU race exists on SMB mounts but is acceptable for this use case (low concurrency internal app)
- File size limit from `MAX_UPLOAD_SIZE` env var (default 52MB)
### Public Methods
```
isConfigured(): boolean
createProjectFolder(projectNumber, projectName): boolean
deleteProjectFolder(projectNumber): Promise<boolean>
projectFolderExists(projectNumber): boolean
renameProjectFolder(projectNumber, newName): boolean
listFiles(projectNumber, subPath): ListResult | null
uploadFile(projectNumber, subPath, fileBuffer, fileName): Promise<string | null>
downloadFile(projectNumber, filePath): DownloadResult | null
deleteItem(projectNumber, filePath): Promise<string | null>
moveItem(projectNumber, fromPath, toPath): string | null
createFolder(projectNumber, subPath, folderName): string | null
sanitizeFilename(name): string
```
### Private Methods
```
findProjectFolder(projectNumber): string | null
buildFolderName(projectNumber, projectName): string
resolveProjectPath(projectNumber, subPath): string | null
walkAndRejectSymlinks(fullPath, basePath): boolean
countItems(dirPath): number
formatFileSize(bytes): string
isSuspiciousMime(mime, ext): boolean
```
---
## 2. Backend — Project Files Route
**File:** `src/routes/admin/project-files.ts`
**Modify:** `src/server.ts` — add import and register: `app.register(projectFilesRoutes, { prefix: '/api/admin/project-files' })`
### Endpoints
| Method | Query Params | Permission | Action |
|--------|-------------|------------|--------|
| GET | `project_id, path` | `projects.view` | List files/folders |
| GET | `action=download, project_id, path` | `projects.view` | Stream file download |
| POST | `action=upload, project_id, path` | `projects.files` | Upload file (multipart) |
| POST | `action=create_folder, project_id, path, folder_name` | `projects.files` | Create subfolder |
| PUT | `action=move, project_id, from_path, to_path` | `projects.files` | Move/rename |
| DELETE | `project_id, path` | `projects.files` | Delete file/folder |
### Behavior
- All endpoints validate `project_id` exists in database before file operations
- All write operations logged via `logAudit()`
- Download sets `Content-Disposition: attachment` and `Content-Type` from detected MIME
- Upload route: register `@fastify/multipart` within the plugin scope (same pattern as orders route) with `bodyLimit: config.nas.maxUploadSize` to override the global 1MB limit
- Folder name max 100 characters on create
- `moveItem`: handle `EXDEV` error (cross-device) gracefully with error message — source and target are expected to be on the same NAS mount
---
## 3. Project CRUD Integration
**Modify:** `src/services/projects.service.ts`
- `createProject()` — after DB insert, call `nasFileManager.createProjectFolder(projectNumber, name)`
- `updateProject()` — if project name changed, call `nasFileManager.renameProjectFolder(projectNumber, newName)`
- `deleteProject()` — if `deleteFiles` flag is true, call `nasFileManager.deleteProjectFolder(projectNumber)`
- `getProject()` — add `has_nas_folder: nasFileManager.projectFolderExists(projectNumber)` to response
---
## 4. Frontend — ProjectFileManager Component
**File:** `src/admin/components/ProjectFileManager.tsx`
Port of PHP's `ProjectFileManager.jsx` (657 lines).
### Features
- File/folder table with columns: Name, Size, Modified, Actions
- File type icons by extension (folder, pdf, image, doc, xls, zip, etc.)
- Breadcrumb navigation with clickable path segments
- Full path display (real path on disk)
- Drag-and-drop upload zone with visual feedback
- Click-to-upload fallback via hidden file input
- Create folder dialog (input + confirm)
- Inline rename (click name to edit)
- Delete with ConfirmModal
- Download via direct link with auth cookie (NOT blob URL — avoids loading large files into memory)
- Permission check: write operations only shown if `hasPermission('projects.files')`
- Item count for folders, formatted size for files
- Folders sorted first, then files, both alphabetically
### Props
```typescript
interface ProjectFileManagerProps {
projectId: number
projectNumber: string | null
hasPermission: (perm: string) => boolean
hasNasFolder: boolean
}
```
---
## 5. ProjectDetail Integration
**Modify:** `src/admin/pages/ProjectDetail.tsx`
- Replace the placeholder "Soubory" section with `<ProjectFileManager />` component
- Pass `projectId`, `projectNumber`, `hasPermission`, `hasNasFolder` props
- Delete dialog: add checkbox "Smazat i soubory na disku" that sends `delete_files: true`
---
## Dependencies
- `file-type@16` — MIME detection (version 16 is the last CJS-compatible version)
- `@fastify/multipart` — already installed (used by orders route)
---
## Out of Scope
- File versioning / history
- File sharing links (public URLs)
- Thumbnail generation for images
- Full-text search within files
- Virus scanning

View File

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

View File

@@ -1,47 +1,51 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp, extractCookie } from './helpers'; import { buildApp, extractCookie } from "./helpers";
let app: Awaited<ReturnType<typeof buildApp>>; let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => { app = await buildApp(); }); beforeAll(async () => {
afterAll(async () => { await app.close(); }); app = await buildApp();
});
afterAll(async () => {
await app.close();
});
describe('POST /api/admin/login', () => { describe("POST /api/admin/login", () => {
it('returns 401 for invalid credentials', async () => { it("returns 401 for invalid credentials", async () => {
const res = await app.inject({ const res = await app.inject({
method: 'POST', method: "POST",
url: '/api/admin/login', url: "/api/admin/login",
payload: { username: 'nonexistent', password: 'wrong' }, payload: { username: "nonexistent", password: "wrong" },
}); });
expect(res.statusCode).toBe(401); expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false); expect(res.json().success).toBe(false);
}); });
it('returns 400 for missing fields', async () => { it("returns 400 for missing fields", async () => {
const res = await app.inject({ const res = await app.inject({
method: 'POST', method: "POST",
url: '/api/admin/login', url: "/api/admin/login",
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
}); });
describe('POST /api/admin/refresh', () => { describe("POST /api/admin/refresh", () => {
it('returns 401 without refresh token', async () => { it("returns 401 without refresh token", async () => {
const res = await app.inject({ const res = await app.inject({
method: 'POST', method: "POST",
url: '/api/admin/refresh', url: "/api/admin/refresh",
}); });
expect(res.statusCode).toBe(401); expect(res.statusCode).toBe(401);
}); });
}); });
describe('POST /api/admin/logout', () => { describe("POST /api/admin/logout", () => {
it('clears refresh token cookie', async () => { it("clears refresh token cookie", async () => {
const res = await app.inject({ const res = await app.inject({
method: 'POST', method: "POST",
url: '/api/admin/logout', url: "/api/admin/logout",
}); });
expect(res.statusCode).toBeLessThan(500); expect(res.statusCode).toBeLessThan(500);
}); });

View File

@@ -1,25 +1,25 @@
import Fastify from 'fastify'; import Fastify from "fastify";
import cookie from '@fastify/cookie'; import cookie from "@fastify/cookie";
import rateLimit from '@fastify/rate-limit'; import rateLimit from "@fastify/rate-limit";
import authRoutes from '../routes/admin/auth'; import authRoutes from "../routes/admin/auth";
import totpRoutes from '../routes/admin/totp'; import totpRoutes from "../routes/admin/totp";
export async function buildApp() { export async function buildApp() {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
await app.register(cookie); await app.register(cookie);
await app.register(rateLimit, { max: 1000, timeWindow: '1 minute' }); await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
await app.register(authRoutes, { prefix: '/api/admin' }); await app.register(authRoutes, { prefix: "/api/admin" });
await app.register(totpRoutes, { prefix: '/api/admin/totp' }); await app.register(totpRoutes, { prefix: "/api/admin/totp" });
return app; return app;
} }
export function extractCookie(response: any, name: string): string | undefined { export function extractCookie(response: any, name: string): string | undefined {
const cookies = response.headers['set-cookie']; const cookies = response.headers["set-cookie"];
if (!cookies) return undefined; if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies]; const arr = Array.isArray(cookies) ? cookies : [cookies];
for (const c of arr) { for (const c of arr) {
if (c.startsWith(`${name}=`)) { if (c.startsWith(`${name}=`)) {
return c.split(';')[0].split('=')[1]; return c.split(";")[0].split("=")[1];
} }
} }
return undefined; return undefined;

View File

@@ -1,16 +1,19 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { generateSharedNumber, generateOfferNumber } from '../services/numbering.service'; import {
generateSharedNumber,
generateOfferNumber,
} from "../services/numbering.service";
describe('generateSharedNumber', () => { describe("generateSharedNumber", () => {
it('returns correct format (YYtypeCode + 4 digits)', async () => { it("returns correct format (YYtypeCode + 4 digits)", async () => {
const num = await generateSharedNumber(); const num = await generateSharedNumber();
const yy = String(new Date().getFullYear()).slice(-2); const yy = String(new Date().getFullYear()).slice(-2);
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`)); expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
}); });
}); });
describe('generateOfferNumber', () => { describe("generateOfferNumber", () => {
it('returns correct format (YEAR/PREFIX/NNN)', async () => { it("returns correct format (YEAR/PREFIX/NNN)", async () => {
const num = await generateOfferNumber(); const num = await generateOfferNumber();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`)); expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));

View File

@@ -1,2 +1,2 @@
import dotenv from 'dotenv'; import dotenv from "dotenv";
dotenv.config({ path: '.env.test' }); dotenv.config({ path: ".env.test" });

View File

@@ -1,48 +1,48 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from "react";
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from "react-router-dom";
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from './context/AlertContext' import { AlertProvider } from "./context/AlertContext";
import ErrorBoundary from './components/ErrorBoundary' import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from './components/AdminLayout' import AdminLayout from "./components/AdminLayout";
import AlertContainer from './components/AlertContainer' import AlertContainer from "./components/AlertContainer";
import Login from './pages/Login' import Login from "./pages/Login";
import Dashboard from './pages/Dashboard' import Dashboard from "./pages/Dashboard";
import './admin.css' import "./admin.css";
import './login.css' import "./login.css";
import './dashboard.css' import "./dashboard.css";
import './attendance.css' import "./attendance.css";
import './settings.css' import "./settings.css";
import './offers.css' import "./offers.css";
import './invoices.css' import "./invoices.css";
const Users = lazy(() => import('./pages/Users')) const Users = lazy(() => import("./pages/Users"));
const Attendance = lazy(() => import('./pages/Attendance')) const Attendance = lazy(() => import("./pages/Attendance"));
const AttendanceHistory = lazy(() => import('./pages/AttendanceHistory')) const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
const AttendanceAdmin = lazy(() => import('./pages/AttendanceAdmin')) const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin"));
const AttendanceBalances = lazy(() => import('./pages/AttendanceBalances')) const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances"));
const AttendanceCreate = lazy(() => import('./pages/AttendanceCreate')) const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
const LeaveRequests = lazy(() => import('./pages/LeaveRequests')) const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
const LeaveApproval = lazy(() => import('./pages/LeaveApproval')) const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
const AttendanceLocation = lazy(() => import('./pages/AttendanceLocation')) const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
const Trips = lazy(() => import('./pages/Trips')) const Trips = lazy(() => import("./pages/Trips"));
const TripsHistory = lazy(() => import('./pages/TripsHistory')) const TripsHistory = lazy(() => import("./pages/TripsHistory"));
const TripsAdmin = lazy(() => import('./pages/TripsAdmin')) const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
const Vehicles = lazy(() => import('./pages/Vehicles')) const Vehicles = lazy(() => import("./pages/Vehicles"));
const Offers = lazy(() => import('./pages/Offers')) const Offers = lazy(() => import("./pages/Offers"));
const OfferDetail = lazy(() => import('./pages/OfferDetail')) const OfferDetail = lazy(() => import("./pages/OfferDetail"));
const OffersCustomers = lazy(() => import('./pages/OffersCustomers')) const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
const OffersTemplates = lazy(() => import('./pages/OffersTemplates')) const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const CompanySettings = lazy(() => import('./pages/CompanySettings')) const CompanySettings = lazy(() => import("./pages/CompanySettings"));
const Orders = lazy(() => import('./pages/Orders')) const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import('./pages/OrderDetail')) const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const Projects = lazy(() => import('./pages/Projects')) const Projects = lazy(() => import("./pages/Projects"));
const ProjectCreate = lazy(() => import('./pages/ProjectCreate')) const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
const ProjectDetail = lazy(() => import('./pages/ProjectDetail')) const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import('./pages/Invoices')) const Invoices = lazy(() => import("./pages/Invoices"));
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail')) const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
const Settings = lazy(() => import('./pages/Settings')) const Settings = lazy(() => import("./pages/Settings"));
const AuditLog = lazy(() => import('./pages/AuditLog')) const AuditLog = lazy(() => import("./pages/AuditLog"));
const NotFound = lazy(() => import('./pages/NotFound')) const NotFound = lazy(() => import("./pages/NotFound"));
export default function AdminApp() { export default function AdminApp() {
return ( return (
@@ -50,20 +50,38 @@ export default function AdminApp() {
<AlertProvider> <AlertProvider>
<AlertContainer /> <AlertContainer />
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<div className="admin-loading"><div className="admin-spinner" /></div>}> <Suspense
fallback={
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<Routes> <Routes>
<Route path="login" element={<Login />} /> <Route path="login" element={<Login />} />
<Route element={<AdminLayout />}> <Route element={<AdminLayout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="users" element={<Users />} /> <Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} /> <Route path="attendance" element={<Attendance />} />
<Route path="attendance/history" element={<AttendanceHistory />} /> <Route
path="attendance/history"
element={<AttendanceHistory />}
/>
<Route path="attendance/admin" element={<AttendanceAdmin />} /> <Route path="attendance/admin" element={<AttendanceAdmin />} />
<Route path="attendance/balances" element={<AttendanceBalances />} /> <Route
path="attendance/balances"
element={<AttendanceBalances />}
/>
<Route path="attendance/requests" element={<LeaveRequests />} /> <Route path="attendance/requests" element={<LeaveRequests />} />
<Route path="attendance/approval" element={<LeaveApproval />} /> <Route path="attendance/approval" element={<LeaveApproval />} />
<Route path="attendance/create" element={<AttendanceCreate />} /> <Route
<Route path="attendance/location/:id" element={<AttendanceLocation />} /> path="attendance/create"
element={<AttendanceCreate />}
/>
<Route
path="attendance/location/:id"
element={<AttendanceLocation />}
/>
<Route path="trips" element={<Trips />} /> <Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} /> <Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} /> <Route path="trips/admin" element={<TripsAdmin />} />
@@ -91,5 +109,5 @@ export default function AdminApp() {
</ErrorBoundary> </ErrorBoundary>
</AlertProvider> </AlertProvider>
</AuthProvider> </AuthProvider>
) );
} }

View File

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

View File

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

View File

@@ -1,44 +1,72 @@
import React from 'react' import React from "react";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import { useAlertState } from '../context/AlertContext' import { useAlertState } from "../context/AlertContext";
const icons: Record<string, React.ReactNode> = { const icons: Record<string, React.ReactNode> = {
success: ( success: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" /> <polyline points="22 4 12 14.01 9 11.01" />
</svg> </svg>
), ),
error: ( error: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" /> <line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" /> <line x1="9" y1="9" x2="15" y2="15" />
</svg> </svg>
), ),
warning: ( warning: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <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="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
), ),
info: ( info: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" /> <line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" /> <line x1="12" y1="8" x2="12.01" y2="8" />
</svg> </svg>
), ),
} };
export default function AlertContainer() { export default function AlertContainer() {
const { alerts, removeAlert } = useAlertState() const { alerts, removeAlert } = useAlertState();
return ( return (
<div className="admin-alert-container" role="status" aria-live="polite"> <div className="admin-alert-container" role="status" aria-live="polite">
<AnimatePresence> <AnimatePresence>
{alerts.map(alert => ( {alerts.map((alert) => (
<motion.div <motion.div
key={alert.id} key={alert.id}
className={`admin-toast admin-toast-${alert.type}`} className={`admin-toast admin-toast-${alert.type}`}
@@ -54,7 +82,14 @@ export default function AlertContainer() {
onClick={() => removeAlert(alert.id)} onClick={() => removeAlert(alert.id)}
aria-label="Zavřít" aria-label="Zavřít"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
@@ -63,5 +98,5 @@ export default function AlertContainer() {
))} ))}
</AnimatePresence> </AnimatePresence>
</div> </div>
) );
} }

View File

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

View File

@@ -1,31 +1,31 @@
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from './AdminDatePicker' import AdminDatePicker from "./AdminDatePicker";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
interface BulkAttendanceForm { interface BulkAttendanceForm {
month: string month: string;
user_ids: string[] user_ids: string[];
arrival_time: string arrival_time: string;
departure_time: string departure_time: string;
break_start_time: string break_start_time: string;
break_end_time: string break_end_time: string;
} }
interface BulkAttendanceUser { interface BulkAttendanceUser {
id: number | string id: number | string;
name: string name: string;
} }
interface BulkAttendanceModalProps { interface BulkAttendanceModalProps {
show: boolean show: boolean;
onClose: () => void onClose: () => void;
form: BulkAttendanceForm form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void setForm: (form: BulkAttendanceForm) => void;
users: BulkAttendanceUser[] users: BulkAttendanceUser[];
onSubmit: () => void onSubmit: () => void;
submitting: boolean submitting: boolean;
toggleUser: (userId: number | string) => void toggleUser: (userId: number | string) => void;
toggleAllUsers: () => void toggleAllUsers: () => void;
} }
export default function BulkAttendanceModal({ export default function BulkAttendanceModal({
@@ -39,7 +39,7 @@ export default function BulkAttendanceModal({
toggleUser, toggleUser,
toggleAllUsers, toggleAllUsers,
}: BulkAttendanceModalProps) { }: BulkAttendanceModalProps) {
useModalLock(show) useModalLock(show);
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -51,7 +51,10 @@ export default function BulkAttendanceModal({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-backdrop" onClick={() => !submitting && onClose()} /> <div
className="admin-modal-backdrop"
onClick={() => !submitting && onClose()}
/>
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -61,8 +64,15 @@ export default function BulkAttendanceModal({
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2> <h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}> <p
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Existující záznamy se přeskočí. 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> </p>
</div> </div>
@@ -84,30 +94,32 @@ export default function BulkAttendanceModal({
type="button" type="button"
onClick={toggleAllUsers} onClick={toggleAllUsers}
style={{ style={{
marginLeft: '0.75rem', marginLeft: "0.75rem",
background: 'none', background: "none",
border: 'none', border: "none",
color: 'var(--accent-color)', color: "var(--accent-color)",
cursor: 'pointer', cursor: "pointer",
fontSize: '0.8125rem', fontSize: "0.8125rem",
fontWeight: 500, fontWeight: 500,
padding: 0, padding: 0,
}} }}
> >
{form.user_ids.length === users.length ? 'Odznačit vše' : 'Vybrat vše'} {form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</button> </button>
</label> </label>
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
gap: '0.375rem', gap: "0.375rem",
maxHeight: '200px', maxHeight: "200px",
overflowY: 'auto', overflowY: "auto",
padding: '0.75rem', padding: "0.75rem",
background: 'var(--bg-tertiary)', background: "var(--bg-tertiary)",
borderRadius: 'var(--border-radius-sm)', borderRadius: "var(--border-radius-sm)",
border: '1px solid var(--border-color)', border: "1px solid var(--border-color)",
}} }}
> >
{users.map((user) => ( {users.map((user) => (
@@ -132,7 +144,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.arrival_time} value={form.arrival_time}
onChange={(val) => setForm({ ...form, arrival_time: val })} onChange={(val) =>
setForm({ ...form, arrival_time: val })
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -140,7 +154,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.departure_time} value={form.departure_time}
onChange={(val) => setForm({ ...form, departure_time: val })} onChange={(val) =>
setForm({ ...form, departure_time: val })
}
/> />
</div> </div>
</div> </div>
@@ -151,7 +167,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => setForm({ ...form, break_start_time: val })} onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -159,7 +177,9 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_end_time} value={form.break_end_time}
onChange={(val) => setForm({ ...form, break_end_time: val })} onChange={(val) =>
setForm({ ...form, break_end_time: val })
}
/> />
</div> </div>
</div> </div>
@@ -181,12 +201,12 @@ export default function BulkAttendanceModal({
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0} disabled={submitting || form.user_ids.length === 0}
> >
{submitting ? 'Vytvářím záznamy...' : 'Vyplnit měsíc'} {submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
</button> </button>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
) );
} }

View File

@@ -1,24 +1,41 @@
import type { ReactNode } from 'react' import type { ReactNode } from "react";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
interface ConfirmModalProps { interface ConfirmModalProps {
isOpen: boolean isOpen: boolean;
onClose: () => void onClose: () => void;
onConfirm: () => void onConfirm: () => void;
title: string title: string;
message: ReactNode message: ReactNode;
confirmText?: string confirmText?: string;
cancelText?: string cancelText?: string;
type?: 'danger' | 'warning' | 'default' | 'info' type?: "danger" | "warning" | "default" | "info";
confirmVariant?: 'danger' | 'primary' confirmVariant?: "danger" | "primary";
loading?: boolean loading?: boolean;
} }
export default function ConfirmModal({ isOpen, onClose, onConfirm, title, message, confirmText = 'Potvrdit', cancelText = 'Zrušit', type = 'default', confirmVariant, loading }: ConfirmModalProps) { export default function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Potvrdit",
cancelText = "Zrušit",
type = "default",
confirmVariant,
loading,
}: ConfirmModalProps) {
return ( return (
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}> <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} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
className="admin-modal admin-confirm-modal" className="admin-modal admin-confirm-modal"
@@ -29,7 +46,14 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
> >
<div className="admin-modal-body admin-confirm-content"> <div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}> <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"> <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" /> <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="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
@@ -39,14 +63,26 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
<p className="admin-confirm-message">{message}</p> <p className="admin-confirm-message">{message}</p>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">
<button type="button" onClick={onClose} className="admin-btn admin-btn-secondary" disabled={loading}>{cancelText}</button> <button
<button type="button" onClick={onConfirm} className="admin-btn admin-btn-primary" disabled={loading}> type="button"
{loading ? 'Zpracování...' : confirmText} onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
className="admin-btn admin-btn-primary"
disabled={loading}
>
{loading ? "Zpracování..." : confirmText}
</button> </button>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
) );
} }

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +1,173 @@
import { useMemo, useRef, useCallback } from 'react' import { useMemo, useRef, useCallback } from "react";
import ReactQuill from 'react-quill-new' import ReactQuill from "react-quill-new";
import 'react-quill-new/dist/quill.snow.css' import "react-quill-new/dist/quill.snow.css";
const Quill = ReactQuill.Quill const Quill = ReactQuill.Quill;
if (!(Quill as any).__bohaRegistered) { if (!(Quill as any).__bohaRegistered) {
const Font = Quill.import('attributors/class/font') as any const Font = Quill.import("attributors/class/font") as any;
Font.whitelist = [ Font.whitelist = [
'arial', 'tahoma', 'verdana', 'georgia', 'times-new-roman', "arial",
'courier-new', 'trebuchet-ms', 'impact', 'comic-sans-ms', "tahoma",
'lucida-console', 'palatino-linotype', 'garamond' "verdana",
] "georgia",
Quill.register(Font, true) "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 const SizeStyle = Quill.import("attributors/style/size") as any;
SizeStyle.whitelist = [ SizeStyle.whitelist = [
'8px', '9px', '10px', '11px', '12px', '14px', '16px', "8px",
'18px', '20px', '24px', '28px', '32px', '36px', '48px' "9px",
] "10px",
Quill.register(SizeStyle, true) "11px",
;(Quill as any).__bohaRegistered = true "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 Font = Quill.import("attributors/class/font") as any;
const SIZE_WHITELIST = [ const SIZE_WHITELIST = [
'8px', '9px', '10px', '11px', '12px', '14px', '16px', "8px",
'18px', '20px', '24px', '28px', '32px', '36px', '48px' "9px",
] "10px",
"11px",
"12px",
"14px",
"16px",
"18px",
"20px",
"24px",
"28px",
"32px",
"36px",
"48px",
];
const COLORS = [ const COLORS = [
'#000000', '#1a1a1a', '#333333', '#555555', '#777777', '#999999', '#bbbbbb', '#dddddd', '#ffffff', "#000000",
'#de3a3a', '#e57373', '#c62828', "#1a1a1a",
'#1565c0', '#42a5f5', '#0d47a1', "#333333",
'#2e7d32', '#66bb6a', '#1b5e20', "#555555",
'#f57f17', '#ffca28', '#e65100', "#777777",
'#6a1b9a', '#ab47bc', '#4a148c', "#999999",
'#00695c', '#26a69a', '#004d40', "#bbbbbb",
'#37474f', '#78909c', '#263238', "#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 = [ const TOOLBAR = [
[{ font: Font.whitelist }], [{ font: Font.whitelist }],
[{ size: SIZE_WHITELIST }], [{ size: SIZE_WHITELIST }],
['bold', 'italic', 'underline', 'strike'], ["bold", "italic", "underline", "strike"],
[{ color: COLORS }, { background: COLORS }], [{ color: COLORS }, { background: COLORS }],
[{ list: 'ordered' }, { list: 'bullet' }], [{ list: "ordered" }, { list: "bullet" }],
[{ indent: '-1' }, { indent: '+1' }], [{ indent: "-1" }, { indent: "+1" }],
[{ align: [] }], [{ align: [] }],
['link'], ["link"],
['clean'] ["clean"],
] ];
const FORMATS = [ const FORMATS = [
'font', 'size', "font",
'bold', 'italic', 'underline', 'strike', "size",
'color', 'background', "bold",
'list', 'indent', 'align', "italic",
'link' "underline",
] "strike",
"color",
"background",
"list",
"indent",
"align",
"link",
];
interface RichEditorProps { interface RichEditorProps {
value: string value: string;
onChange: (value: string) => void onChange: (value: string) => void;
placeholder?: string placeholder?: string;
minHeight?: string minHeight?: string;
readOnly?: boolean readOnly?: boolean;
} }
export default function RichEditor({ export default function RichEditor({
value, value,
onChange, onChange,
placeholder = 'Obsah...', placeholder = "Obsah...",
minHeight = '120px', minHeight = "120px",
readOnly = false, readOnly = false,
}: RichEditorProps) { }: RichEditorProps) {
const quillRef = useRef<ReactQuill>(null) const quillRef = useRef<ReactQuill>(null);
const lastValueRef = useRef(value) const lastValueRef = useRef(value);
const modules = useMemo(() => ({ const modules = useMemo(
() => ({
toolbar: readOnly ? false : TOOLBAR, toolbar: readOnly ? false : TOOLBAR,
clipboard: { clipboard: {
matchVisual: false, matchVisual: false,
}, },
}), [readOnly]) }),
[readOnly],
);
const handleChange = useCallback((content: string, _delta: any, source: string) => { const handleChange = useCallback(
if (source !== 'user') return (content: string, _delta: any, source: string) => {
if (content === lastValueRef.current) return if (source !== "user") return;
lastValueRef.current = content if (content === lastValueRef.current) return;
onChange(content) lastValueRef.current = content;
}, [onChange]) onChange(content);
},
[onChange],
);
return ( return (
<div className="rich-editor" style={{ '--re-min-height': minHeight } as React.CSSProperties}> <div
className="rich-editor"
style={{ "--re-min-height": minHeight } as React.CSSProperties}
>
<ReactQuill <ReactQuill
ref={quillRef} ref={quillRef}
theme="snow" theme="snow"
value={value || ''} value={value || ""}
onChange={handleChange} onChange={handleChange}
modules={modules} modules={modules}
formats={FORMATS} formats={FORMATS}
@@ -104,5 +175,5 @@ export default function RichEditor({
readOnly={readOnly} readOnly={readOnly}
/> />
</div> </div>
) );
} }

View File

@@ -1,119 +1,123 @@
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from './AdminDatePicker' import AdminDatePicker from "./AdminDatePicker";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
import { calcFormWorkMinutes, calcProjectMinutesTotal, formatDate } from '../utils/attendanceHelpers' import {
calcFormWorkMinutes,
calcProjectMinutesTotal,
formatDate,
} from "../utils/attendanceHelpers";
let _logKeyCounter = 0 let _logKeyCounter = 0;
// ---------- Shared types ---------- // ---------- Shared types ----------
export interface ShiftFormData { export interface ShiftFormData {
user_id: string user_id: string;
shift_date: string shift_date: string;
leave_type: string leave_type: string;
leave_hours: number leave_hours: number;
arrival_date: string arrival_date: string;
arrival_time: string arrival_time: string;
break_start_date: string break_start_date: string;
break_start_time: string break_start_time: string;
break_end_date: string break_end_date: string;
break_end_time: string break_end_time: string;
departure_date: string departure_date: string;
departure_time: string departure_time: string;
notes: string notes: string;
} }
export interface ProjectLog { export interface ProjectLog {
_key?: string _key?: string;
id?: number id?: number;
project_id: string | number project_id: string | number;
hours: string | number hours: string | number;
minutes: string | number minutes: string | number;
} }
export interface Project { export interface Project {
id: number | string id: number | string;
project_number: string project_number: string;
name: string name: string;
} }
export interface User { export interface User {
id: number | string id: number | string;
name: string name: string;
} }
export interface EditingRecord { export interface EditingRecord {
user_name: string user_name: string;
shift_date: string shift_date: string;
} }
// ---------- Sub-component props ---------- // ---------- Sub-component props ----------
interface ProjectTimeStatusProps { interface ProjectTimeStatusProps {
form: ShiftFormData form: ShiftFormData;
projectLogs: ProjectLog[] projectLogs: ProjectLog[];
} }
interface ProjectLogRowProps { interface ProjectLogRowProps {
log: ProjectLog log: ProjectLog;
index: number index: number;
projectList: Project[] projectList: Project[];
onUpdate: (index: number, field: string, value: string) => void onUpdate: (index: number, field: string, value: string) => void;
onRemove: (index: number) => void onRemove: (index: number) => void;
} }
export interface ShiftFormModalProps { export interface ShiftFormModalProps {
mode: 'create' | 'edit' mode: "create" | "edit";
show: boolean show: boolean;
onClose: () => void onClose: () => void;
onSubmit: () => void onSubmit: () => void;
form: ShiftFormData form: ShiftFormData;
setForm: (form: ShiftFormData) => void setForm: (form: ShiftFormData) => void;
projectLogs: ProjectLog[] projectLogs: ProjectLog[];
setProjectLogs: (logs: ProjectLog[]) => void setProjectLogs: (logs: ProjectLog[]) => void;
projectList: Project[] projectList: Project[];
users: User[] users: User[];
onShiftDateChange: (value: string) => void onShiftDateChange: (value: string) => void;
editingRecord: EditingRecord | null editingRecord: EditingRecord | null;
} }
// ---------- ProjectTimeStatus ---------- // ---------- ProjectTimeStatus ----------
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) { function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const totalWork = calcFormWorkMinutes(form) const totalWork = calcFormWorkMinutes(form);
const totalProject = calcProjectMinutesTotal(projectLogs) const totalProject = calcProjectMinutesTotal(projectLogs);
const remaining = totalWork - totalProject const remaining = totalWork - totalProject;
const hasLogs = projectLogs.some((l) => l.project_id) const hasLogs = projectLogs.some((l) => l.project_id);
if (!hasLogs || totalWork <= 0) return null if (!hasLogs || totalWork <= 0) return null;
const isMatch = remaining === 0 const isMatch = remaining === 0;
return ( return (
<div <div
style={{ style={{
padding: '0.5rem 0.75rem', padding: "0.5rem 0.75rem",
marginBottom: '0.5rem', marginBottom: "0.5rem",
borderRadius: '6px', borderRadius: "6px",
fontSize: '0.8rem', fontSize: "0.8rem",
background: isMatch background: isMatch
? 'var(--success-bg, rgba(34,197,94,0.1))' ? "var(--success-bg, rgba(34,197,94,0.1))"
: 'var(--danger-bg, rgba(239,68,68,0.1))', : "var(--danger-bg, rgba(239,68,68,0.1))",
color: isMatch color: isMatch
? 'var(--success-color, #16a34a)' ? "var(--success-color, #16a34a)"
: 'var(--danger-color, #dc2626)', : "var(--danger-color, #dc2626)",
border: `1px solid ${ border: `1px solid ${
isMatch isMatch
? 'var(--success-border, rgba(34,197,94,0.3))' ? "var(--success-border, rgba(34,197,94,0.3))"
: 'var(--danger-border, rgba(239,68,68,0.3))' : "var(--danger-border, rgba(239,68,68,0.3))"
}`, }`,
}} }}
> >
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m | {Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{' '} {Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
{Math.abs(remaining) % 60}m {remaining < 0 ? '(překročeno)' : ''} {remaining < 0 ? "(překročeno)" : ""}
</div> </div>
) );
} }
// ---------- ProjectLogRow ---------- // ---------- ProjectLogRow ----------
@@ -129,7 +133,7 @@ function ProjectLogRow({
<div className="flex-row gap-2 mb-2"> <div className="flex-row gap-2 mb-2">
<select <select
value={log.project_id} value={log.project_id}
onChange={(e) => onUpdate(index, 'project_id', e.target.value)} onChange={(e) => onUpdate(index, "project_id", e.target.value)}
className="admin-form-select" className="admin-form-select"
style={{ flex: 3, marginBottom: 0 }} style={{ flex: 3, marginBottom: 0 }}
> >
@@ -145,12 +149,12 @@ function ProjectLogRow({
min="0" min="0"
max="24" max="24"
value={log.hours} value={log.hours}
onChange={(e) => onUpdate(index, 'hours', e.target.value)} onChange={(e) => onUpdate(index, "hours", e.target.value)}
className="admin-form-input" className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }} style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="h" placeholder="h"
/> />
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}> <span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
h h
</span> </span>
<input <input
@@ -158,19 +162,19 @@ function ProjectLogRow({
min="0" min="0"
max="59" max="59"
value={log.minutes} value={log.minutes}
onChange={(e) => onUpdate(index, 'minutes', e.target.value)} onChange={(e) => onUpdate(index, "minutes", e.target.value)}
className="admin-form-input" className="admin-form-input"
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }} style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="m" placeholder="m"
/> />
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}> <span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
m m
</span> </span>
<button <button
type="button" type="button"
onClick={() => onRemove(index)} onClick={() => onRemove(index)}
className="admin-btn admin-btn-secondary admin-btn-sm" className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ padding: '0.375rem', flexShrink: 0 }} style={{ padding: "0.375rem", flexShrink: 0 }}
title="Odebrat" title="Odebrat"
> >
<svg <svg
@@ -185,7 +189,7 @@ function ProjectLogRow({
</svg> </svg>
</button> </button>
</div> </div>
) );
} }
// ---------- ShiftFormModal ---------- // ---------- ShiftFormModal ----------
@@ -204,30 +208,35 @@ export default function ShiftFormModal({
onShiftDateChange, onShiftDateChange,
editingRecord, editingRecord,
}: ShiftFormModalProps) { }: ShiftFormModalProps) {
useModalLock(show) useModalLock(show);
const isCreate = mode === 'create' const isCreate = mode === "create";
const isWorkType = form.leave_type === 'work' const isWorkType = form.leave_type === "work";
const updateField = (field: keyof ShiftFormData, value: string | number) => { const updateField = (field: keyof ShiftFormData, value: string | number) => {
setForm({ ...form, [field]: value }) setForm({ ...form, [field]: value });
} };
const updateProjectLog = (index: number, field: string, value: string) => { const updateProjectLog = (index: number, field: string, value: string) => {
const updated = [...projectLogs] const updated = [...projectLogs];
updated[index] = { ...updated[index], [field]: value } updated[index] = { ...updated[index], [field]: value };
setProjectLogs(updated) setProjectLogs(updated);
} };
const removeProjectLog = (index: number) => { const removeProjectLog = (index: number) => {
setProjectLogs(projectLogs.filter((_, j) => j !== index)) setProjectLogs(projectLogs.filter((_, j) => j !== index));
} };
const addProjectLog = () => { const addProjectLog = () => {
setProjectLogs([ setProjectLogs([
...projectLogs, ...projectLogs,
{ _key: `log-${++_logKeyCounter}`, project_id: '', hours: '', minutes: '' }, {
]) _key: `log-${++_logKeyCounter}`,
} project_id: "",
hours: "",
minutes: "",
},
]);
};
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -249,16 +258,16 @@ export default function ShiftFormModal({
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2 className="admin-modal-title">
{isCreate ? 'Přidat záznam docházky' : 'Upravit docházku'} {isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2> </h2>
{!isCreate && editingRecord && ( {!isCreate && editingRecord && (
<p <p
style={{ style={{
color: 'var(--text-secondary)', color: "var(--text-secondary)",
marginTop: '0.25rem', marginTop: "0.25rem",
}} }}
> >
{editingRecord.user_name} {' '} {editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)} {formatDate(editingRecord.shift_date)}
</p> </p>
)} )}
@@ -274,9 +283,7 @@ export default function ShiftFormModal({
</label> </label>
<select <select
value={form.user_id} value={form.user_id}
onChange={(e) => onChange={(e) => updateField("user_id", e.target.value)}
updateField('user_id', e.target.value)
}
className="admin-form-select" className="admin-form-select"
> >
<option value="">Vyberte zaměstnance</option> <option value="">Vyberte zaměstnance</option>
@@ -304,7 +311,7 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.shift_date} value={form.shift_date}
onChange={(val) => updateField('shift_date', val)} onChange={(val) => updateField("shift_date", val)}
/> />
</div> </div>
)} )}
@@ -313,9 +320,7 @@ export default function ShiftFormModal({
<label className="admin-form-label">Typ záznamu</label> <label className="admin-form-label">Typ záznamu</label>
<select <select
value={form.leave_type} value={form.leave_type}
onChange={(e) => onChange={(e) => updateField("leave_type", e.target.value)}
updateField('leave_type', e.target.value)
}
className="admin-form-select" className="admin-form-select"
> >
<option value="work">Práce</option> <option value="work">Práce</option>
@@ -334,7 +339,7 @@ export default function ShiftFormModal({
inputMode="decimal" inputMode="decimal"
value={form.leave_hours} value={form.leave_hours}
onChange={(e) => onChange={(e) =>
updateField('leave_hours', parseFloat(e.target.value)) updateField("leave_hours", parseFloat(e.target.value))
} }
min="0.5" min="0.5"
max="24" max="24"
@@ -359,9 +364,7 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.arrival_date} value={form.arrival_date}
onChange={(val) => onChange={(val) => updateField("arrival_date", val)}
updateField('arrival_date', val)
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -371,9 +374,7 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.arrival_time} value={form.arrival_time}
onChange={(val) => onChange={(val) => updateField("arrival_time", val)}
updateField('arrival_time', val)
}
/> />
</div> </div>
</div> </div>
@@ -387,7 +388,7 @@ export default function ShiftFormModal({
mode="date" mode="date"
value={form.break_start_date} value={form.break_start_date}
onChange={(val) => onChange={(val) =>
updateField('break_start_date', val) updateField("break_start_date", val)
} }
/> />
</div> </div>
@@ -399,7 +400,7 @@ export default function ShiftFormModal({
mode="time" mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => onChange={(val) =>
updateField('break_start_time', val) updateField("break_start_time", val)
} }
/> />
</div> </div>
@@ -413,9 +414,7 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.break_end_date} value={form.break_end_date}
onChange={(val) => onChange={(val) => updateField("break_end_date", val)}
updateField('break_end_date', val)
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -425,9 +424,7 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_end_time} value={form.break_end_time}
onChange={(val) => onChange={(val) => updateField("break_end_time", val)}
updateField('break_end_time', val)
}
/> />
</div> </div>
</div> </div>
@@ -440,21 +437,15 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.departure_date} value={form.departure_date}
onChange={(val) => onChange={(val) => updateField("departure_date", val)}
updateField('departure_date', val)
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Odchod - čas</label>
Odchod - čas
</label>
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.departure_time} value={form.departure_time}
onChange={(val) => onChange={(val) => updateField("departure_time", val)}
updateField('departure_time', val)
}
/> />
</div> </div>
</div> </div>
@@ -489,7 +480,7 @@ export default function ShiftFormModal({
<label className="admin-form-label">Poznámka</label> <label className="admin-form-label">Poznámka</label>
<textarea <textarea
value={form.notes} value={form.notes}
onChange={(e) => updateField('notes', e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea" className="admin-form-textarea"
rows={3} rows={3}
/> />
@@ -517,5 +508,5 @@ export default function ShiftFormModal({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
) );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,192 +1,287 @@
import { useState } from 'react' import { useState } from "react";
import { Link } from 'react-router-dom' import { Link } from "react-router-dom";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from '../../context/AuthContext' import { useAuth } from "../../context/AuthContext";
import { useAlert } from '../../context/AlertContext' import { useAlert } from "../../context/AlertContext";
import { formatKm } from '../../utils/formatters' import { formatKm } from "../../utils/formatters";
import AdminDatePicker from '../AdminDatePicker' import AdminDatePicker from "../AdminDatePicker";
import apiFetch from '../../utils/api' import apiFetch from "../../utils/api";
import useModalLock from '../../hooks/useModalLock' import useModalLock from "../../hooks/useModalLock";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface Vehicle { interface Vehicle {
id: number | string id: number | string;
spz: string spz: string;
name: string name: string;
} }
interface TripForm { interface TripForm {
vehicle_id: string vehicle_id: string;
trip_date: string trip_date: string;
start_km: string start_km: string;
end_km: string end_km: string;
route_from: string route_from: string;
route_to: string route_to: string;
is_business: number is_business: number;
notes: string notes: string;
} }
interface TripErrors { interface TripErrors {
vehicle_id?: string vehicle_id?: string;
trip_date?: string trip_date?: string;
start_km?: string start_km?: string;
end_km?: string end_km?: string;
route_from?: string route_from?: string;
route_to?: string route_to?: string;
} }
interface DashQuickActionsProps { interface DashQuickActionsProps {
dashData: { dashData: {
my_shift?: { my_shift?: {
has_ongoing: boolean has_ongoing: boolean;
} };
} | null } | null;
punching: boolean punching: boolean;
onPunch: () => void onPunch: () => void;
} }
export default function DashQuickActions({ dashData, punching, onPunch }: DashQuickActionsProps) { export default function DashQuickActions({
const { hasPermission } = useAuth() dashData,
const alert = useAlert() punching,
onPunch,
}: DashQuickActionsProps) {
const { hasPermission } = useAuth();
const alert = useAlert();
const [showTripModal, setShowTripModal] = useState(false) const [showTripModal, setShowTripModal] = useState(false);
const [tripSubmitting, setTripSubmitting] = useState(false) const [tripSubmitting, setTripSubmitting] = useState(false);
const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([]) const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([]);
const [tripForm, setTripForm] = useState<TripForm>({ const [tripForm, setTripForm] = useState<TripForm>({
vehicle_id: '', trip_date: '', start_km: '', end_km: '', vehicle_id: "",
route_from: '', route_to: '', is_business: 1, notes: '' trip_date: "",
}) start_km: "",
const [tripErrors, setTripErrors] = useState<TripErrors>({}) end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: "",
});
const [tripErrors, setTripErrors] = useState<TripErrors>({});
useModalLock(showTripModal) useModalLock(showTripModal);
const openTripModal = async () => { const openTripModal = async () => {
setTripForm({ setTripForm({
vehicle_id: '', trip_date: new Date().toISOString().split('T')[0], vehicle_id: "",
start_km: '', end_km: '', route_from: '', route_to: '', trip_date: new Date().toISOString().split("T")[0],
is_business: 1, notes: '' start_km: "",
}) end_km: "",
setTripErrors({}) route_from: "",
setShowTripModal(true) route_to: "",
is_business: 1,
notes: "",
});
setTripErrors({});
setShowTripModal(true);
try { try {
const response = await apiFetch(`${API_BASE}/vehicles`) const response = await apiFetch(`${API_BASE}/vehicles`);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setTripVehicles(Array.isArray(result.data) ? result.data : result.data?.vehicles || []) setTripVehicles(
Array.isArray(result.data)
? result.data
: result.data?.vehicles || [],
);
} }
} catch { } catch {
// vozidla se nenacetla // vozidla se nenacetla
} }
} };
const handleTripVehicleChange = async (vehicleId: string) => { const handleTripVehicleChange = async (vehicleId: string) => {
setTripForm(prev => ({ ...prev, vehicle_id: vehicleId })) setTripForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
if (!vehicleId) { if (!vehicleId) {
return return;
} }
try { try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`) const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setTripForm(prev => ({ ...prev, start_km: result.data.last_km })) setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
} }
} catch { } catch {
// last_km se nenacetlo // last_km se nenacetlo
} }
} };
const handleTripSubmit = async () => { const handleTripSubmit = async () => {
const errs: TripErrors = {} const errs: TripErrors = {};
if (!tripForm.vehicle_id) { if (!tripForm.vehicle_id) {
errs.vehicle_id = 'Vyberte vozidlo' errs.vehicle_id = "Vyberte vozidlo";
} }
if (!tripForm.trip_date) { if (!tripForm.trip_date) {
errs.trip_date = 'Zadejte datum' errs.trip_date = "Zadejte datum";
} }
if (!tripForm.start_km) { if (!tripForm.start_km) {
errs.start_km = 'Zadejte počáteční km' errs.start_km = "Zadejte počáteční km";
} }
if (!tripForm.end_km) { if (!tripForm.end_km) {
errs.end_km = 'Zadejte konečný km' errs.end_km = "Zadejte konečný km";
} }
if (tripForm.start_km && tripForm.end_km && parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)) { if (
errs.end_km = 'Musí být větší než počáteční' 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) { if (!tripForm.route_from) {
errs.route_from = 'Zadejte místo odjezdu' errs.route_from = "Zadejte místo odjezdu";
} }
if (!tripForm.route_to) { if (!tripForm.route_to) {
errs.route_to = 'Zadejte místo příjezdu' errs.route_to = "Zadejte místo příjezdu";
} }
setTripErrors(errs) setTripErrors(errs);
if (Object.keys(errs).length > 0) { if (Object.keys(errs).length > 0) {
return return;
} }
setTripSubmitting(true) setTripSubmitting(true);
try { try {
const response = await apiFetch(`${API_BASE}/trips`, { const response = await apiFetch(`${API_BASE}/trips`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(tripForm) body: JSON.stringify(tripForm),
}) });
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setShowTripModal(false) setShowTripModal(false);
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setTripSubmitting(false) setTripSubmitting(false);
}
} }
};
const tripDistance = (): number => { const tripDistance = (): number => {
const s = parseInt(tripForm.start_km) || 0 const s = parseInt(tripForm.start_km) || 0;
const e = parseInt(tripForm.end_km) || 0 const e = parseInt(tripForm.end_km) || 0;
return e > s ? e - s : 0 return e > s ? e - s : 0;
} };
const hasOngoingShift = dashData?.my_shift?.has_ongoing const hasOngoingShift = dashData?.my_shift?.has_ongoing;
const punchLabel = hasOngoingShift ? 'Zaznamenat odchod' : 'Zaznamenat příchod' const punchLabel = hasOngoingShift
? "Zaznamenat odchod"
: "Zaznamenat příchod";
const quickActions: Array<{ const quickActions: Array<{
label: string label: string;
color: string color: string;
icon: React.ReactNode icon: React.ReactNode;
onClick?: () => void onClick?: () => void;
path?: string path?: string;
disabled?: boolean disabled?: boolean;
}> = [] }> = [];
if (hasPermission('attendance.record')) { if (hasPermission("attendance.record")) {
quickActions.push({ quickActions.push({
label: punching ? 'Odesílám...' : punchLabel, label: punching ? "Odesílám..." : punchLabel,
color: hasOngoingShift ? 'danger' : 'success', color: hasOngoingShift ? "danger" : "success",
icon: hasOngoingShift 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
: <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>, 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, onClick: onPunch,
disabled: punching, disabled: punching,
}) });
} }
if (hasPermission('offers.create')) { 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({ quickActions.push({
label: 'Přidat jízdu', label: "Nová nabídka",
color: 'warning', path: "/offers/new",
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>, color: "info",
onClick: openTripModal, 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('invoices.create')) { if (hasPermission("trips.record")) {
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> }) 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 ( return (
@@ -197,7 +292,8 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }} transition={{ duration: 0.25, delay: 0.08 }}
> >
{quickActions.map((action) => action.onClick ? ( {quickActions.map((action) =>
action.onClick ? (
<button <button
key={action.label} key={action.label}
onClick={action.onClick} onClick={action.onClick}
@@ -208,11 +304,16 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
<span>{action.label}</span> <span>{action.label}</span>
</button> </button>
) : ( ) : (
<Link key={action.label} to={action.path!} className={`dash-quick-btn dash-quick-btn-${action.color}`}> <Link
key={action.label}
to={action.path!}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
>
{action.icon} {action.icon}
<span>{action.label}</span> <span>{action.label}</span>
</Link> </Link>
))} ),
)}
</motion.div> </motion.div>
<AnimatePresence> <AnimatePresence>
@@ -224,7 +325,10 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-backdrop" onClick={() => setShowTripModal(false)} /> <div
className="admin-modal-backdrop"
onClick={() => setShowTripModal(false)}
/>
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -238,102 +342,188 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
<div className="admin-modal-body"> <div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
<div className={`admin-form-group${tripErrors.vehicle_id ? ' has-error' : ''}`}> <div
<label className="admin-form-label required">Vozidlo</label> className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Vozidlo
</label>
<select <select
value={tripForm.vehicle_id} value={tripForm.vehicle_id}
onChange={(e) => { onChange={(e) => {
handleTripVehicleChange(e.target.value) handleTripVehicleChange(e.target.value);
setTripErrors(prev => ({ ...prev, vehicle_id: undefined })) setTripErrors((prev) => ({
...prev,
vehicle_id: undefined,
}));
}} }}
className="admin-form-select" className="admin-form-select"
> >
<option value="">Vyberte vozidlo</option> <option value="">Vyberte vozidlo</option>
{tripVehicles.map((v) => ( {tripVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.spz} - {v.name}</option> <option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))} ))}
</select> </select>
{tripErrors.vehicle_id && <span className="admin-form-error">{tripErrors.vehicle_id}</span>} {tripErrors.vehicle_id && (
<span className="admin-form-error">
{tripErrors.vehicle_id}
</span>
)}
</div> </div>
<div className={`admin-form-group${tripErrors.trip_date ? ' has-error' : ''}`}> <div
<label className="admin-form-label required">Datum jízdy</label> className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Datum jízdy
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={tripForm.trip_date} value={tripForm.trip_date}
onChange={(val: string) => { onChange={(val: string) => {
setTripForm(prev => ({ ...prev, trip_date: val })) setTripForm((prev) => ({ ...prev, trip_date: val }));
setTripErrors(prev => ({ ...prev, trip_date: undefined })) setTripErrors((prev) => ({
...prev,
trip_date: undefined,
}));
}} }}
/> />
{tripErrors.trip_date && <span className="admin-form-error">{tripErrors.trip_date}</span>} {tripErrors.trip_date && (
<span className="admin-form-error">
{tripErrors.trip_date}
</span>
)}
</div> </div>
</div> </div>
<div className="admin-form-row admin-form-row-3"> <div className="admin-form-row admin-form-row-3">
<div className={`admin-form-group${tripErrors.start_km ? ' has-error' : ''}`}> <div
<label className="admin-form-label required">Počáteční stav km</label> className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Počáteční stav km
</label>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={tripForm.start_km} value={tripForm.start_km}
onChange={(e) => { onChange={(e) => {
setTripForm(prev => ({ ...prev, start_km: e.target.value })) setTripForm((prev) => ({
setTripErrors(prev => ({ ...prev, start_km: undefined })) ...prev,
start_km: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
start_km: undefined,
}));
}} }}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
/> />
{tripErrors.start_km && <span className="admin-form-error">{tripErrors.start_km}</span>} {tripErrors.start_km && (
<span className="admin-form-error">
{tripErrors.start_km}
</span>
)}
</div> </div>
<div className={`admin-form-group${tripErrors.end_km ? ' has-error' : ''}`}> <div
<label className="admin-form-label required">Konečný stav km</label> className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Konečný stav km
</label>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={tripForm.end_km} value={tripForm.end_km}
onChange={(e) => { onChange={(e) => {
setTripForm(prev => ({ ...prev, end_km: e.target.value })) setTripForm((prev) => ({
setTripErrors(prev => ({ ...prev, end_km: undefined })) ...prev,
end_km: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
end_km: undefined,
}));
}} }}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
/> />
{tripErrors.end_km && <span className="admin-form-error">{tripErrors.end_km}</span>} {tripErrors.end_km && (
<span className="admin-form-error">
{tripErrors.end_km}
</span>
)}
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label> <label className="admin-form-label">Vzdálenost</label>
<input type="text" value={`${formatKm(tripDistance())} km`} className="admin-form-input" readOnly disabled /> <input
type="text"
value={`${formatKm(tripDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</div> </div>
</div> </div>
<div className="admin-form-row"> <div className="admin-form-row">
<div className={`admin-form-group${tripErrors.route_from ? ' has-error' : ''}`}> <div
<label className="admin-form-label required">Místo odjezdu</label> className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Místo odjezdu
</label>
<input <input
type="text" type="text"
value={tripForm.route_from} value={tripForm.route_from}
onChange={(e) => { onChange={(e) => {
setTripForm(prev => ({ ...prev, route_from: e.target.value })) setTripForm((prev) => ({
setTripErrors(prev => ({ ...prev, route_from: undefined })) ...prev,
route_from: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
route_from: undefined,
}));
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="Např. Praha" placeholder="Např. Praha"
/> />
{tripErrors.route_from && <span className="admin-form-error">{tripErrors.route_from}</span>} {tripErrors.route_from && (
<span className="admin-form-error">
{tripErrors.route_from}
</span>
)}
</div> </div>
<div className={`admin-form-group${tripErrors.route_to ? ' has-error' : ''}`}> <div
<label className="admin-form-label required">Místo příjezdu</label> className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Místo příjezdu
</label>
<input <input
type="text" type="text"
value={tripForm.route_to} value={tripForm.route_to}
onChange={(e) => { onChange={(e) => {
setTripForm(prev => ({ ...prev, route_to: e.target.value })) setTripForm((prev) => ({
setTripErrors(prev => ({ ...prev, route_to: undefined })) ...prev,
route_to: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
route_to: undefined,
}));
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="Např. Brno" placeholder="Např. Brno"
/> />
{tripErrors.route_to && <span className="admin-form-error">{tripErrors.route_to}</span>} {tripErrors.route_to && (
<span className="admin-form-error">
{tripErrors.route_to}
</span>
)}
</div> </div>
</div> </div>
@@ -341,7 +531,12 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
<label className="admin-form-label">Typ jízdy</label> <label className="admin-form-label">Typ jízdy</label>
<select <select
value={tripForm.is_business} value={tripForm.is_business}
onChange={(e) => setTripForm(prev => ({ ...prev, is_business: parseInt(e.target.value) }))} onChange={(e) =>
setTripForm((prev) => ({
...prev,
is_business: parseInt(e.target.value),
}))
}
className="admin-form-select" className="admin-form-select"
> >
<option value={1}>Služební</option> <option value={1}>Služební</option>
@@ -353,7 +548,12 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
<label className="admin-form-label">Poznámky</label> <label className="admin-form-label">Poznámky</label>
<textarea <textarea
value={tripForm.notes} value={tripForm.notes}
onChange={(e) => setTripForm(prev => ({ ...prev, notes: e.target.value }))} onChange={(e) =>
setTripForm((prev) => ({
...prev,
notes: e.target.value,
}))
}
className="admin-form-textarea" className="admin-form-textarea"
rows={2} rows={2}
placeholder="Volitelné poznámky..." placeholder="Volitelné poznámky..."
@@ -362,11 +562,21 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
</div> </div>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">
<button type="button" onClick={() => setShowTripModal(false)} className="admin-btn admin-btn-secondary" disabled={tripSubmitting}> <button
type="button"
onClick={() => setShowTripModal(false)}
className="admin-btn admin-btn-secondary"
disabled={tripSubmitting}
>
Zrušit Zrušit
</button> </button>
<button type="button" onClick={handleTripSubmit} className="admin-btn admin-btn-primary" disabled={tripSubmitting}> <button
{tripSubmitting ? 'Ukládám...' : 'Uložit'} type="button"
onClick={handleTripSubmit}
className="admin-btn admin-btn-primary"
disabled={tripSubmitting}
>
{tripSubmitting ? "Ukládám..." : "Uložit"}
</button> </button>
</div> </div>
</motion.div> </motion.div>
@@ -374,5 +584,5 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
)} )}
</AnimatePresence> </AnimatePresence>
</> </>
) );
} }

View File

@@ -1,126 +1,159 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { motion } from 'framer-motion' import { motion } from "framer-motion";
import { useAlert } from '../../context/AlertContext' import { useAlert } from "../../context/AlertContext";
import ConfirmModal from '../ConfirmModal' import ConfirmModal from "../ConfirmModal";
import useModalLock from '../../hooks/useModalLock' import useModalLock from "../../hooks/useModalLock";
import apiFetch from '../../utils/api' import apiFetch from "../../utils/api";
import { formatSessionDate } from '../../utils/dashboardHelpers' import { formatSessionDate } from "../../utils/dashboardHelpers";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface DeviceInfo { interface DeviceInfo {
icon?: string icon?: string;
browser?: string browser?: string;
os?: string os?: string;
} }
interface Session { interface Session {
id: number | string id: number | string;
is_current: boolean is_current: boolean;
device_info?: DeviceInfo device_info?: DeviceInfo;
ip_address: string ip_address: string;
created_at: string created_at: string;
} }
interface DeleteModalState { interface DeleteModalState {
isOpen: boolean isOpen: boolean;
session: Session | null session: Session | null;
} }
function getDeviceIcon(iconType?: string) { function getDeviceIcon(iconType?: string) {
switch (iconType) { switch (iconType) {
case 'smartphone': case "smartphone":
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" /> 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> </svg>
) );
case 'tablet': case "tablet":
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" /> 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> </svg>
) );
default: default:
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <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" /> <line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg> </svg>
) );
} }
} }
export default function DashSessions() { export default function DashSessions() {
const alert = useAlert() const alert = useAlert();
const [sessions, setSessions] = useState<Session[]>([]) const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true) const [sessionsLoading, setSessionsLoading] = useState(true);
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({ isOpen: false, session: null }) const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
const [deleteAllModal, setDeleteAllModal] = useState(false) isOpen: false,
const [deleting, setDeleting] = useState(false) session: null,
});
const [deleteAllModal, setDeleteAllModal] = useState(false);
const [deleting, setDeleting] = useState(false);
useModalLock(deleteAllModal) useModalLock(deleteAllModal);
const fetchSessions = useCallback(async () => { const fetchSessions = useCallback(async () => {
try { try {
const response = await apiFetch(`${API_BASE}/sessions`) const response = await apiFetch(`${API_BASE}/sessions`);
const data = await response.json() const data = await response.json();
if (data.success) { if (data.success) {
setSessions(Array.isArray(data.data) ? data.data : data.data?.sessions || []) setSessions(
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
);
} }
} catch { } catch {
// session fetch failed silently // session fetch failed silently
} finally { } finally {
setSessionsLoading(false) setSessionsLoading(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchSessions() fetchSessions();
}, [fetchSessions]) }, [fetchSessions]);
const handleDeleteSession = async () => { const handleDeleteSession = async () => {
if (!deleteModal.session) { if (!deleteModal.session) {
return return;
} }
const sessionId = deleteModal.session.id const sessionId = deleteModal.session.id;
setDeleting(true) setDeleting(true);
try { try {
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, { method: 'DELETE' }) const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, {
const data = await response.json() method: "DELETE",
});
const data = await response.json();
if (data.success) { if (data.success) {
setDeleteModal({ isOpen: false, session: null }) setDeleteModal({ isOpen: false, session: null });
setSessions(prev => prev.filter(s => s.id !== sessionId)) setSessions((prev) => prev.filter((s) => s.id !== sessionId));
alert.success('Relace byla ukončena') alert.success("Relace byla ukončena");
} else { } else {
alert.error(data.error || 'Nepodařilo se ukončit relaci') alert.error(data.error || "Nepodařilo se ukončit relaci");
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setDeleting(false) setDeleting(false);
}
} }
};
const handleDeleteAllSessions = async () => { const handleDeleteAllSessions = async () => {
setDeleting(true) setDeleting(true);
try { try {
const response = await apiFetch(`${API_BASE}/sessions?action=all`, { method: 'DELETE' }) const response = await apiFetch(`${API_BASE}/sessions?action=all`, {
const data = await response.json() method: "DELETE",
});
const data = await response.json();
if (data.success) { if (data.success) {
setDeleteAllModal(false) setDeleteAllModal(false);
setSessions(prev => prev.filter(s => s.is_current)) setSessions((prev) => prev.filter((s) => s.is_current));
alert.success(data.message || 'Ostatní relace byly ukončeny') alert.success(data.message || "Ostatní relace byly ukončeny");
} else { } else {
alert.error(data.error || 'Nepodařilo se ukončit relace') alert.error(data.error || "Nepodařilo se ukončit relace");
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setDeleting(false) setDeleting(false);
}
} }
};
return ( return (
<> <>
@@ -130,43 +163,81 @@ export default function DashSessions() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
> >
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}> <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> <h2 className="admin-card-title">Přihlášená zařízení</h2>
{sessions.filter(s => !s.is_current).length > 0 && ( {sessions.filter((s) => !s.is_current).length > 0 && (
<button onClick={() => setDeleteAllModal(true)} className="admin-btn admin-btn-secondary admin-btn-sm"> <button
onClick={() => setDeleteAllModal(true)}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Odhlásit ostatní Odhlásit ostatní
</button> </button>
)} )}
</div> </div>
<div className="admin-card-body" style={{ padding: 0 }}> <div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && ( {sessionsLoading && (
<div className="admin-skeleton" style={{ padding: '1rem', gap: '1rem' }}> <div
{[0, 1, 2].map(i => ( className="admin-skeleton"
style={{ padding: "1rem", gap: "1rem" }}
>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row"> <div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" /> <div className="admin-skeleton-line circle" />
<div className="flex-1"> <div className="flex-1">
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} /> <div
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} /> 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> </div>
))} ))}
</div> </div>
)} )}
{!sessionsLoading && sessions.length === 0 && ( {!sessionsLoading && sessions.length === 0 && (
<div className="text-secondary" style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.875rem' }}> <div
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
>
Žádné aktivní relace Žádné aktivní relace
</div> </div>
)} )}
{!sessionsLoading && sessions.length > 0 && ( {!sessionsLoading && sessions.length > 0 && (
<div className="sessions-list"> <div className="sessions-list">
{sessions.map((session) => ( {sessions.map((session) => (
<div key={session.id} className={`session-item ${session.is_current ? 'session-item-current' : ''}`}> <div
<div className="session-icon">{getDeviceIcon(session.device_info?.icon)}</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-info">
<div className="session-device"> <div className="session-device">
{session.device_info?.browser} na {session.device_info?.os} {session.device_info?.browser} na{" "}
{session.device_info?.os}
{session.is_current && ( {session.is_current && (
<span className="admin-badge admin-badge-success" style={{ marginLeft: '0.5rem' }}>Aktuální</span> <span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
)} )}
</div> </div>
<div className="session-meta"> <div className="session-meta">
@@ -177,9 +248,25 @@ export default function DashSessions() {
</div> </div>
<div className="session-actions"> <div className="session-actions">
{!session.is_current && ( {!session.is_current && (
<button onClick={() => setDeleteModal({ isOpen: true, session })} className="admin-btn-icon danger" title="Ukončit relaci" aria-label="Ukončit relaci"> <button
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> onClick={() =>
<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" /> 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> </svg>
</button> </button>
)} )}
@@ -214,5 +301,5 @@ export default function DashSessions() {
loading={deleting} loading={deleting}
/> />
</> </>
) );
} }

View File

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

View File

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

View File

@@ -1,45 +1,48 @@
import { useCallback, useRef } from 'react' import { useCallback, useRef } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
interface ApiCallResult<T> { interface ApiCallResult<T> {
data: T | null data: T | null;
ok: boolean ok: boolean;
response: Response | null response: Response | null;
} }
export default function useApiCall() { export default function useApiCall() {
const alert = useAlert() const alert = useAlert();
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null);
const call = useCallback(async <T = unknown>( const call = useCallback(
async <T = unknown>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
errorMsg = 'Chyba při načítání dat' errorMsg = "Chyba při načítání dat",
): Promise<ApiCallResult<T>> => { ): Promise<ApiCallResult<T>> => {
if (abortRef.current) abortRef.current.abort() if (abortRef.current) abortRef.current.abort();
const controller = new AbortController() const controller = new AbortController();
abortRef.current = controller abortRef.current = controller;
try { try {
const response = await apiFetch(url, { const response = await apiFetch(url, {
...options, ...options,
signal: controller.signal, signal: controller.signal,
}) });
const data = await response.json() const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
alert.error(data.error || errorMsg) alert.error(data.error || errorMsg);
return { data: null, ok: false, response } return { data: null, ok: false, response };
} }
return { data: data.data as T, ok: true, response } return { data: data.data as T, ok: true, response };
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') { if (err instanceof Error && err.name === "AbortError") {
return { data: null, ok: false, response: null } return { data: null, ok: false, response: null };
} }
alert.error(errorMsg) alert.error(errorMsg);
return { data: null, ok: false, response: null } return { data: null, ok: false, response: null };
} }
}, [alert]) },
[alert],
);
return { call } return { call };
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,90 +1,126 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
import useDebounce from './useDebounce' import useDebounce from "./useDebounce";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface PaginationData { interface PaginationData {
total: number total: number;
page: number page: number;
per_page: number per_page: number;
total_pages: number total_pages: number;
} }
interface UseListDataOptions { interface UseListDataOptions {
dataKey?: string dataKey?: string;
search?: string search?: string;
sort?: string sort?: string;
order?: string order?: string;
page?: number page?: number;
perPage?: number perPage?: number;
extraParams?: Record<string, string> extraParams?: Record<string, string>;
errorMsg?: string errorMsg?: string;
} }
export default function useListData<T = unknown>( export default function useListData<T = unknown>(
endpoint: string, endpoint: string,
options: UseListDataOptions = {} options: UseListDataOptions = {},
) { ) {
const { dataKey, search = '', sort, order, page = 1, perPage = 25, extraParams = {}, errorMsg = 'Nepodařilo se načíst data' } = options const {
const alert = useAlert() dataKey,
const [items, setItems] = useState<T[]>([]) search = "",
const [loading, setLoading] = useState(true) sort,
const [initialLoad, setInitialLoad] = useState(true) order,
const [pagination, setPagination] = useState<PaginationData | null>(null) page = 1,
const abortRef = useRef<AbortController | null>(null) perPage = 25,
const debouncedSearch = useDebounce(search, 300) 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 () => { const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort() if (abortRef.current) abortRef.current.abort();
const controller = new AbortController() const controller = new AbortController();
abortRef.current = controller abortRef.current = controller;
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(page), page: String(page),
per_page: String(perPage), per_page: String(perPage),
}) });
if (debouncedSearch) params.set('search', debouncedSearch) if (debouncedSearch) params.set("search", debouncedSearch);
if (sort) params.set('sort', sort) if (sort) params.set("sort", sort);
if (order) params.set('order', order) if (order) params.set("order", order);
Object.entries(extraParams).forEach(([k, v]) => { Object.entries(extraParams).forEach(([k, v]) => {
if (v) params.set(k, v) if (v) params.set(k, v);
}) });
const url = endpoint.startsWith('/') ? `${endpoint}?${params}` : `${API_BASE}/${endpoint}?${params}` const url = endpoint.startsWith("/")
const response = await apiFetch(url, { signal: controller.signal }) ? `${endpoint}?${params}`
if (response.status === 401) return : `${API_BASE}/${endpoint}?${params}`;
const result = await response.json() const response = await apiFetch(url, { signal: controller.signal });
if (response.status === 401) return;
const result = await response.json();
if (result.success) { if (result.success) {
const data = dataKey ? result.data[dataKey] : (Array.isArray(result.data) ? result.data : result.data?.items || []) const data = dataKey
setItems(data || []) ? result.data[dataKey]
const pag = result.pagination || (!Array.isArray(result.data) && result.data?.pagination) || null : Array.isArray(result.data)
setPagination(pag || { ? 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, total: data?.length ?? 0,
page, page,
per_page: perPage, per_page: perPage,
total_pages: 1, total_pages: 1,
}) },
);
} else { } else {
alert.error(result.error || errorMsg) alert.error(result.error || errorMsg);
} }
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return if (err instanceof Error && err.name === "AbortError") return;
alert.error(errorMsg) alert.error(errorMsg);
} finally { } finally {
setLoading(false) setLoading(false);
setInitialLoad(false) setInitialLoad(false);
} }
}, [endpoint, debouncedSearch, sort, order, page, perPage, dataKey, JSON.stringify(extraParams)]) // eslint-disable-line react-hooks/exhaustive-deps }, [
endpoint,
debouncedSearch,
sort,
order,
page,
perPage,
dataKey,
JSON.stringify(extraParams),
]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
fetchData() fetchData();
return () => { return () => {
if (abortRef.current) abortRef.current.abort() if (abortRef.current) abortRef.current.abort();
} };
}, [fetchData]) }, [fetchData]);
return { items, setItems, loading, initialLoad, pagination, refetch: fetchData } return {
items,
setItems,
loading,
initialLoad,
pagination,
refetch: fetchData,
};
} }

View File

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

View File

@@ -1,25 +1,31 @@
import { useState, useCallback, useRef } from 'react' import { useState, useCallback, useRef } from "react";
interface SortState { interface SortState {
sort: string sort: string;
order: 'asc' | 'desc' order: "asc" | "desc";
} }
export default function useTableSort(defaultSort = 'id', defaultOrder: 'asc' | 'desc' = 'desc') { export default function useTableSort(
const [state, setState] = useState<SortState>({ sort: defaultSort, order: defaultOrder }) defaultSort = "id",
const userClicked = useRef(false) defaultOrder: "asc" | "desc" = "desc",
) {
const [state, setState] = useState<SortState>({
sort: defaultSort,
order: defaultOrder,
});
const userClicked = useRef(false);
const handleSort = useCallback((column: string) => { const handleSort = useCallback((column: string) => {
userClicked.current = true userClicked.current = true;
setState(prev => { setState((prev) => {
if (prev.sort === column) { if (prev.sort === column) {
return { sort: column, order: prev.order === 'asc' ? 'desc' : 'asc' } return { sort: column, order: prev.order === "asc" ? "desc" : "asc" };
} }
return { sort: column, order: 'desc' } return { sort: column, order: "desc" };
}) });
}, []) }, []);
const activeSort = userClicked.current ? state.sort : null const activeSort = userClicked.current ? state.sort : null;
return { sort: state.sort, order: state.order, handleSort, activeSort } return { sort: state.sort, order: state.order, handleSort, activeSort };
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +1,169 @@
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { motion } from 'framer-motion' import { motion } from "framer-motion";
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from "../components/ConfirmModal";
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from "../components/AdminDatePicker";
import BulkAttendanceModal from '../components/BulkAttendanceModal' import BulkAttendanceModal from "../components/BulkAttendanceModal";
import ShiftFormModal from '../components/ShiftFormModal' import ShiftFormModal from "../components/ShiftFormModal";
import AttendanceShiftTable from '../components/AttendanceShiftTable' import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from '../hooks/useAttendanceAdmin' import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from '../components/FormField' import FormField from "../components/FormField";
import { formatMinutes } from '../utils/attendanceHelpers' import { formatMinutes } from "../utils/attendanceHelpers";
interface UserTotalData { interface UserTotalData {
name: string name: string;
minutes: number minutes: number;
working: boolean working: boolean;
vacation_hours: number vacation_hours: number;
sick_hours: number sick_hours: number;
holiday_hours: number holiday_hours: number;
unpaid_hours: number unpaid_hours: number;
fund: number | null fund: number | null;
worked_hours: number worked_hours: number;
covered: number covered: number;
missing: number missing: number;
overtime: number overtime: number;
} }
function getFundBarBackground(data: UserTotalData) { function getFundBarBackground(data: UserTotalData) {
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)' if (data.overtime > 0)
if (data.covered >= (data.fund ?? 0)) return 'linear-gradient(135deg, var(--success), #059669)' return "linear-gradient(135deg, var(--warning), #d97706)";
return 'var(--gradient)' if (data.covered >= (data.fund ?? 0))
return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
} }
export default function AttendanceAdmin() { export default function AttendanceAdmin() {
const alert = useAlert() const alert = useAlert();
const { hasPermission } = useAuth() const { hasPermission } = useAuth();
const { const {
loading, month, setMonth, loading,
filterUserId, setFilterUserId, month,
data, hasData, setMonth,
showBulkModal, setShowBulkModal, filterUserId,
bulkSubmitting, bulkForm, setBulkForm, setFilterUserId,
showCreateModal, setShowCreateModal, data,
createForm, setCreateForm, hasData,
showEditModal, setShowEditModal, showBulkModal,
editingRecord, editForm, setEditForm, setShowBulkModal,
deleteConfirm, setDeleteConfirm, bulkSubmitting,
bulkForm,
setBulkForm,
showCreateModal,
setShowCreateModal,
createForm,
setCreateForm,
showEditModal,
setShowEditModal,
editingRecord,
editForm,
setEditForm,
deleteConfirm,
setDeleteConfirm,
projectList, projectList,
createProjectLogs, setCreateProjectLogs, createProjectLogs,
editProjectLogs, setEditProjectLogs, setCreateProjectLogs,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit, editProjectLogs,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit, setEditProjectLogs,
openEditModal, handleEditSubmit, openCreateModal,
handleDelete, handlePrint handleCreateShiftDateChange,
} = useAttendanceAdmin({ alert }) handleCreateSubmit,
openBulkModal,
toggleBulkUser,
toggleAllBulkUsers,
handleBulkSubmit,
openEditModal,
handleEditSubmit,
handleDelete,
handlePrint,
} = useAttendanceAdmin({ alert });
useModalLock(showBulkModal) useModalLock(showBulkModal);
useModalLock(showEditModal) useModalLock(showEditModal);
useModalLock(showCreateModal) useModalLock(showCreateModal);
if (!hasPermission('attendance.admin')) return <Forbidden /> if (!hasPermission("attendance.admin")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes // Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad = loading && data.records.length === 0 && Object.keys(data.user_totals).length === 0 const isInitialLoad =
loading &&
data.records.length === 0 &&
Object.keys(data.user_totals).length === 0;
if (isInitialLoad) { if (isInitialLoad) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}> <div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div> <div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} /> <div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
</div> </div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}> <div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} /> <div
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} /> className="admin-skeleton-line h-10"
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} /> 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> </div>
<div className="admin-card"> <div className="admin-card">
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}> <div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div className="admin-skeleton-row"> <div className="admin-skeleton-row">
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} /> <div
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} /> 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>
</div> </div>
<div className="admin-grid admin-grid-3"> <div className="admin-grid admin-grid-3">
{[0, 1, 2].map(i => ( {[0, 1, 2].map((i) => (
<div key={i} className="admin-card"> <div key={i} className="admin-card">
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-skeleton" style={{ gap: '0.75rem' }}> <div className="admin-skeleton" style={{ gap: "0.75rem" }}>
<div className="admin-skeleton-line w-1/2" /> <div className="admin-skeleton-line w-1/2" />
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} /> <div
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} /> className="admin-skeleton-line h-8"
<div className="admin-skeleton-line w-full" style={{ height: '4px' }} /> 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> </div>
))} ))}
</div> </div>
<div className="admin-card"> <div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <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/3" /> <div className="admin-skeleton-line w-1/3" />
@@ -113,7 +173,7 @@ export default function AttendanceAdmin() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -134,7 +194,15 @@ export default function AttendanceAdmin() {
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary"
title="Tisk docházky" title="Tisk docházky"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}> <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" /> <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" /> <path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" /> <rect x="6" y="14" width="12" height="8" />
@@ -152,7 +220,14 @@ export default function AttendanceAdmin() {
onClick={openCreateModal} onClick={openCreateModal}
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -185,7 +260,9 @@ export default function AttendanceAdmin() {
> >
<option value="">Všichni</option> <option value="">Všichni</option>
{data.users.map((user) => ( {data.users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option> <option key={user.id} value={user.id}>
{user.name}
</option>
))} ))}
</select> </select>
</FormField> </FormField>
@@ -202,68 +279,110 @@ export default function AttendanceAdmin() {
transition={{ duration: 0.25, delay: 0.09 }} transition={{ duration: 0.25, delay: 0.09 }}
> >
{Object.entries(data.user_totals).map(([uid, userData]) => { {Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData const ut = userData as UserTotalData;
return ( return (
<div key={uid} className="admin-card"> <div key={uid} className="admin-card">
<div className="admin-card-body"> <div className="admin-card-body">
<div className="flex-row gap-2 mb-2"> <div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span> <span style={{ fontWeight: 600 }}>{ut.name}</span>
<span className={`attendance-working-badge ${ut.working ? 'working' : 'finished'}`}> <span
{ut.working ? '\u2713' : '\u2717'} className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
>
{ut.working ? "\u2713" : "\u2717"}
</span> </span>
</div> </div>
<div className="admin-stat-value">{formatMinutes(ut.minutes)}</div> <div className="admin-stat-value">
{formatMinutes(ut.minutes)}
</div>
<div className="admin-stat-label">odpracováno</div> <div className="admin-stat-label">odpracováno</div>
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}> <div
style={{
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
}}
>
{ut.vacation_hours > 0 && ( {ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">Dov: {ut.vacation_hours}h</span> <span className="attendance-leave-badge badge-vacation">
Dov: {ut.vacation_hours}h
</span>
)} )}
{ut.sick_hours > 0 && ( {ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">Nem: {ut.sick_hours}h</span> <span className="attendance-leave-badge badge-sick">
Nem: {ut.sick_hours}h
</span>
)} )}
{ut.holiday_hours > 0 && ( {ut.holiday_hours > 0 && (
<span className="attendance-leave-badge badge-holiday">Sv: {ut.holiday_hours}h</span> <span className="attendance-leave-badge badge-holiday">
Sv: {ut.holiday_hours}h
</span>
)} )}
{ut.unpaid_hours > 0 && ( {ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">Nep: {ut.unpaid_hours}h</span> <span className="attendance-leave-badge badge-unpaid">
Nep: {ut.unpaid_hours}h
</span>
)} )}
</div> </div>
{ut.fund !== null && ( {ut.fund !== null && (
<div className="mt-2"> <div className="mt-2">
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem' }}> <div
<span>Fond: {ut.worked_hours}h / {ut.fund}h</span> 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 && ( {ut.overtime > 0 && (
<span className="text-warning fw-600">+{ut.overtime}h</span> <span className="text-warning fw-600">
+{ut.overtime}h
</span>
)} )}
{ut.overtime <= 0 && ut.missing > 0 && ( {ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">-{ut.missing}h</span> <span className="text-danger fw-600">
-{ut.missing}h
</span>
)} )}
</div> </div>
<div style={{ <div
marginTop: '0.375rem', style={{
height: '4px', marginTop: "0.375rem",
background: 'var(--bg-tertiary)', height: "4px",
borderRadius: '2px', background: "var(--bg-tertiary)",
overflow: 'hidden' borderRadius: "2px",
}}> overflow: "hidden",
<div style={{ }}
height: '100%', >
<div
style={{
height: "100%",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`, width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut), background: getFundBarBackground(ut),
borderRadius: '2px', borderRadius: "2px",
transition: 'width 0.3s ease' transition: "width 0.3s ease",
}} /> }}
/>
</div> </div>
</div> </div>
)} )}
{data.leave_balances[uid] && ( {data.leave_balances[uid] && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}> <div
Zbývá dovolené: {data.leave_balances[uid].vacation_remaining.toFixed(1)}h / {data.leave_balances[uid].vacation_total}h 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> </div>
</div> </div>
) );
})} })}
</motion.div> </motion.div>
)} )}
@@ -337,5 +456,5 @@ export default function AttendanceAdmin() {
confirmVariant="danger" confirmVariant="danger"
/> />
</div> </div>
) );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,113 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from "react-router-dom";
import { motion } from 'framer-motion' import { motion } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from "../components/AdminDatePicker";
import FormField from '../components/FormField' import FormField from "../components/FormField";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface User { interface User {
id: number | string id: number | string;
name: string name: string;
} }
interface CreateForm { interface CreateForm {
user_id: string user_id: string;
shift_date: string shift_date: string;
leave_type: string leave_type: string;
leave_hours: number leave_hours: number;
arrival_date: string arrival_date: string;
arrival_time: string arrival_time: string;
break_start_date: string break_start_date: string;
break_start_time: string break_start_time: string;
break_end_date: string break_end_date: string;
break_end_time: string break_end_time: string;
departure_date: string departure_date: string;
departure_time: string departure_time: string;
notes: string notes: string;
} }
export default function AttendanceCreate() { export default function AttendanceCreate() {
const alert = useAlert() const alert = useAlert();
const { hasPermission } = useAuth() const { hasPermission } = useAuth();
const navigate = useNavigate() const navigate = useNavigate();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([]);
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split("T")[0];
const [form, setForm] = useState<CreateForm>({ const [form, setForm] = useState<CreateForm>({
user_id: '', user_id: "",
shift_date: today, shift_date: today,
leave_type: 'work', leave_type: "work",
leave_hours: 8, leave_hours: 8,
arrival_date: today, arrival_date: today,
arrival_time: '', arrival_time: "",
break_start_date: today, break_start_date: today,
break_start_time: '', break_start_time: "",
break_end_date: today, break_end_date: today,
break_end_time: '', break_end_time: "",
departure_date: today, departure_date: today,
departure_time: '', departure_time: "",
notes: '' notes: "",
}) });
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const response = await apiFetch(`${API_BASE}/users`) const response = await apiFetch(`${API_BASE}/users`);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setUsers(Array.isArray(result.data) ? result.data : result.data?.items || []) setUsers(
Array.isArray(result.data) ? result.data : result.data?.items || [],
);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst uživatele') alert.error("Nepodařilo se načíst uživatele");
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
fetchUsers() fetchUsers();
}, [alert]) }, [alert]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
if (!form.user_id || !form.shift_date) { if (!form.user_id || !form.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny') alert.error("Vyplňte zaměstnance a datum směny");
return return;
} }
setSubmitting(true) setSubmitting(true);
try { try {
const response = await apiFetch(`${API_BASE}/attendance`, { const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(form) body: JSON.stringify(form),
}) });
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
alert.success(result.message) alert.success(result.message);
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`) navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setSubmitting(false) setSubmitting(false);
}
} }
};
const handleShiftDateChange = (newDate: string) => { const handleShiftDateChange = (newDate: string) => {
setForm({ setForm({
@@ -114,33 +116,42 @@ export default function AttendanceCreate() {
arrival_date: newDate, arrival_date: newDate,
break_start_date: newDate, break_start_date: newDate,
break_end_date: newDate, break_end_date: newDate,
departure_date: newDate departure_date: newDate,
}) });
} };
const isWorkType = form.leave_type === 'work' const isWorkType = form.leave_type === "work";
if (!hasPermission('attendance.admin')) return <Forbidden /> if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) { if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <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' }} /> className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div> </div>
<div className="admin-card" style={{ maxWidth: '600px' }}> <div className="admin-card" style={{ maxWidth: "600px" }}>
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i}> <div key={i}>
<div className="admin-skeleton-line w-1/4" style={{ marginBottom: '0.5rem', height: '10px' }} /> <div
className="admin-skeleton-line w-1/4"
style={{ marginBottom: "0.5rem", height: "10px" }}
/>
<div className="admin-skeleton-line w-full h-10" /> <div className="admin-skeleton-line w-full h-10" />
</div> </div>
))} ))}
<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> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -155,7 +166,10 @@ export default function AttendanceCreate() {
<h1 className="admin-page-title">Přidat záznam docházky</h1> <h1 className="admin-page-title">Přidat záznam docházky</h1>
</div> </div>
<div className="admin-page-actions"> <div className="admin-page-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary"> <Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu &larr; Zpět na správu
</Link> </Link>
</div> </div>
@@ -163,7 +177,7 @@ export default function AttendanceCreate() {
<motion.div <motion.div
className="admin-card" className="admin-card"
style={{ maxWidth: '600px' }} style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
@@ -174,13 +188,17 @@ export default function AttendanceCreate() {
<FormField label="Zaměstnanec" required> <FormField label="Zaměstnanec" required>
<select <select
value={form.user_id} value={form.user_id}
onChange={(e) => setForm({ ...form, user_id: e.target.value })} onChange={(e) =>
setForm({ ...form, user_id: e.target.value })
}
className="admin-form-select" className="admin-form-select"
required required
> >
<option value="">Vyberte zaměstnance</option> <option value="">Vyberte zaměstnance</option>
{users.map((user) => ( {users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option> <option key={user.id} value={user.id}>
{user.name}
</option>
))} ))}
</select> </select>
</FormField> </FormField>
@@ -197,7 +215,9 @@ export default function AttendanceCreate() {
<FormField label="Typ záznamu" required> <FormField label="Typ záznamu" required>
<select <select
value={form.leave_type} value={form.leave_type}
onChange={(e) => setForm({ ...form, leave_type: e.target.value })} onChange={(e) =>
setForm({ ...form, leave_type: e.target.value })
}
className="admin-form-select" className="admin-form-select"
> >
<option value="work">Práce</option> <option value="work">Práce</option>
@@ -213,13 +233,20 @@ export default function AttendanceCreate() {
<input <input
type="number" type="number"
value={form.leave_hours} value={form.leave_hours}
onChange={(e) => setForm({ ...form, leave_hours: parseFloat(e.target.value) })} onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5" min="0.5"
max="24" max="24"
step="0.5" step="0.5"
className="admin-form-input" className="admin-form-input"
/> />
<small className="admin-form-hint">Výchozí 8 hodin pro celý den</small> <small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField> </FormField>
)} )}
@@ -230,14 +257,18 @@ export default function AttendanceCreate() {
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.arrival_date} value={form.arrival_date}
onChange={(val: string) => setForm({ ...form, arrival_date: val })} onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/> />
</FormField> </FormField>
<FormField label="Příchod - čas"> <FormField label="Příchod - čas">
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.arrival_time} value={form.arrival_time}
onChange={(val: string) => setForm({ ...form, arrival_time: val })} onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/> />
</FormField> </FormField>
</div> </div>
@@ -247,14 +278,18 @@ export default function AttendanceCreate() {
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.break_start_date} value={form.break_start_date}
onChange={(val: string) => setForm({ ...form, break_start_date: val })} onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/> />
</FormField> </FormField>
<FormField label="Začátek pauzy - čas"> <FormField label="Začátek pauzy - čas">
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val: string) => setForm({ ...form, break_start_time: val })} onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/> />
</FormField> </FormField>
</div> </div>
@@ -264,14 +299,18 @@ export default function AttendanceCreate() {
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.break_end_date} value={form.break_end_date}
onChange={(val: string) => setForm({ ...form, break_end_date: val })} onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/> />
</FormField> </FormField>
<FormField label="Konec pauzy - čas"> <FormField label="Konec pauzy - čas">
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_end_time} value={form.break_end_time}
onChange={(val: string) => setForm({ ...form, break_end_time: val })} onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/> />
</FormField> </FormField>
</div> </div>
@@ -281,14 +320,18 @@ export default function AttendanceCreate() {
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.departure_date} value={form.departure_date}
onChange={(val: string) => setForm({ ...form, departure_date: val })} onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/> />
</FormField> </FormField>
<FormField label="Odchod - čas"> <FormField label="Odchod - čas">
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.departure_time} value={form.departure_time}
onChange={(val: string) => setForm({ ...form, departure_time: val })} onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/> />
</FormField> </FormField>
</div> </div>
@@ -305,7 +348,10 @@ export default function AttendanceCreate() {
</FormField> </FormField>
<div className="admin-form-actions"> <div className="admin-form-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary"> <Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit Zrušit
</Link> </Link>
<button <button
@@ -313,12 +359,12 @@ export default function AttendanceCreate() {
disabled={submitting} disabled={submitting}
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
> >
{submitting ? 'Ukládám...' : 'Uložit'} {submitting ? "Ukládám..." : "Uložit"}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</motion.div> </motion.div>
</div> </div>
) );
} }

View File

@@ -1,164 +1,210 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { motion } from 'framer-motion' import { motion } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from "../components/AdminDatePicker";
import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } from '../utils/attendanceHelpers' import {
import FormField from '../components/FormField' formatDate,
import apiFetch from '../utils/api' 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' const API_BASE = "/api/admin";
interface ProjectLog { interface ProjectLog {
id?: number id?: number;
project_id?: number project_id?: number;
project_name?: string project_name?: string;
started_at?: string started_at?: string;
ended_at?: string | null ended_at?: string | null;
hours?: string | number | null hours?: string | number | null;
minutes?: string | number | null minutes?: string | number | null;
} }
interface AttendanceRecord { interface AttendanceRecord {
id: number id: number;
shift_date: string shift_date: string;
leave_type?: string leave_type?: string;
leave_hours?: number leave_hours?: number;
arrival_time?: string | null arrival_time?: string | null;
departure_time?: string | null departure_time?: string | null;
break_start?: string | null break_start?: string | null;
break_end?: string | null break_end?: string | null;
notes?: string notes?: string;
project_name?: string project_name?: string;
project_logs?: ProjectLog[] project_logs?: ProjectLog[];
} }
const MONTH_NAMES = [ const MONTH_NAMES = [
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen', "Leden",
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec' "Únor",
] "Březen",
"Duben",
"Květen",
"Červen",
"Červenec",
"Srpen",
"Září",
"Říjen",
"Listopad",
"Prosinec",
];
const formatBreakRange = (record: AttendanceRecord): string => { const formatBreakRange = (record: AttendanceRecord): string => {
if (record.break_start && record.break_end) { if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}` return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
} }
if (record.break_start) { if (record.break_start) {
return `${formatTime(record.break_start)} - ?` return `${formatTime(record.break_start)} - ?`;
} }
return '—' return "—";
} };
const renderProjectCell = (record: AttendanceRecord) => { const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) { if (record.project_logs && record.project_logs.length > 0) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}> <div
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
>
{record.project_logs.map((log, i) => { {record.project_logs.map((log, i) => {
let h: number, m: number, isActive = false let h: number,
m: number,
isActive = false;
if (log.hours !== null && log.hours !== undefined) { if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0 h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0 m = parseInt(String(log.minutes)) || 0;
} else { } else {
isActive = !log.ended_at isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date() 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) const mins = Math.floor(
h = Math.floor(mins / 60) (end.getTime() - new Date(log.started_at!).getTime()) / 60000,
m = mins % 60 );
h = Math.floor(mins / 60);
m = mins % 60;
} }
return ( return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}> <span
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''}) 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> </span>
) );
})} })}
</div> </div>
) );
} }
if (record.project_name) { if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span> return (
<span
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.75rem" }}
>
{record.project_name}
</span>
);
} }
return '—' return "—";
} };
export default function AttendanceHistory() { export default function AttendanceHistory() {
const alert = useAlert() const alert = useAlert();
const { user, hasPermission } = useAuth() const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const printRef = useRef<HTMLDivElement>(null) const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => { const [month, setMonth] = useState(() => {
const now = new Date() const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}) });
const [records, setRecords] = useState<AttendanceRecord[]>([]) const [records, setRecords] = useState<AttendanceRecord[]>([]);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true);
try { try {
const [yearStr, monthStr] = month.split('-') const [yearStr, monthStr] = month.split("-");
const response = await apiFetch(`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ''}`) const response = await apiFetch(
if (response.status === 401) return `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
const result = await response.json() );
if (response.status === 401) return;
const result = await response.json();
if (result.success) { if (result.success) {
setRecords(result.data) setRecords(result.data);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst data') alert.error("Nepodařilo se načíst data");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, [month, alert, user?.id]) }, [month, alert, user?.id]);
useEffect(() => { useEffect(() => {
fetchData() fetchData();
}, [fetchData]) }, [fetchData]);
// Compute totals client-side from raw records // Compute totals client-side from raw records
const computed = useMemo(() => { const computed = useMemo(() => {
const [yearStr, monthStr] = month.split('-') const [yearStr, monthStr] = month.split("-");
const monthIndex = parseInt(monthStr, 10) - 1 const monthIndex = parseInt(monthStr, 10) - 1;
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}` const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`;
let totalMinutes = 0 let totalMinutes = 0;
let vacationHours = 0 let vacationHours = 0;
let sickHours = 0 let sickHours = 0;
let holidayHours = 0 let holidayHours = 0;
let unpaidHours = 0 let unpaidHours = 0;
for (const record of records) { for (const record of records) {
const leaveType = record.leave_type || 'work' const leaveType = record.leave_type || "work";
if (leaveType === 'work') { if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record) totalMinutes += calculateWorkMinutes(record);
} else { } else {
const hours = Number(record.leave_hours) || 8 const hours = Number(record.leave_hours) || 8;
if (leaveType === 'vacation') vacationHours += hours if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === 'sick') sickHours += hours else if (leaveType === "sick") sickHours += hours;
else if (leaveType === 'holiday') holidayHours += hours else if (leaveType === "holiday") holidayHours += hours;
else if (leaveType === 'unpaid') unpaidHours += hours else if (leaveType === "unpaid") unpaidHours += hours;
} }
} }
// Compute monthly fund (working days * 8h) // Compute monthly fund (working days * 8h)
// Exclude holidays from business days (matching PHP CzechHolidays logic) // Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10) const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1 const mo = parseInt(monthStr, 10) - 1;
// Count holiday records to subtract from business days // Count holiday records to subtract from business days
const holidayDays = records.filter(r => (r.leave_type || 'work') === 'holiday').length const holidayDays = records.filter(
let businessDays = 0 (r) => (r.leave_type || "work") === "holiday",
const cur = new Date(yr, mo, 1) ).length;
let businessDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) { while (cur.getMonth() === mo) {
const dow = cur.getDay() const dow = cur.getDay();
if (dow !== 0 && dow !== 6) businessDays++ if (dow !== 0 && dow !== 6) businessDays++;
cur.setDate(cur.getDate() + 1) cur.setDate(cur.getDate() + 1);
} }
// Subtract holidays from business days (holidays are non-working days, not part of the fund) // Subtract holidays from business days (holidays are non-working days, not part of the fund)
businessDays = Math.max(0, businessDays - holidayDays) businessDays = Math.max(0, businessDays - holidayDays);
const fund = businessDays * 8 const fund = businessDays * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100 const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary) // Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
const leaveHours = vacationHours + sickHours const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 100) / 100 const covered = Math.round((worked + leaveHours) * 100) / 100;
const remaining = Math.max(0, Math.round((fund - covered) * 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 overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
const monthlyFund = { const monthlyFund = {
fund, fund,
@@ -167,18 +213,26 @@ export default function AttendanceHistory() {
covered, covered,
remaining, remaining,
overtime, overtime,
} };
return { monthName, totalMinutes, vacationHours, sickHours, holidayHours, unpaidHours, monthlyFund } return {
}, [records, month]) monthName,
totalMinutes,
vacationHours,
sickHours,
holidayHours,
unpaidHours,
monthlyFund,
};
}, [records, month]);
if (!hasPermission('attendance.history')) return <Forbidden /> if (!hasPermission("attendance.history")) return <Forbidden />;
const handlePrint = () => { const handlePrint = () => {
if (!printRef.current) return if (!printRef.current) return;
const content = printRef.current.innerHTML const content = printRef.current.innerHTML;
const printWindow = window.open('', '_blank') const printWindow = window.open("", "_blank");
if (!printWindow) return if (!printWindow) return;
printWindow.document.write(` printWindow.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
@@ -266,12 +320,12 @@ export default function AttendanceHistory() {
${content} ${content}
</body> </body>
</html> </html>
`) `);
printWindow.document.close() printWindow.document.close();
printWindow.onload = () => { printWindow.onload = () => {
printWindow.print() printWindow.print();
} };
} };
return ( return (
<div> <div>
@@ -292,7 +346,15 @@ export default function AttendanceHistory() {
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary"
title="Tisk docházky" title="Tisk docházky"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}> <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" /> <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" /> <path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" /> <rect x="6" y="14" width="12" height="8" />
@@ -332,33 +394,81 @@ export default function AttendanceHistory() {
> >
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( {loading && (
<div className="admin-skeleton" style={{ gap: '0.5rem' }}> <div className="admin-skeleton" style={{ gap: "0.5rem" }}>
<div className="admin-skeleton-row" style={{ gap: '1rem' }}> <div className="admin-skeleton-row" style={{ gap: "1rem" }}>
<div className="admin-skeleton-line" style={{ width: '48px', height: '48px', borderRadius: '12px', flexShrink: 0 }} /> <div
className="admin-skeleton-line"
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
flexShrink: 0,
}}
/>
<div className="flex-1"> <div className="flex-1">
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} /> <div
<div className="admin-skeleton-line w-full" style={{ height: '6px', borderRadius: '3px' }} /> className="admin-skeleton-line w-1/2"
<div className="admin-skeleton-line w-1/3" style={{ height: '10px', marginTop: '0.5rem' }} /> 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> </div>
</div> </div>
)} )}
{!loading && computed.monthlyFund && ( {!loading && computed.monthlyFund && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}> <div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="admin-stat-icon info"> <div className="admin-stat-icon info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" /> <line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" /> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
</div> </div>
<div style={{ flex: 1, minWidth: '200px' }}> <div style={{ flex: 1, minWidth: "200px" }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.375rem' }}> <div
<span style={{ fontWeight: 600, fontSize: '1rem', color: 'var(--text-primary)' }}> style={{
Fond: {computed.monthlyFund.worked}h / {computed.monthlyFund.fund}h 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>
<span className="text-secondary" style={{ fontSize: '0.8125rem' }}> <span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů {computed.monthlyFund.business_days} prac. dnů
</span> </span>
</div> </div>
@@ -367,23 +477,41 @@ export default function AttendanceHistory() {
className="attendance-balance-progress" className="attendance-balance-progress"
style={{ style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`, width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
background: computed.monthlyFund.covered >= computed.monthlyFund.fund background:
? 'linear-gradient(135deg, var(--success), #059669)' computed.monthlyFund.covered >=
: 'var(--gradient)' computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}} }}
/> />
</div> </div>
<div className="text-muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginTop: '0.375rem' }}> <div
className="text-muted"
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.75rem",
marginTop: "0.375rem",
}}
>
<span> <span>
{'Pokryto: '}{computed.monthlyFund.covered}h (práce {computed.monthlyFund.worked}h {"Pokryto: "}
{computed.vacationHours > 0 && ` + dovolená ${computed.vacationHours}h`} {computed.monthlyFund.covered}h (práce{" "}
{computed.sickHours > 0 && ` + nemoc ${computed.sickHours}h`} {computed.monthlyFund.worked}h
{computed.holidayHours > 0 && ` + svátek ${computed.holidayHours}h`} {computed.vacationHours > 0 &&
{computed.unpaidHours > 0 && ` + neplacené ${computed.unpaidHours}h`} ` + 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> </span>
{computed.monthlyFund.overtime > 0 ? ( {computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">Přesčas: +{computed.monthlyFund.overtime}h</span> <span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : ( ) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span> <span>Zbývá: {computed.monthlyFund.remaining}h</span>
)} )}
@@ -392,7 +520,14 @@ export default function AttendanceHistory() {
</div> </div>
)} )}
{!loading && !computed.monthlyFund && ( {!loading && !computed.monthlyFund && (
<div className="text-muted" style={{ fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem 0' }}> <div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici Fond měsíce není k dispozici
</div> </div>
)} )}
@@ -408,8 +543,8 @@ export default function AttendanceHistory() {
> >
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( {loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <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/3" /> <div className="admin-skeleton-line w-1/3" />
@@ -440,34 +575,53 @@ export default function AttendanceHistory() {
</thead> </thead>
<tbody> <tbody>
{records.map((record) => { {records.map((record) => {
const leaveType = record.leave_type || 'work' const leaveType = record.leave_type || "work";
const isLeave = leaveType !== 'work' const isLeave = leaveType !== "work";
const workMinutes = isLeave const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60 ? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record) : calculateWorkMinutes(record);
return ( return (
<tr key={record.id}> <tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td> <td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td> <td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}> <span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)} {getLeaveTypeName(leaveType)}
</span> </span>
</td> </td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono"> <td className="admin-mono">
{isLeave ? '—' : formatBreakRange(record)} {isLeave ? "—" : formatDatetime(record.arrival_time)}
</td> </td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td> <td className="admin-mono">
<td className="admin-mono">{workMinutes > 0 ? formatMinutes(workMinutes, true) : '—'}</td> {isLeave ? "—" : formatBreakRange(record)}
<td>
{renderProjectCell(record)}
</td> </td>
<td style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <td className="admin-mono">
{record.notes || ''} {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> </td>
</tr> </tr>
) );
})} })}
</tbody> </tbody>
</table> </table>
@@ -478,13 +632,18 @@ export default function AttendanceHistory() {
{/* Hidden Print Content */} {/* Hidden Print Content */}
{records.length > 0 && ( {records.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}> <div ref={printRef} style={{ display: "none" }}>
<table className="print-wrapper-table"> <table className="print-wrapper-table">
<thead> <thead>
<tr><td> <tr>
<td>
<div className="print-header"> <div className="print-header">
<div className="print-header-left"> <div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" /> <img
src="/images/logo-light.png"
alt="BOHA"
className="print-logo"
/>
<div className="print-header-text"> <div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1> <h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div> <div className="company">BOHA Automation s.r.o.</div>
@@ -492,95 +651,197 @@ export default function AttendanceHistory() {
</div> </div>
<div className="print-header-right"> <div className="print-header-right">
<div className="period">{computed.monthName}</div> <div className="period">{computed.monthName}</div>
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div> <div className="filters">
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div> Zaměstnanec: {user?.fullName || ""}
</div>
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div> </div>
</div> </div>
</td></tr> </div>
</td>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr><td> <tr>
<td>
<div className="user-section"> <div className="user-section">
<div className="user-header"> <div className="user-header">
<h3>{user?.fullName || ''}</h3> <h3>{user?.fullName || ""}</h3>
<span className="total">Odpracováno: {formatMinutes(computed.totalMinutes, true)}</span> <span className="total">
Odpracováno:{" "}
{formatMinutes(computed.totalMinutes, true)}
</span>
</div> </div>
{(computed.vacationHours > 0 || computed.sickHours > 0 || computed.holidayHours > 0) && ( {(computed.vacationHours > 0 ||
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
<div className="leave-summary"> <div className="leave-summary">
{computed.vacationHours > 0 && <><span className="leave-badge badge-vacation">Dovolená: {computed.vacationHours}h</span> </>} {computed.vacationHours > 0 && (
{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> </>} <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> </div>
)} )}
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ width: '70px' }}>Datum</th> <th style={{ width: "70px" }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th> <th style={{ width: "70px" }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th> <th className="text-center" style={{ width: "70px" }}>
<th className="text-center" style={{ width: '90px' }}>Pauza</th> Příchod
<th className="text-center" style={{ width: '70px' }}>Odchod</th> </th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</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>Projekty</th>
<th>Poznámka</th> <th>Poznámka</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[...records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).map((record) => { {[...records]
const leaveType = record.leave_type || 'work' .sort((a, b) =>
const isLeave = leaveType !== 'work' a.shift_date.localeCompare(b.shift_date),
const workMinutes = calculateWorkMinutesPrint(record) )
const hours = Math.floor(workMinutes / 60) .map((record) => {
const mins = workMinutes % 60 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 ( return (
<tr key={record.id}> <tr key={record.id}>
<td>{formatDate(record.shift_date)}</td> <td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td> <td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td> <span
className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="text-center"> <td className="text-center">
{isLeave || !record.break_start || !record.break_end {isLeave
? '—' ? "—"
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}` : formatTimeOrDatetimePrint(
} record.arrival_time,
record.shift_date,
)}
</td> </td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td> <td className="text-center">
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td> {isLeave ||
<td style={{ fontSize: '8px' }}> !record.break_start ||
{(record.project_logs && record.project_logs.length > 0) !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) => { ? record.project_logs.map((log, i) => {
let h: number, m: number let h: number, m: number;
if (log.hours !== null && log.hours !== undefined) { if (
h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0 log.hours !== null &&
} else if (log.started_at && log.ended_at) { log.hours !== undefined
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 h = parseInt(String(log.hours)) || 0;
} else { h = 0; m = 0 } m =
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div> 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 || '—'} : record.project_name || "—"}
</td> </td>
<td>{record.notes || ''}</td> <td>{record.notes || ""}</td>
</tr> </tr>
) );
})} })}
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colSpan={6} className="text-right">Odpracováno:</td> <td colSpan={6} className="text-right">
<td className="text-center">{formatMinutes(computed.totalMinutes, true)}</td> Odpracováno:
</td>
<td className="text-center">
{formatMinutes(computed.totalMinutes, true)}
</td>
<td colSpan={2}></td> <td colSpan={2}></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
</td></tr> </td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
</div> </div>
) );
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,87 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import { formatDate, formatDatetime } from '../utils/attendanceHelpers' import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
import { czechPlural } from '../utils/formatters' import { czechPlural } from "../utils/formatters";
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from "../components/ConfirmModal";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
import FormField from '../components/FormField' import FormField from "../components/FormField";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
const leaveTypeLabels: Record<string, string> = { const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená', vacation: "Dovolená",
sick: 'Nemoc', sick: "Nemoc",
unpaid: 'Neplacené volno' unpaid: "Neplacené volno",
} };
const leaveTypeClasses: Record<string, string> = { const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation', vacation: "badge-vacation",
sick: 'badge-sick', sick: "badge-sick",
unpaid: 'badge-unpaid' unpaid: "badge-unpaid",
} };
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení', pending: "Čeká na schválení",
approved: 'Schváleno', approved: "Schváleno",
rejected: 'Zamítnuto', rejected: "Zamítnuto",
cancelled: 'Zrušeno' cancelled: "Zrušeno",
} };
const statusClasses: Record<string, string> = { const statusClasses: Record<string, string> = {
pending: 'badge-pending', pending: "badge-pending",
approved: 'badge-approved', approved: "badge-approved",
rejected: 'badge-rejected', rejected: "badge-rejected",
cancelled: 'badge-cancelled' cancelled: "badge-cancelled",
} };
interface RawLeaveRequest { interface RawLeaveRequest {
id: number id: number;
leave_type: string leave_type: string;
date_from: string date_from: string;
date_to: string date_to: string;
total_days: number total_days: number;
total_hours: number total_hours: number;
status: string status: string;
notes?: string notes?: string;
reviewer_note?: string reviewer_note?: string;
created_at: string created_at: string;
reviewed_at?: string reviewed_at?: string;
users_leave_requests_user_idTousers?: { first_name: string; last_name: string } users_leave_requests_user_idTousers?: {
users_leave_requests_reviewer_idTousers?: { first_name: string; last_name: string } | null first_name: string;
last_name: string;
};
users_leave_requests_reviewer_idTousers?: {
first_name: string;
last_name: string;
} | null;
} }
interface LeaveRequest { interface LeaveRequest {
id: number id: number;
employee_name: string employee_name: string;
leave_type: string leave_type: string;
date_from: string date_from: string;
date_to: string date_to: string;
total_days: number total_days: number;
total_hours: number total_hours: number;
status: string status: string;
notes?: string notes?: string;
reviewer_name?: string reviewer_name?: string;
reviewer_note?: string reviewer_note?: string;
created_at: string created_at: string;
reviewed_at?: string reviewed_at?: string;
} }
function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest { function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
const user = raw.users_leave_requests_user_idTousers const user = raw.users_leave_requests_user_idTousers;
const reviewer = raw.users_leave_requests_reviewer_idTousers const reviewer = raw.users_leave_requests_reviewer_idTousers;
return { return {
id: raw.id, id: raw.id,
employee_name: user ? `${user.first_name} ${user.last_name}` : 'Neznámý', employee_name: user ? `${user.first_name} ${user.last_name}` : "Neznámý",
leave_type: raw.leave_type, leave_type: raw.leave_type,
date_from: raw.date_from, date_from: raw.date_from,
date_to: raw.date_to, date_to: raw.date_to,
@@ -83,152 +89,196 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
total_hours: raw.total_hours, total_hours: raw.total_hours,
status: raw.status, status: raw.status,
notes: raw.notes, notes: raw.notes,
reviewer_name: reviewer ? `${reviewer.first_name} ${reviewer.last_name}` : undefined, reviewer_name: reviewer
? `${reviewer.first_name} ${reviewer.last_name}`
: undefined,
reviewer_note: raw.reviewer_note, reviewer_note: raw.reviewer_note,
created_at: raw.created_at, created_at: raw.created_at,
reviewed_at: raw.reviewed_at, reviewed_at: raw.reviewed_at,
} };
} }
export default function LeaveApproval() { export default function LeaveApproval() {
const { hasPermission } = useAuth() const { hasPermission } = useAuth();
const alert = useAlert() const alert = useAlert();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'pending' | 'processed'>('pending') const [activeTab, setActiveTab] = useState<"pending" | "processed">(
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]) "pending",
const [pendingCount, setPendingCount] = useState(0) );
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>([]) const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]);
const [approveModal, setApproveModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null }) const [pendingCount, setPendingCount] = useState(0);
const [rejectModal, setRejectModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null }) const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>(
const [rejectNote, setRejectNote] = useState('') [],
const [processing, setProcessing] = useState(false) );
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) useModalLock(rejectModal.open);
const fetchPending = useCallback(async () => { const fetchPending = useCallback(async () => {
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=pending`) const response = await apiFetch(
if (response.status === 401) return `${API_BASE}/leave-requests?status=pending`,
const result = await response.json() );
if (response.status === 401) return;
const result = await response.json();
if (result.success) { if (result.success) {
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest) const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest);
setPendingRequests(mapped) setPendingRequests(mapped);
setPendingCount(result.pagination?.total ?? mapped.length) setPendingCount(result.pagination?.total ?? mapped.length);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst žádosti') alert.error("Nepodařilo se načíst žádosti");
} }
}, [alert]) }, [alert]);
const fetchProcessed = useCallback(async () => { const fetchProcessed = useCallback(async () => {
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests?status=approved`) const response = await apiFetch(
if (response.status === 401) return `${API_BASE}/leave-requests?status=approved`,
const resultApproved = await response.json() );
if (response.status === 401) return;
const resultApproved = await response.json();
const response2 = await apiFetch(`${API_BASE}/leave-requests?status=rejected`) const response2 = await apiFetch(
if (response2.status === 401) return `${API_BASE}/leave-requests?status=rejected`,
const resultRejected = await response2.json() );
if (response2.status === 401) return;
const resultRejected = await response2.json();
const all = [ const all = [
...(resultApproved.success ? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest) : []), ...(resultApproved.success
...(resultRejected.success ? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest) : []) ? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest)
].sort((a: LeaveRequest, b: LeaveRequest) => new Date(b.reviewed_at!).getTime() - new Date(a.reviewed_at!).getTime()) : []),
...(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) setProcessedRequests(all);
} catch { } catch {
alert.error('Nepodařilo se načíst vyřízené žádosti') alert.error("Nepodařilo se načíst vyřízené žádosti");
} }
}, [alert]) }, [alert]);
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true);
fetchPending().finally(() => setLoading(false)) fetchPending().finally(() => setLoading(false));
}, [fetchPending]) }, [fetchPending]);
useEffect(() => { useEffect(() => {
if (activeTab === 'processed' && processedRequests.length === 0) { if (activeTab === "processed" && processedRequests.length === 0) {
fetchProcessed() fetchProcessed();
} }
}, [activeTab, processedRequests.length, fetchProcessed]) }, [activeTab, processedRequests.length, fetchProcessed]);
if (!hasPermission('attendance.approve')) return <Forbidden /> if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => { const handleApprove = async () => {
setProcessing(true) setProcessing(true);
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests/${approveModal.request!.id}`, { const response = await apiFetch(
method: 'PUT', `${API_BASE}/leave-requests/${approveModal.request!.id}`,
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify({ status: 'approved' }) method: "PUT",
}) headers: { "Content-Type": "application/json" },
if (response.status === 401) return body: JSON.stringify({ status: "approved" }),
},
);
if (response.status === 401) return;
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setApproveModal({ open: false, request: null }) setApproveModal({ open: false, request: null });
await fetchPending() await fetchPending();
setProcessedRequests([]) setProcessedRequests([]);
alert.success('Žádost byla schválena') alert.success("Žádost byla schválena");
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setProcessing(false) setProcessing(false);
}
} }
};
const handleReject = async () => { const handleReject = async () => {
if (!rejectNote.trim()) { if (!rejectNote.trim()) {
alert.error('Důvod zamítnutí je povinný') alert.error("Důvod zamítnutí je povinný");
return return;
} }
setProcessing(true) setProcessing(true);
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests/${rejectModal.request!.id}`, { const response = await apiFetch(
method: 'PUT', `${API_BASE}/leave-requests/${rejectModal.request!.id}`,
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify({ status: 'rejected', reviewer_note: rejectNote }) method: "PUT",
}) headers: { "Content-Type": "application/json" },
if (response.status === 401) return body: JSON.stringify({
status: "rejected",
reviewer_note: rejectNote,
}),
},
);
if (response.status === 401) return;
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setRejectModal({ open: false, request: null }) setRejectModal({ open: false, request: null });
setRejectNote('') setRejectNote("");
await fetchPending() await fetchPending();
setProcessedRequests([]) setProcessedRequests([]);
alert.success('Žádost byla zamítnuta') alert.success("Žádost byla zamítnuta");
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setProcessing(false) setProcessing(false);
}
} }
};
if (loading) { if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}> <div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div> <div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} /> <div
<div className="admin-skeleton-line" style={{ width: '140px' }} /> className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div> </div>
</div> </div>
<div className="admin-card"> <div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" /> <div className="admin-skeleton-line circle" />
<div className="flex-1"> <div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" /> <div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} /> <div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div> </div>
<div className="admin-skeleton-line w-1/4" /> <div className="admin-skeleton-line w-1/4" />
</div> </div>
@@ -236,7 +286,7 @@ export default function LeaveApproval() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -251,9 +301,8 @@ export default function LeaveApproval() {
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1> <h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle"> <p className="admin-page-subtitle">
{pendingCount > 0 {pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, 'žádost čeká', 'žádosti čekají', 'žádostí čeká')} na schválení` ? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: 'Žádné čekající žádosti' : "Žádné čekající žádosti"}
}
</p> </p>
</div> </div>
</motion.div> </motion.div>
@@ -266,19 +315,26 @@ export default function LeaveApproval() {
> >
<div className="offers-tabs mb-6"> <div className="offers-tabs mb-6">
<button <button
className={`offers-tab ${activeTab === 'pending' ? 'active' : ''}`} className={`offers-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab('pending')} onClick={() => setActiveTab("pending")}
> >
Ke schválení Ke schválení
{pendingCount > 0 && ( {pendingCount > 0 && (
<span className="admin-badge badge-pending" style={{ marginLeft: '0.5rem', fontSize: '0.7rem', padding: '0.15rem 0.5rem' }}> <span
className="admin-badge badge-pending"
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingCount} {pendingCount}
</span> </span>
)} )}
</button> </button>
<button <button
className={`offers-tab ${activeTab === 'processed' ? 'active' : ''}`} className={`offers-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab('processed')} onClick={() => setActiveTab("processed")}
> >
Vyřízené Vyřízené
</button> </button>
@@ -286,7 +342,7 @@ export default function LeaveApproval() {
</motion.div> </motion.div>
{/* Pending Tab */} {/* Pending Tab */}
{activeTab === 'pending' && ( {activeTab === "pending" && (
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -296,7 +352,15 @@ export default function LeaveApproval() {
<div className="admin-card"> <div className="admin-card">
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-empty-state"> <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"> <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" /> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" /> <polyline points="22 4 12 14.01 9 11.01" />
</svg> </svg>
@@ -305,43 +369,100 @@ export default function LeaveApproval() {
</div> </div>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
>
{pendingRequests.map((req) => ( {pendingRequests.map((req) => (
<div key={req.id} className="admin-card"> <div key={req.id} className="admin-card">
<div className="admin-card-body" style={{ padding: '1.25rem' }}> <div
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}> 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-1">
<div className="flex-row-gap mb-2"> <div className="flex-row-gap mb-2">
<strong style={{ fontSize: '1rem' }}>{req.employee_name}</strong> <strong style={{ fontSize: "1rem" }}>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}> {req.employee_name}
</strong>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type} {leaveTypeLabels[req.leave_type] || req.leave_type}
</span> </span>
</div> </div>
<div className="text-secondary" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', fontSize: '0.875rem' }}> <div
className="text-secondary"
style={{
display: "flex",
gap: "1.5rem",
flexWrap: "wrap",
fontSize: "0.875rem",
}}
>
<span> <span>
<strong>{formatDate(req.date_from)}</strong> <strong>{formatDate(req.date_to)}</strong> <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> </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> </div>
{req.notes && ( {req.notes && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.875rem', fontStyle: 'italic' }}> <div
className="text-secondary"
style={{
marginTop: "0.5rem",
fontSize: "0.875rem",
fontStyle: "italic",
}}
>
{req.notes} {req.notes}
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}> <div
style={{
display: "flex",
gap: "0.5rem",
flexShrink: 0,
}}
>
<button <button
onClick={() => setApproveModal({ open: true, request: req })} onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm" className="admin-btn admin-btn-sm"
style={{ background: 'var(--success-light)', color: 'var(--success)', border: 'none' }} style={{
background: "var(--success-light)",
color: "var(--success)",
border: "none",
}}
> >
Schválit Schválit
</button> </button>
<button <button
onClick={() => setRejectModal({ open: true, request: req })} onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm" className="admin-btn admin-btn-sm"
style={{ background: 'var(--danger-light)', color: 'var(--danger)', border: 'none' }} style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
> >
Zamítnout Zamítnout
</button> </button>
@@ -356,7 +477,7 @@ export default function LeaveApproval() {
)} )}
{/* Processed Tab */} {/* Processed Tab */}
{activeTab === 'processed' && ( {activeTab === "processed" && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -387,29 +508,46 @@ export default function LeaveApproval() {
<tbody> <tbody>
{processedRequests.map((req) => ( {processedRequests.map((req) => (
<tr key={req.id}> <tr key={req.id}>
<td><strong>{req.employee_name}</strong></td>
<td> <td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}> <strong>{req.employee_name}</strong>
</td>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type} {leaveTypeLabels[req.leave_type] || req.leave_type}
</span> </span>
</td> </td>
<td className="admin-mono">{formatDate(req.date_from)}</td> <td className="admin-mono">
<td className="admin-mono">{formatDate(req.date_to)}</td> {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_days}</td>
<td> <td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}> <span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status} {statusLabels[req.status] || req.status}
</span> </span>
</td> </td>
<td>{req.reviewer_name || '—'}</td> <td>{req.reviewer_name || "—"}</td>
<td style={{ maxWidth: '200px' }}> <td style={{ maxWidth: "200px" }}>
{req.reviewer_note ? ( {req.reviewer_note ? (
<span title={req.reviewer_note}> <span title={req.reviewer_note}>
{req.reviewer_note.length > 40 ? `${req.reviewer_note.substring(0, 40)}...` : req.reviewer_note} {req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span> </span>
) : '—'} ) : (
"—"
)}
</td> </td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}> <td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)} {formatDatetime(req.reviewed_at)}
</td> </td>
</tr> </tr>
@@ -428,9 +566,10 @@ export default function LeaveApproval() {
onClose={() => setApproveModal({ open: false, request: null })} onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove} onConfirm={handleApprove}
title="Schválit žádost" title="Schválit žádost"
message={approveModal.request message={
? `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}?` 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" confirmText="Schválit"
type="info" type="info"
@@ -447,7 +586,13 @@ export default function LeaveApproval() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-backdrop" onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }} /> <div
className="admin-modal-backdrop"
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
/>
<motion.div <motion.div
className="admin-modal" className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -461,8 +606,11 @@ export default function LeaveApproval() {
<div className="admin-modal-body"> <div className="admin-modal-body">
{rejectModal.request && ( {rejectModal.request && (
<p className="text-secondary mb-4"> <p className="text-secondary mb-4">
{rejectModal.request.employee_name} {leaveTypeLabels[rejectModal.request.leave_type]},{' '} {rejectModal.request.employee_name} {" "}
{formatDate(rejectModal.request.date_from)} {formatDate(rejectModal.request.date_to)} ({rejectModal.request.total_days} dnů) {leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p> </p>
)} )}
<FormField label="Důvod zamítnutí" required> <FormField label="Důvod zamítnutí" required>
@@ -479,7 +627,10 @@ export default function LeaveApproval() {
<div className="admin-modal-footer"> <div className="admin-modal-footer">
<button <button
type="button" type="button"
onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }} onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary"
disabled={processing} disabled={processing}
> >
@@ -491,7 +642,7 @@ export default function LeaveApproval() {
disabled={processing || !rejectNote.trim()} disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
> >
{processing ? 'Zpracování...' : 'Zamítnout'} {processing ? "Zpracování..." : "Zamítnout"}
</button> </button>
</div> </div>
</motion.div> </motion.div>
@@ -499,5 +650,5 @@ export default function LeaveApproval() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
) );
} }

View File

@@ -1,124 +1,142 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import { motion } from 'framer-motion' import { motion } from "framer-motion";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { formatDate, formatDatetime } from '../utils/attendanceHelpers' import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from "../components/ConfirmModal";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
const leaveTypeLabels: Record<string, string> = { const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená', vacation: "Dovolená",
sick: 'Nemoc', sick: "Nemoc",
unpaid: 'Neplacené volno' unpaid: "Neplacené volno",
} };
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení', pending: "Čeká na schválení",
approved: 'Schváleno', approved: "Schváleno",
rejected: 'Zamítnuto', rejected: "Zamítnuto",
cancelled: 'Zrušeno' cancelled: "Zrušeno",
} };
const statusClasses: Record<string, string> = { const statusClasses: Record<string, string> = {
pending: 'badge-pending', pending: "badge-pending",
approved: 'badge-approved', approved: "badge-approved",
rejected: 'badge-rejected', rejected: "badge-rejected",
cancelled: 'badge-cancelled' cancelled: "badge-cancelled",
} };
const leaveTypeClasses: Record<string, string> = { const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation', vacation: "badge-vacation",
sick: 'badge-sick', sick: "badge-sick",
unpaid: 'badge-unpaid' unpaid: "badge-unpaid",
} };
interface LeaveRequest { interface LeaveRequest {
id: number id: number;
leave_type: string leave_type: string;
date_from: string date_from: string;
date_to: string date_to: string;
total_days: number total_days: number;
total_hours: number total_hours: number;
status: string status: string;
notes?: string notes?: string;
reviewer_note?: string reviewer_note?: string;
created_at: string created_at: string;
} }
export default function LeaveRequests() { export default function LeaveRequests() {
const alert = useAlert() const alert = useAlert();
const { hasPermission } = useAuth() const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<LeaveRequest[]>([]) const [requests, setRequests] = useState<LeaveRequest[]>([]);
const [cancelModal, setCancelModal] = useState<{ open: boolean; id: number | null }>({ open: false, id: null }) const [cancelModal, setCancelModal] = useState<{
const [cancelling, setCancelling] = useState(false) open: boolean;
id: number | null;
}>({ open: false, id: null });
const [cancelling, setCancelling] = useState(false);
const fetchRequests = useCallback(async () => { const fetchRequests = useCallback(async () => {
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests`) const response = await apiFetch(`${API_BASE}/leave-requests`);
if (response.status === 401) return if (response.status === 401) return;
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setRequests(result.data) setRequests(result.data);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst žádosti') alert.error("Nepodařilo se načíst žádosti");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, [alert]) }, [alert]);
useEffect(() => { useEffect(() => {
fetchRequests() fetchRequests();
}, [fetchRequests]) }, [fetchRequests]);
if (!hasPermission('attendance.record')) return <Forbidden /> if (!hasPermission("attendance.record")) return <Forbidden />;
const handleCancel = async () => { const handleCancel = async () => {
setCancelling(true) setCancelling(true);
try { try {
const response = await apiFetch(`${API_BASE}/leave-requests/${cancelModal.id}`, { const response = await apiFetch(
method: 'DELETE', `${API_BASE}/leave-requests/${cancelModal.id}`,
}) {
if (response.status === 401) return method: "DELETE",
},
);
if (response.status === 401) return;
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setCancelModal({ open: false, id: null }) setCancelModal({ open: false, id: null });
await fetchRequests() await fetchRequests();
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setCancelling(false) setCancelling(false);
}
} }
};
if (loading) { if (loading) {
return ( return (
<div> <div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}> <div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div> <div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} /> <div
<div className="admin-skeleton-line" style={{ width: '140px' }} /> 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-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} /> <div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div> </div>
<div className="admin-card"> <div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" /> <div className="admin-skeleton-line circle" />
<div className="flex-1"> <div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" /> <div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} /> <div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div> </div>
<div className="admin-skeleton-line w-1/4" /> <div className="admin-skeleton-line w-1/4" />
</div> </div>
@@ -127,26 +145,34 @@ export default function LeaveRequests() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
function renderNoteCell(req: LeaveRequest) { function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) => text.length > 40 ? `${text.substring(0, 40)}...` : text const truncate = (text: string) =>
if (req.status === 'rejected' && req.reviewer_note) { text.length > 40 ? `${text.substring(0, 40)}...` : text;
if (req.status === "rejected" && req.reviewer_note) {
return ( return (
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }} title={req.reviewer_note}> <span
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
title={req.reviewer_note}
>
{truncate(req.reviewer_note)} {truncate(req.reviewer_note)}
</span> </span>
) );
} }
if (req.notes) { if (req.notes) {
return ( return (
<span className="text-secondary" style={{ fontSize: '0.875rem' }} title={req.notes}> <span
className="text-secondary"
style={{ fontSize: "0.875rem" }}
title={req.notes}
>
{truncate(req.notes)} {truncate(req.notes)}
</span> </span>
) );
} }
return <span className="text-muted"></span> return <span className="text-muted"></span>;
} }
return ( return (
@@ -173,7 +199,16 @@ export default function LeaveRequests() {
{requests.length === 0 ? ( {requests.length === 0 ? (
<div className="admin-empty-state"> <div className="admin-empty-state">
<div className="admin-empty-icon"> <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"> <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" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" /> <line x1="8" y1="2" x2="8" y2="6" />
@@ -181,7 +216,7 @@ export default function LeaveRequests() {
</svg> </svg>
</div> </div>
<p>Zatím nemáte žádné žádosti</p> <p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}> <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka Novou žádost můžete podat na stránce Docházka
</p> </p>
</div> </div>
@@ -205,29 +240,40 @@ export default function LeaveRequests() {
{requests.map((req) => ( {requests.map((req) => (
<tr key={req.id}> <tr key={req.id}>
<td> <td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}> <span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type} {leaveTypeLabels[req.leave_type] || req.leave_type}
</span> </span>
</td> </td>
<td className="admin-mono">{formatDate(req.date_from)}</td> <td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">{formatDate(req.date_to)}</td> <td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td> <td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td> <td className="admin-mono">{req.total_hours}h</td>
<td> <td>
<span className={`admin-badge ${statusClasses[req.status] || ''}`}> <span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status} {statusLabels[req.status] || req.status}
</span> </span>
</td> </td>
<td style={{ maxWidth: '200px' }}> <td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)} {renderNoteCell(req)}
</td> </td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}> <td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)} {formatDatetime(req.created_at)}
</td> </td>
<td> <td>
{req.status === 'pending' && ( {req.status === "pending" && (
<button <button
onClick={() => setCancelModal({ open: true, id: req.id })} onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm" className="admin-btn admin-btn-secondary admin-btn-sm"
> >
Zrušit Zrušit
@@ -254,5 +300,5 @@ export default function LeaveRequests() {
loading={cancelling} loading={cancelling}
/> />
</div> </div>
) );
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,144 +1,154 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import { Link } from 'react-router-dom' import { Link } from "react-router-dom";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from "../components/ConfirmModal";
import FormField from '../components/FormField' import FormField from "../components/FormField";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { formatDate } from '../utils/attendanceHelpers' import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from '../utils/formatters' import { formatKm } from "../utils/formatters";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface Vehicle { interface Vehicle {
id: number | string id: number | string;
spz: string spz: string;
name: string name: string;
} }
interface Trip { interface Trip {
id: number id: number;
vehicle_id: number | string vehicle_id: number | string;
trip_date: string trip_date: string;
start_km: number start_km: number;
end_km: number end_km: number;
distance?: number | null distance?: number | null;
route_from: string route_from: string;
route_to: string route_to: string;
is_business: boolean is_business: boolean;
notes?: string | null notes?: string | null;
users?: { id: number; first_name: string; last_name: string } users?: { id: number; first_name: string; last_name: string };
vehicles?: { id: number; name: string; spz: string } vehicles?: { id: number; name: string; spz: string };
} }
interface TripForm { interface TripForm {
vehicle_id: string vehicle_id: string;
trip_date: string trip_date: string;
start_km: string | number start_km: string | number;
end_km: string | number end_km: string | number;
route_from: string route_from: string;
route_to: string route_to: string;
is_business: number is_business: number;
notes: string notes: string;
} }
export default function Trips() { export default function Trips() {
const alert = useAlert() const alert = useAlert();
const { hasPermission } = useAuth() const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false);
const [trips, setTrips] = useState<Trip[]>([]) const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]) const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null) const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; tripId: number | null }>({ show: false, tripId: null }) const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
tripId: number | null;
}>({ show: false, tripId: null });
const [form, setForm] = useState<TripForm>({ const [form, setForm] = useState<TripForm>({
vehicle_id: '', vehicle_id: "",
trip_date: new Date().toISOString().split('T')[0], trip_date: new Date().toISOString().split("T")[0],
start_km: '', start_km: "",
end_km: '', end_km: "",
route_from: '', route_from: "",
route_to: '', route_to: "",
is_business: 1, is_business: 1,
notes: '' notes: "",
}) });
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({});
const [, setLastKm] = useState(0) const [, setLastKm] = useState(0);
const fetchData = useCallback(async (showLoading = true) => { const fetchData = useCallback(
if (showLoading) setLoading(true) async (showLoading = true) => {
if (showLoading) setLoading(true);
try { try {
const [tripsRes, vehiclesRes] = await Promise.all([ const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`), apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`), apiFetch(`${API_BASE}/vehicles`),
]) ]);
const tripsResult = await tripsRes.json() const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json() const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) { if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : []) setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : []);
} }
if (vehiclesResult.success) { if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : []) setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst data') alert.error("Nepodařilo se načíst data");
} finally { } finally {
if (showLoading) setLoading(false) if (showLoading) setLoading(false);
} }
}, [alert]) },
[alert],
);
useEffect(() => { useEffect(() => {
fetchData() fetchData();
}, [fetchData]) }, [fetchData]);
useModalLock(showModal) useModalLock(showModal);
if (!hasPermission('trips.record')) return <Forbidden /> if (!hasPermission("trips.record")) return <Forbidden />;
const fetchLastKm = async (vehicleId: string) => { const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) { if (!vehicleId) {
setLastKm(0) setLastKm(0);
return return;
} }
try { try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`) const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
const km = result.data?.last_km || 0 const km = result.data?.last_km || 0;
setLastKm(km) setLastKm(km);
if (!editingTrip) { if (!editingTrip) {
setForm(prev => ({ ...prev, start_km: km })) setForm((prev) => ({ ...prev, start_km: km }));
} }
return return;
} }
} catch { /* fallback below */ } } catch {
setLastKm(0) /* fallback below */
} }
setLastKm(0);
};
const openCreateModal = () => { const openCreateModal = () => {
setEditingTrip(null) setEditingTrip(null);
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split("T")[0];
setForm({ setForm({
vehicle_id: '', vehicle_id: "",
trip_date: today, trip_date: today,
start_km: '', start_km: "",
end_km: '', end_km: "",
route_from: '', route_from: "",
route_to: '', route_to: "",
is_business: 1, is_business: 1,
notes: '' notes: "",
}) });
setLastKm(0) setLastKm(0);
setErrors({}) setErrors({});
setShowModal(true) setShowModal(true);
} };
const openEditModal = (trip: Trip) => { const openEditModal = (trip: Trip) => {
setEditingTrip(trip) setEditingTrip(trip);
setForm({ setForm({
vehicle_id: String(trip.vehicle_id), vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date, trip_date: trip.trip_date,
@@ -147,116 +157,146 @@ export default function Trips() {
route_from: trip.route_from, route_from: trip.route_from,
route_to: trip.route_to, route_to: trip.route_to,
is_business: Number(trip.is_business), is_business: Number(trip.is_business),
notes: trip.notes || '' notes: trip.notes || "",
}) });
setLastKm(trip.start_km) setLastKm(trip.start_km);
setErrors({}) setErrors({});
setShowModal(true) setShowModal(true);
} };
const handleVehicleChange = (vehicleId: string) => { const handleVehicleChange = (vehicleId: string) => {
setForm(prev => ({ ...prev, vehicle_id: vehicleId })) setForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
fetchLastKm(vehicleId) fetchLastKm(vehicleId);
} };
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: Record<string, string> = {} const newErrors: Record<string, string> = {};
if (!form.vehicle_id) newErrors.vehicle_id = 'Vyberte vozidlo' if (!form.vehicle_id) newErrors.vehicle_id = "Vyberte vozidlo";
if (!form.trip_date) newErrors.trip_date = 'Zadejte datum' if (!form.trip_date) newErrors.trip_date = "Zadejte datum";
if (!form.start_km) newErrors.start_km = 'Zadejte počáteční km' 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.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))) { if (
newErrors.end_km = 'Musí být větší než počáteční' form.start_km &&
} form.end_km &&
if (!form.route_from) newErrors.route_from = 'Zadejte místo odjezdu' parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
if (!form.route_to) newErrors.route_to = 'Zadejte místo příjezdu' ) {
setErrors(newErrors) newErrors.end_km = "Musí být větší než počáteční";
return Object.keys(newErrors).length === 0
} }
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 () => { const handleSubmit = async () => {
if (!validateForm()) return if (!validateForm()) return;
setSubmitting(true) setSubmitting(true);
try { try {
const url = editingTrip const url = editingTrip
? `${API_BASE}/trips/${editingTrip.id}` ? `${API_BASE}/trips/${editingTrip.id}`
: `${API_BASE}/trips` : `${API_BASE}/trips`;
const response = await apiFetch(url, { const response = await apiFetch(url, {
method: editingTrip ? 'PUT' : 'POST', method: editingTrip ? "PUT" : "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(form) body: JSON.stringify(form),
}) });
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setShowModal(false) setShowModal(false);
await fetchData(false) await fetchData(false);
await new Promise(resolve => setTimeout(resolve, 300)) await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setSubmitting(false) setSubmitting(false);
}
} }
};
const handleDelete = async (tripId: number) => { const handleDelete = async (tripId: number) => {
try { try {
const response = await apiFetch(`${API_BASE}/trips/${tripId}`, { const response = await apiFetch(`${API_BASE}/trips/${tripId}`, {
method: 'DELETE', method: "DELETE",
}) });
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
await fetchData(false) await fetchData(false);
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setDeleteConfirm({ show: false, tripId: null }) setDeleteConfirm({ show: false, tripId: null });
}
} }
};
const calculateDistance = (): number => { const calculateDistance = (): number => {
const start = parseInt(String(form.start_km)) || 0 const start = parseInt(String(form.start_km)) || 0;
const end = parseInt(String(form.end_km)) || 0 const end = parseInt(String(form.end_km)) || 0;
return end > start ? end - start : 0 return end > start ? end - start : 0;
} };
if (loading) { if (loading) {
return ( return (
<div> <div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}> <div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div> <div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} /> <div
<div className="admin-skeleton-line" style={{ width: '140px' }} /> 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-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} /> <div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div> </div>
<div className="admin-grid admin-grid-4"> <div className="admin-grid admin-grid-4">
{[0, 1, 2, 3].map(i => ( {[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card"> <div key={i} className="admin-stat-card">
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} /> <div
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} /> className="admin-skeleton-line"
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} /> 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> </div>
<div className="admin-card"> <div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <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/3" /> <div className="admin-skeleton-line w-1/3" />
@@ -267,20 +307,20 @@ export default function Trips() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
const totals = trips.reduce( const totals = trips.reduce(
(acc, t) => { (acc, t) => {
const dist = t.distance ?? (t.end_km - t.start_km) const dist = t.distance ?? t.end_km - t.start_km;
acc.count++ acc.count++;
acc.total += dist acc.total += dist;
if (t.is_business) acc.business += dist if (t.is_business) acc.business += dist;
else acc.private += dist else acc.private += dist;
return acc return acc;
}, },
{ total: 0, business: 0, private: 0, count: 0 } { total: 0, business: 0, private: 0, count: 0 },
) );
return ( return (
<div> <div>
@@ -293,12 +333,25 @@ export default function Trips() {
<div> <div>
<h1 className="admin-page-title">Kniha jízd</h1> <h1 className="admin-page-title">Kniha jízd</h1>
<p className="admin-page-subtitle"> <p className="admin-page-subtitle">
{new Date().toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })} {new Date().toLocaleDateString("cs-CZ", {
month: "long",
year: "numeric",
})}
</p> </p>
</div> </div>
<div className="admin-page-actions"> <div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary"> <button
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> 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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -316,7 +369,16 @@ export default function Trips() {
> >
<div className="admin-stat-card info"> <div className="admin-stat-card info">
<div className="admin-stat-icon 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"> <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="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" /> <line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" /> <line x1="6" y1="20" x2="6" y2="16" />
@@ -330,19 +392,39 @@ export default function Trips() {
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon"> <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"> <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" /> <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg> </svg>
</div> </div>
<div className="admin-stat-content"> <div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span> <span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span> <span className="admin-stat-label">Celkem naježděno</span>
</div> </div>
</div> </div>
<div className="admin-stat-card success"> <div className="admin-stat-card success">
<div className="admin-stat-icon 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"> <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" /> <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" /> <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="5.5" cy="18" r="2" />
@@ -351,20 +433,33 @@ export default function Trips() {
</svg> </svg>
</div> </div>
<div className="admin-stat-content"> <div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span> <span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební</span> <span className="admin-stat-label">Služební</span>
</div> </div>
</div> </div>
<div className="admin-stat-card warning"> <div className="admin-stat-card warning">
<div className="admin-stat-icon 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"> <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" /> <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" /> <polyline points="9 22 9 12 15 12 15 22" />
</svg> </svg>
</div> </div>
<div className="admin-stat-content"> <div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.private)} km</span> <span className="admin-stat-value">
{formatKm(totals.private)} km
</span>
<span className="admin-stat-label">Soukromé</span> <span className="admin-stat-label">Soukromé</span>
</div> </div>
</div> </div>
@@ -379,7 +474,10 @@ export default function Trips() {
> >
<div className="admin-card-header flex-between"> <div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední jízdy</h2> <h2 className="admin-card-title">Poslední jízdy</h2>
<Link to="/trips/history" className="admin-btn admin-btn-secondary admin-btn-sm"> <Link
to="/trips/history"
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zobrazit historii Zobrazit historii
</Link> </Link>
</div> </div>
@@ -387,7 +485,16 @@ export default function Trips() {
{trips.length === 0 ? ( {trips.length === 0 ? (
<div className="admin-empty-state"> <div className="admin-empty-state">
<div className="admin-empty-icon"> <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"> <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" /> <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" /> <polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" /> <line x1="16" y1="13" x2="8" y2="13" />
@@ -395,7 +502,10 @@ export default function Trips() {
</svg> </svg>
</div> </div>
<p>Zatím nemáte žádné záznamy jízd.</p> <p>Zatím nemáte žádné záznamy jízd.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary"> <button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první jízdu Přidat první jízdu
</button> </button>
</div> </div>
@@ -416,20 +526,37 @@ export default function Trips() {
<tbody> <tbody>
{trips.slice(0, 10).map((trip) => ( {trips.slice(0, 10).map((trip) => (
<tr key={trip.id}> <tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td> <td className="admin-mono">
<td> {formatDate(trip.trip_date)}
<span className="admin-badge">{trip.vehicles?.spz ?? ''}</span>
</td> </td>
<td>{trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : ''}</td>
<td> <td>
<span style={{ whiteSpace: 'nowrap' }}> <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} {trip.route_from} &rarr; {trip.route_to}
</span> </span>
</td> </td>
<td className="admin-mono"><strong>{formatKm(trip.distance ?? (trip.end_km - trip.start_km))} km</strong></td> <td className="admin-mono">
<strong>
{formatKm(
trip.distance ?? trip.end_km - trip.start_km,
)}{" "}
km
</strong>
</td>
<td> <td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}> <span
{trip.is_business ? 'Služební' : 'Soukromá'} className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span> </span>
</td> </td>
<td> <td>
@@ -440,18 +567,38 @@ export default function Trips() {
title="Upravit" title="Upravit"
aria-label="Upravit" aria-label="Upravit"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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="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" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
</button> </button>
<button <button
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })} onClick={() =>
setDeleteConfirm({ show: true, tripId: trip.id })
}
className="admin-btn-icon danger" className="admin-btn-icon danger"
title="Smazat" title="Smazat"
aria-label="Smazat" aria-label="Smazat"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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" /> <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" /> <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> </svg>
@@ -477,7 +624,10 @@ export default function Trips() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} /> <div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -487,19 +637,23 @@ export default function Trips() {
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2 className="admin-modal-title">
{editingTrip ? 'Upravit jízdu' : 'Přidat jízdu'} {editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
</h2> </h2>
</div> </div>
<div className="admin-modal-body"> <div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
<FormField label="Vozidlo" error={errors.vehicle_id} required> <FormField
label="Vozidlo"
error={errors.vehicle_id}
required
>
<select <select
value={form.vehicle_id} value={form.vehicle_id}
onChange={(e) => { onChange={(e) => {
handleVehicleChange(e.target.value) handleVehicleChange(e.target.value);
setErrors(prev => ({ ...prev, vehicle_id: '' })) setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}} }}
className="admin-form-select" className="admin-form-select"
> >
@@ -512,41 +666,53 @@ export default function Trips() {
</select> </select>
</FormField> </FormField>
<FormField label="Datum jízdy" error={errors.trip_date} required> <FormField
label="Datum jízdy"
error={errors.trip_date}
required
>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.trip_date} value={form.trip_date}
onChange={(val: string) => { onChange={(val: string) => {
setForm({ ...form, trip_date: val }) setForm({ ...form, trip_date: val });
setErrors(prev => ({ ...prev, trip_date: '' })) setErrors((prev) => ({ ...prev, trip_date: "" }));
}} }}
/> />
</FormField> </FormField>
</div> </div>
<div className="admin-form-row admin-form-row-3"> <div className="admin-form-row admin-form-row-3">
<FormField label="Počáteční stav km" error={errors.start_km} required> <FormField
label="Počáteční stav km"
error={errors.start_km}
required
>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={form.start_km} value={form.start_km}
onChange={(e) => { onChange={(e) => {
setForm({ ...form, start_km: e.target.value }) setForm({ ...form, start_km: e.target.value });
setErrors(prev => ({ ...prev, start_km: '' })) setErrors((prev) => ({ ...prev, start_km: "" }));
}} }}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
/> />
</FormField> </FormField>
<FormField label="Konečný stav km" error={errors.end_km} required> <FormField
label="Konečný stav km"
error={errors.end_km}
required
>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={form.end_km} value={form.end_km}
onChange={(e) => { onChange={(e) => {
setForm({ ...form, end_km: e.target.value }) setForm({ ...form, end_km: e.target.value });
setErrors(prev => ({ ...prev, end_km: '' })) setErrors((prev) => ({ ...prev, end_km: "" }));
}} }}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
@@ -565,26 +731,34 @@ export default function Trips() {
</div> </div>
<div className="admin-form-row"> <div className="admin-form-row">
<FormField label="Místo odjezdu" error={errors.route_from} required> <FormField
label="Místo odjezdu"
error={errors.route_from}
required
>
<input <input
type="text" type="text"
value={form.route_from} value={form.route_from}
onChange={(e) => { onChange={(e) => {
setForm({ ...form, route_from: e.target.value }) setForm({ ...form, route_from: e.target.value });
setErrors(prev => ({ ...prev, route_from: '' })) setErrors((prev) => ({ ...prev, route_from: "" }));
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="Např. Praha" placeholder="Např. Praha"
/> />
</FormField> </FormField>
<FormField label="Místo příjezdu" error={errors.route_to} required> <FormField
label="Místo příjezdu"
error={errors.route_to}
required
>
<input <input
type="text" type="text"
value={form.route_to} value={form.route_to}
onChange={(e) => { onChange={(e) => {
setForm({ ...form, route_to: e.target.value }) setForm({ ...form, route_to: e.target.value });
setErrors(prev => ({ ...prev, route_to: '' })) setErrors((prev) => ({ ...prev, route_to: "" }));
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="Např. Brno" placeholder="Např. Brno"
@@ -595,7 +769,12 @@ export default function Trips() {
<FormField label="Typ jízdy"> <FormField label="Typ jízdy">
<select <select
value={form.is_business} value={form.is_business}
onChange={(e) => setForm({ ...form, is_business: parseInt(e.target.value) })} onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select" className="admin-form-select"
> >
<option value={1}>Služební</option> <option value={1}>Služební</option>
@@ -606,7 +785,9 @@ export default function Trips() {
<FormField label="Poznámky"> <FormField label="Poznámky">
<textarea <textarea
value={form.notes} value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })} onChange={(e) =>
setForm({ ...form, notes: e.target.value })
}
className="admin-form-textarea" className="admin-form-textarea"
rows={2} rows={2}
placeholder="Volitelné poznámky..." placeholder="Volitelné poznámky..."
@@ -630,7 +811,7 @@ export default function Trips() {
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
disabled={submitting} disabled={submitting}
> >
{submitting ? 'Ukládám...' : 'Uložit'} {submitting ? "Ukládám..." : "Uložit"}
</button> </button>
</div> </div>
</motion.div> </motion.div>
@@ -649,5 +830,5 @@ export default function Trips() {
type="danger" type="danger"
/> />
</div> </div>
) );
} }

View File

@@ -1,74 +1,74 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import { Link } from 'react-router-dom' import { Link } from "react-router-dom";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from "../components/ConfirmModal";
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from "../components/AdminDatePicker";
import FormField from '../components/FormField' import FormField from "../components/FormField";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
import { formatDate } from '../utils/attendanceHelpers' import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from '../utils/formatters' import { formatKm } from "../utils/formatters";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface Vehicle { interface Vehicle {
id: number | string id: number | string;
spz: string spz: string;
name: string name: string;
} }
interface UserShort { interface UserShort {
id: number | string id: number | string;
name: string name: string;
} }
interface Trip { interface Trip {
id: number id: number;
vehicle_id: number | string vehicle_id: number | string;
trip_date: string trip_date: string;
start_km: number start_km: number;
end_km: number end_km: number;
distance: number distance: number;
route_from: string route_from: string;
route_to: string route_to: string;
is_business: number | boolean is_business: number | boolean;
notes?: string notes?: string;
spz: string spz: string;
driver_name: string driver_name: string;
} }
interface BackendTrip { interface BackendTrip {
id: number id: number;
vehicle_id: number vehicle_id: number;
user_id: number user_id: number;
trip_date: string trip_date: string;
start_km: number start_km: number;
end_km: number end_km: number;
distance: number | null distance: number | null;
route_from: string route_from: string;
route_to: string route_to: string;
is_business: boolean is_business: boolean;
notes: string | null notes: string | null;
users: { id: number; first_name: string; last_name: string } users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string } vehicles: { id: number; name: string; spz: string };
} }
interface EditForm { interface EditForm {
vehicle_id: string vehicle_id: string;
trip_date: string trip_date: string;
start_km: string | number start_km: string | number;
end_km: string | number end_km: string | number;
route_from: string route_from: string;
route_to: string route_to: string;
is_business: number is_business: number;
notes: string notes: string;
} }
function mapTrip(bt: BackendTrip): Trip { function mapTrip(bt: BackendTrip): Trip {
const distance = bt.distance ?? (bt.end_km - bt.start_km) const distance = bt.distance ?? bt.end_km - bt.start_km;
return { return {
id: bt.id, id: bt.id,
vehicle_id: bt.vehicle_id, vehicle_id: bt.vehicle_id,
@@ -80,38 +80,45 @@ function mapTrip(bt: BackendTrip): Trip {
route_to: bt.route_to, route_to: bt.route_to,
is_business: bt.is_business ? 1 : 0, is_business: bt.is_business ? 1 : 0,
notes: bt.notes || undefined, notes: bt.notes || undefined,
spz: bt.vehicles?.spz ?? '', spz: bt.vehicles?.spz ?? "",
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : '', driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : "",
} };
} }
export default function TripsAdmin() { export default function TripsAdmin() {
const alert = useAlert() const alert = useAlert();
const { hasPermission } = useAuth() const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [filterMonth, setFilterMonth] = useState(() => String(new Date().getMonth() + 1)) const [filterMonth, setFilterMonth] = useState(() =>
const [filterYear, setFilterYear] = useState(() => String(new Date().getFullYear())) String(new Date().getMonth() + 1),
const [filterVehicleId, setFilterVehicleId] = useState('') );
const [filterUserId, setFilterUserId] = useState('') const [filterYear, setFilterYear] = useState(() =>
const [trips, setTrips] = useState<Trip[]>([]) String(new Date().getFullYear()),
const [vehicles, setVehicles] = useState<Vehicle[]>([]) );
const [users, setUsers] = useState<UserShort[]>([]) const [filterVehicleId, setFilterVehicleId] = useState("");
const printRef = useRef<HTMLDivElement>(null) 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 [showEditModal, setShowEditModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null) const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [editForm, setEditForm] = useState<EditForm>({ const [editForm, setEditForm] = useState<EditForm>({
vehicle_id: '', vehicle_id: "",
trip_date: '', trip_date: "",
start_km: '', start_km: "",
end_km: '', end_km: "",
route_from: '', route_from: "",
route_to: '', route_to: "",
is_business: 1, is_business: 1,
notes: '' notes: "",
}) });
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; trip: Trip | null }>({ show: false, trip: null }) const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
trip: Trip | null;
}>({ show: false, trip: null });
// Fetch vehicles and users once on mount // Fetch vehicles and users once on mount
useEffect(() => { useEffect(() => {
@@ -120,53 +127,60 @@ export default function TripsAdmin() {
const [vRes, uRes] = await Promise.all([ const [vRes, uRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`), apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/users?limit=1000`), apiFetch(`${API_BASE}/users?limit=1000`),
]) ]);
const vJson = await vRes.json() const vJson = await vRes.json();
const uJson = await uRes.json() const uJson = await uRes.json();
if (vJson.success) setVehicles(vJson.data) if (vJson.success) setVehicles(vJson.data);
if (uJson.success) { if (uJson.success) {
setUsers(uJson.data.map((u: { id: number; first_name: string; last_name: string }) => ({ setUsers(
uJson.data.map(
(u: { id: number; first_name: string; last_name: string }) => ({
id: u.id, id: u.id,
name: `${u.first_name} ${u.last_name}`, name: `${u.first_name} ${u.last_name}`,
}))) }),
),
);
} }
} catch { } catch {
// silently fail, filters will just be empty // silently fail, filters will just be empty
} }
} };
fetchLookups() fetchLookups();
}, []) }, []);
const fetchData = useCallback(async (showLoading = true) => { const fetchData = useCallback(
if (showLoading) setLoading(true) async (showLoading = true) => {
if (showLoading) setLoading(true);
try { try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}` let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`;
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}` if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`;
if (filterUserId) url += `&user_id=${filterUserId}` if (filterUserId) url += `&user_id=${filterUserId}`;
const response = await apiFetch(url) const response = await apiFetch(url);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip) const mapped = (result.data as BackendTrip[]).map(mapTrip);
setTrips(mapped) setTrips(mapped);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst data') alert.error("Nepodařilo se načíst data");
} finally { } finally {
if (showLoading) setLoading(false) if (showLoading) setLoading(false);
} }
}, [filterMonth, filterYear, filterVehicleId, filterUserId, alert]) },
[filterMonth, filterYear, filterVehicleId, filterUserId, alert],
);
useEffect(() => { useEffect(() => {
fetchData() fetchData();
}, [fetchData]) }, [fetchData]);
useModalLock(showEditModal) useModalLock(showEditModal);
if (!hasPermission('trips.admin')) return <Forbidden /> if (!hasPermission("trips.admin")) return <Forbidden />;
const openEditModal = (trip: Trip) => { const openEditModal = (trip: Trip) => {
setEditingTrip(trip) setEditingTrip(trip);
setEditForm({ setEditForm({
vehicle_id: String(trip.vehicle_id), vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date, trip_date: trip.trip_date,
@@ -175,82 +189,91 @@ export default function TripsAdmin() {
route_from: trip.route_from, route_from: trip.route_from,
route_to: trip.route_to, route_to: trip.route_to,
is_business: Number(trip.is_business), is_business: Number(trip.is_business),
notes: trip.notes || '' notes: trip.notes || "",
}) });
setShowEditModal(true) setShowEditModal(true);
} };
const handleEditSubmit = async () => { const handleEditSubmit = async () => {
if (!editingTrip) return if (!editingTrip) return;
if (parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))) { if (
alert.error('Konečný stav km musí být větší než počáteční') parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))
return ) {
alert.error("Konečný stav km musí být větší než počáteční");
return;
} }
try { try {
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, { const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
method: 'PUT', method: "PUT",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm) body: JSON.stringify(editForm),
}) });
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setShowEditModal(false) setShowEditModal(false);
await fetchData(false) await fetchData(false);
await new Promise(resolve => setTimeout(resolve, 300)) await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
}
} }
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteConfirm.trip) return if (!deleteConfirm.trip) return;
try { try {
const response = await apiFetch(`${API_BASE}/trips/${deleteConfirm.trip.id}`, { const response = await apiFetch(
method: 'DELETE', `${API_BASE}/trips/${deleteConfirm.trip.id}`,
}) {
method: "DELETE",
},
);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, trip: null }) setDeleteConfirm({ show: false, trip: null });
await fetchData(false) await fetchData(false);
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
}
} }
};
const getPeriodName = () => new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString('cs-CZ', { month: 'long', year: 'numeric' }) const getPeriodName = () =>
new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString(
"cs-CZ",
{ month: "long", year: "numeric" },
);
const getSelectedVehicleName = () => { const getSelectedVehicleName = () => {
if (!filterVehicleId) return null if (!filterVehicleId) return null;
const v = vehicles.find(v => String(v.id) === filterVehicleId) const v = vehicles.find((v) => String(v.id) === filterVehicleId);
return v ? `${v.spz} - ${v.name}` : null return v ? `${v.spz} - ${v.name}` : null;
} };
const getSelectedUserName = () => { const getSelectedUserName = () => {
if (!filterUserId) return null if (!filterUserId) return null;
const u = users.find(u => String(u.id) === filterUserId) const u = users.find((u) => String(u.id) === filterUserId);
return u?.name || null return u?.name || null;
} };
const handlePrint = () => { const handlePrint = () => {
const periodName = getPeriodName() const periodName = getPeriodName();
setTimeout(() => { setTimeout(() => {
if (printRef.current) { if (printRef.current) {
const content = printRef.current.innerHTML const content = printRef.current.innerHTML;
const printWindow = window.open('', '_blank') const printWindow = window.open("", "_blank");
if (!printWindow) return if (!printWindow) return;
printWindow.document.write(` printWindow.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
@@ -324,26 +347,28 @@ export default function TripsAdmin() {
${content} ${content}
</body> </body>
</html> </html>
`) `);
printWindow.document.close() printWindow.document.close();
printWindow.onload = () => { printWindow.onload = () => {
printWindow.print() printWindow.print();
} };
}
}, 100)
} }
}, 100);
};
const calculateDistance = (): number => { const calculateDistance = (): number => {
const start = parseInt(String(editForm.start_km)) || 0 const start = parseInt(String(editForm.start_km)) || 0;
const end = parseInt(String(editForm.end_km)) || 0 const end = parseInt(String(editForm.end_km)) || 0;
return end > start ? end - start : 0 return end > start ? end - start : 0;
} };
const totals = { const totals = {
count: trips.length, count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0), total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips.filter(t => Number(t.is_business)).reduce((sum, t) => sum + t.distance, 0), business: trips
} .filter((t) => Number(t.is_business))
.reduce((sum, t) => sum + t.distance, 0),
};
return ( return (
<div> <div>
@@ -363,7 +388,15 @@ export default function TripsAdmin() {
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary"
title="Tisk knihy jízd" 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' }}> <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" /> <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" /> <path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" /> <rect x="6" y="14" width="12" height="8" />
@@ -394,7 +427,9 @@ export default function TripsAdmin() {
> >
{Array.from({ length: 12 }, (_, i) => ( {Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}> <option key={i + 1} value={i + 1}>
{new Date(2000, i).toLocaleString('cs-CZ', { month: 'long' })} {new Date(2000, i).toLocaleString("cs-CZ", {
month: "long",
})}
</option> </option>
))} ))}
</select> </select>
@@ -406,8 +441,12 @@ export default function TripsAdmin() {
className="admin-form-select" className="admin-form-select"
> >
{Array.from({ length: 5 }, (_, i) => { {Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i const y = new Date().getFullYear() - 2 + i;
return <option key={y} value={y}>{y}</option> return (
<option key={y} value={y}>
{y}
</option>
);
})} })}
</select> </select>
</FormField> </FormField>
@@ -451,7 +490,16 @@ export default function TripsAdmin() {
> >
<div className="admin-stat-card info"> <div className="admin-stat-card info">
<div className="admin-stat-icon 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"> <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="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" /> <line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" /> <line x1="6" y1="20" x2="6" y2="16" />
@@ -464,18 +512,38 @@ export default function TripsAdmin() {
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon"> <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"> <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" /> <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg> </svg>
</div> </div>
<div className="admin-stat-content"> <div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span> <span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span> <span className="admin-stat-label">Celkem naježděno</span>
</div> </div>
</div> </div>
<div className="admin-stat-card success"> <div className="admin-stat-card success">
<div className="admin-stat-icon 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"> <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" /> <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" /> <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="5.5" cy="18" r="2" />
@@ -484,7 +552,9 @@ export default function TripsAdmin() {
</svg> </svg>
</div> </div>
<div className="admin-stat-content"> <div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span> <span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span> <span className="admin-stat-label">Služební km</span>
</div> </div>
</div> </div>
@@ -499,8 +569,8 @@ export default function TripsAdmin() {
> >
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( {loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <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/3" /> <div className="admin-skeleton-line w-1/3" />
@@ -532,25 +602,31 @@ export default function TripsAdmin() {
<tbody> <tbody>
{trips.map((trip) => ( {trips.map((trip) => (
<tr key={trip.id}> <tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td> <td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.driver_name}</td> <td>{trip.driver_name}</td>
<td> <td>
<span className="admin-badge">{trip.spz}</span> <span className="admin-badge">{trip.spz}</span>
</td> </td>
<td> <td>
<span style={{ whiteSpace: 'nowrap' }}> <span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to} {trip.route_from} &rarr; {trip.route_to}
</span> </span>
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
<span style={{ whiteSpace: 'nowrap' }}> <span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)} {formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span> </span>
</td> </td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td> <td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td> <td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}> <span
{trip.is_business ? 'Služební' : 'Soukromá'} className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span> </span>
</td> </td>
<td> <td>
@@ -561,18 +637,38 @@ export default function TripsAdmin() {
title="Upravit" title="Upravit"
aria-label="Upravit" aria-label="Upravit"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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="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" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
</button> </button>
<button <button
onClick={() => setDeleteConfirm({ show: true, trip })} onClick={() =>
setDeleteConfirm({ show: true, trip })
}
className="admin-btn-icon danger" className="admin-btn-icon danger"
title="Smazat" title="Smazat"
aria-label="Smazat" aria-label="Smazat"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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" /> <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" /> <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> </svg>
@@ -598,7 +694,10 @@ export default function TripsAdmin() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-backdrop" onClick={() => setShowEditModal(false)} /> <div
className="admin-modal-backdrop"
onClick={() => setShowEditModal(false)}
/>
<motion.div <motion.div
className="admin-modal admin-modal-lg" className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -608,7 +707,12 @@ export default function TripsAdmin() {
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit jízdu</h2> <h2 className="admin-modal-title">Upravit jízdu</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem' }}> <p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingTrip.driver_name} {editingTrip.driver_name}
</p> </p>
</div> </div>
@@ -619,7 +723,12 @@ export default function TripsAdmin() {
<FormField label="Vozidlo"> <FormField label="Vozidlo">
<select <select
value={editForm.vehicle_id} value={editForm.vehicle_id}
onChange={(e) => setEditForm({ ...editForm, vehicle_id: e.target.value })} onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select" className="admin-form-select"
> >
{vehicles.map((v) => ( {vehicles.map((v) => (
@@ -634,7 +743,9 @@ export default function TripsAdmin() {
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={editForm.trip_date} value={editForm.trip_date}
onChange={(val: string) => setEditForm({ ...editForm, trip_date: val })} onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/> />
</FormField> </FormField>
</div> </div>
@@ -645,7 +756,9 @@ export default function TripsAdmin() {
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={editForm.start_km} value={editForm.start_km}
onChange={(e) => setEditForm({ ...editForm, start_km: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
/> />
@@ -656,7 +769,9 @@ export default function TripsAdmin() {
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={editForm.end_km} value={editForm.end_km}
onChange={(e) => setEditForm({ ...editForm, end_km: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
/> />
@@ -678,7 +793,12 @@ export default function TripsAdmin() {
<input <input
type="text" type="text"
value={editForm.route_from} value={editForm.route_from}
onChange={(e) => setEditForm({ ...editForm, route_from: e.target.value })} onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input" className="admin-form-input"
/> />
</FormField> </FormField>
@@ -687,7 +807,9 @@ export default function TripsAdmin() {
<input <input
type="text" type="text"
value={editForm.route_to} value={editForm.route_to}
onChange={(e) => setEditForm({ ...editForm, route_to: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input" className="admin-form-input"
/> />
</FormField> </FormField>
@@ -696,7 +818,12 @@ export default function TripsAdmin() {
<FormField label="Typ jízdy"> <FormField label="Typ jízdy">
<select <select
value={editForm.is_business} value={editForm.is_business}
onChange={(e) => setEditForm({ ...editForm, is_business: parseInt(e.target.value) })} onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select" className="admin-form-select"
> >
<option value={1}>Služební</option> <option value={1}>Služební</option>
@@ -707,7 +834,9 @@ export default function TripsAdmin() {
<FormField label="Poznámky"> <FormField label="Poznámky">
<textarea <textarea
value={editForm.notes} value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea" className="admin-form-textarea"
rows={2} rows={2}
/> />
@@ -742,17 +871,25 @@ export default function TripsAdmin() {
onClose={() => setDeleteConfirm({ show: false, trip: null })} onClose={() => setDeleteConfirm({ show: false, trip: null })}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat záznam" title="Smazat záznam"
message={deleteConfirm.trip ? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?` : ''} message={
deleteConfirm.trip
? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?`
: ""
}
confirmText="Smazat" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
/> />
{/* Hidden Print Content */} {/* Hidden Print Content */}
{trips.length > 0 && ( {trips.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}> <div ref={printRef} style={{ display: "none" }}>
<div className="print-header"> <div className="print-header">
<div className="print-header-left"> <div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" /> <img
src="/images/logo-light.png"
alt="BOHA"
className="print-logo"
/>
<div className="print-header-text"> <div className="print-header-text">
<h1>KNIHA JÍZD</h1> <h1>KNIHA JÍZD</h1>
<div className="company">BOHA Automation s.r.o.</div> <div className="company">BOHA Automation s.r.o.</div>
@@ -760,9 +897,17 @@ export default function TripsAdmin() {
</div> </div>
<div className="print-header-right"> <div className="print-header-right">
<div className="period">{getPeriodName()}</div> <div className="period">{getPeriodName()}</div>
{getSelectedVehicleName() && <div className="filters">Vozidlo: {getSelectedVehicleName()}</div>} {getSelectedVehicleName() && (
{getSelectedUserName() && <div className="filters">Řidič: {getSelectedUserName()}</div>} <div className="filters">
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div> Vozidlo: {getSelectedVehicleName()}
</div>
)}
{getSelectedUserName() && (
<div className="filters">Řidič: {getSelectedUserName()}</div>
)}
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div> </div>
</div> </div>
@@ -776,11 +921,15 @@ export default function TripsAdmin() {
<div className="summary-label">Celkem</div> <div className="summary-label">Celkem</div>
</div> </div>
<div className="summary-item"> <div className="summary-item">
<div className="summary-value">{formatKm(totals.business)} km</div> <div className="summary-value">
{formatKm(totals.business)} km
</div>
<div className="summary-label">Služební</div> <div className="summary-label">Služební</div>
</div> </div>
<div className="summary-item"> <div className="summary-item">
<div className="summary-value">{formatKm(totals.total - totals.business)} km</div> <div className="summary-value">
{formatKm(totals.total - totals.business)} km
</div>
<div className="summary-label">Soukromé</div> <div className="summary-label">Soukromé</div>
</div> </div>
</div> </div>
@@ -788,13 +937,19 @@ export default function TripsAdmin() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th style={{ width: '70px' }}>Datum</th> <th style={{ width: "70px" }}>Datum</th>
<th style={{ width: '80px' }}>Řidič</th> <th style={{ width: "80px" }}>Řidič</th>
<th style={{ width: '70px' }}>Vozidlo</th> <th style={{ width: "70px" }}>Vozidlo</th>
<th>Trasa</th> <th>Trasa</th>
<th style={{ width: '70px' }} className="text-right">Stav km</th> <th style={{ width: "70px" }} className="text-right">
<th style={{ width: '60px' }} className="text-right">Vzdálenost</th> Stav km
<th style={{ width: '55px' }} className="text-center">Typ</th> </th>
<th style={{ width: "60px" }} className="text-right">
Vzdálenost
</th>
<th style={{ width: "55px" }} className="text-center">
Typ
</th>
<th>Poznámka</th> <th>Poznámka</th>
</tr> </tr>
</thead> </thead>
@@ -804,22 +959,34 @@ export default function TripsAdmin() {
<td>{formatDate(trip.trip_date)}</td> <td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td> <td>{trip.driver_name}</td>
<td>{trip.spz}</td> <td>{trip.spz}</td>
<td>{trip.route_from} &rarr; {trip.route_to}</td> <td>
<td className="text-right">{formatKm(trip.start_km)} - {formatKm(trip.end_km)}</td> {trip.route_from} &rarr; {trip.route_to}
<td className="text-right"><strong>{formatKm(trip.distance)} km</strong></td> </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"> <td className="text-center">
<span className={`badge ${trip.is_business ? 'badge-success' : 'badge-warning'}`}> <span
{trip.is_business ? 'Služební' : 'Soukromá'} className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span> </span>
</td> </td>
<td>{trip.notes || ''}</td> <td>{trip.notes || ""}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colSpan={5} className="text-right">Celkem:</td> <td colSpan={5} className="text-right">
<td className="text-right"><strong>{formatKm(totals.total)} km</strong></td> Celkem:
</td>
<td className="text-right">
<strong>{formatKm(totals.total)} km</strong>
</td>
<td colSpan={2}></td> <td colSpan={2}></td>
</tr> </tr>
</tfoot> </tfoot>
@@ -827,5 +994,5 @@ export default function TripsAdmin() {
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,103 +1,110 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import { motion } from 'framer-motion' import { motion } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from "../components/AdminDatePicker";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { formatDate } from '../utils/attendanceHelpers' import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from '../utils/formatters' import { formatKm } from "../utils/formatters";
import FormField from '../components/FormField' import FormField from "../components/FormField";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface Vehicle { interface Vehicle {
id: number | string id: number | string;
spz: string spz: string;
name: string name: string;
} }
interface Trip { interface Trip {
id: number id: number;
trip_date: string trip_date: string;
spz: string spz: string;
driver_name: string driver_name: string;
route_from: string route_from: string;
route_to: string route_to: string;
start_km: number start_km: number;
end_km: number end_km: number;
distance: number distance: number;
is_business: number | boolean is_business: number | boolean;
notes?: string notes?: string;
} }
export default function TripsHistory() { export default function TripsHistory() {
const alert = useAlert() const alert = useAlert();
const { user, hasPermission } = useAuth() const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => { const [month, setMonth] = useState(() => {
const now = new Date() const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}) });
const [vehicleId, setVehicleId] = useState('') const [vehicleId, setVehicleId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]) const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]) const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const totals = trips.reduce( const totals = trips.reduce(
(acc, t) => ({ (acc, t) => ({
total: acc.total + (t.distance || 0), total: acc.total + (t.distance || 0),
business: acc.business + (t.is_business ? (t.distance || 0) : 0), business: acc.business + (t.is_business ? t.distance || 0 : 0),
count: acc.count + 1, count: acc.count + 1,
}), }),
{ total: 0, business: 0, count: 0 } { total: 0, business: 0, count: 0 },
) );
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true);
try { try {
const params = new URLSearchParams({ month }) const params = new URLSearchParams({ month });
if (user?.id) params.set('user_id', String(user.id)) if (user?.id) params.set("user_id", String(user.id));
if (vehicleId) params.set('vehicle_id', vehicleId) if (vehicleId) params.set("vehicle_id", vehicleId);
const [tripsRes, vehiclesRes] = await Promise.all([ const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips?${params}`), apiFetch(`${API_BASE}/trips?${params}`),
apiFetch(`${API_BASE}/vehicles`), apiFetch(`${API_BASE}/vehicles`),
]) ]);
if (tripsRes.status === 401) return if (tripsRes.status === 401) return;
const tripsResult = await tripsRes.json() const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json() const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) { if (tripsResult.success) {
const raw = Array.isArray(tripsResult.data) ? tripsResult.data : tripsResult.data?.items || [] const raw = Array.isArray(tripsResult.data)
setTrips(raw.map((t: Record<string, unknown>) => ({ ? tripsResult.data
: tripsResult.data?.items || [];
setTrips(
raw.map((t: Record<string, unknown>) => ({
...t, ...t,
spz: (t.vehicles as Record<string, string>)?.spz || '', spz: (t.vehicles as Record<string, string>)?.spz || "",
driver_name: t.users driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ''} ${(t.users as Record<string, string>).last_name || ''}`.trim() ? `${(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), distance:
}))) ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})),
);
} }
if (vehiclesResult.success) { if (vehiclesResult.success) {
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : []) setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst data') alert.error("Nepodařilo se načíst data");
} finally { } finally {
setLoading(false) setLoading(false);
} }
}, [month, vehicleId, alert, user?.id]) }, [month, vehicleId, alert, user?.id]);
useEffect(() => { useEffect(() => {
fetchData() fetchData();
}, [fetchData]) }, [fetchData]);
if (!hasPermission('trips.history')) return <Forbidden /> if (!hasPermission("trips.history")) return <Forbidden />;
const getMonthName = (monthStr: string): string => { const getMonthName = (monthStr: string): string => {
const [yearStr, monthNum] = monthStr.split('-') const [yearStr, monthNum] = monthStr.split("-");
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1) const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1);
return date.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' }) return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
} };
return ( return (
<div> <div>
@@ -155,7 +162,16 @@ export default function TripsHistory() {
> >
<div className="admin-stat-card info"> <div className="admin-stat-card info">
<div className="admin-stat-icon 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"> <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="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" /> <line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" /> <line x1="6" y1="20" x2="6" y2="16" />
@@ -168,18 +184,38 @@ export default function TripsHistory() {
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon"> <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"> <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" /> <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg> </svg>
</div> </div>
<div className="admin-stat-content"> <div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span> <span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span> <span className="admin-stat-label">Celkem naježděno</span>
</div> </div>
</div> </div>
<div className="admin-stat-card success"> <div className="admin-stat-card success">
<div className="admin-stat-icon 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"> <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" /> <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" /> <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="5.5" cy="18" r="2" />
@@ -188,7 +224,9 @@ export default function TripsHistory() {
</svg> </svg>
</div> </div>
<div className="admin-stat-content"> <div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span> <span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span> <span className="admin-stat-label">Služební km</span>
</div> </div>
</div> </div>
@@ -204,7 +242,7 @@ export default function TripsHistory() {
<div className="admin-card-body"> <div className="admin-card-body">
{loading && ( {loading && (
<div className="admin-skeleton gap-5"> <div className="admin-skeleton gap-5">
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <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/3" /> <div className="admin-skeleton-line w-1/3" />
@@ -236,29 +274,47 @@ export default function TripsHistory() {
<tbody> <tbody>
{trips.map((trip) => ( {trips.map((trip) => (
<tr key={trip.id}> <tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td> <td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td> <td>
<span className="admin-badge">{trip.spz}</span> <span className="admin-badge">{trip.spz}</span>
</td> </td>
<td style={{ color: 'var(--text-secondary)' }}>{trip.driver_name}</td> <td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td> <td>
<span style={{ whiteSpace: 'nowrap' }}> <span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to} {trip.route_from} &rarr; {trip.route_to}
</span> </span>
</td> </td>
<td className="admin-mono"> <td className="admin-mono">
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}> <span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)} {formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span> </span>
</td> </td>
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td> <td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td> <td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}> <span
{trip.is_business ? 'Služební' : 'Soukromá'} className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span> </span>
</td> </td>
<td style={{ color: 'var(--text-secondary)', maxWidth: '200px' }}> <td
{trip.notes || '—'} style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td> </td>
</tr> </tr>
))} ))}
@@ -269,5 +325,5 @@ export default function TripsHistory() {
</div> </div>
</motion.div> </motion.div>
</div> </div>
) );
} }

View File

@@ -1,252 +1,284 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from "../components/ConfirmModal";
import FormField from '../components/FormField' import FormField from "../components/FormField";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface User { interface User {
id: number id: number;
username: string username: string;
email: string email: string;
first_name: string first_name: string;
last_name: string last_name: string;
role_id: number role_id: number;
roles?: { id: number; name: string; display_name: string } | null roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean is_active: boolean;
} }
interface Role { interface Role {
id: number id: number;
name: string name: string;
display_name: string display_name: string;
} }
interface FormData { interface FormData {
username: string username: string;
email: string email: string;
password: string password: string;
first_name: string first_name: string;
last_name: string last_name: string;
role_id: number | string role_id: number | string;
is_active: boolean is_active: boolean;
} }
export default function Users() { export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth() const { user: currentUser, updateUser, hasPermission } = useAuth();
const alert = useAlert() const alert = useAlert();
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]) const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null) const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user: User | null }>({ isOpen: false, user: null }) const [deleteModal, setDeleteModal] = useState<{
const [deleting, setDeleting] = useState(false) isOpen: boolean;
user: User | null;
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const [formData, setFormData] = useState<FormData>({ const [formData, setFormData] = useState<FormData>({
username: '', username: "",
email: '', email: "",
password: '', password: "",
first_name: '', first_name: "",
last_name: '', last_name: "",
role_id: '', role_id: "",
is_active: true is_active: true,
}) });
useModalLock(showModal) useModalLock(showModal);
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
try { try {
const usersRes = await apiFetch(`${API_BASE}/users`) const usersRes = await apiFetch(`${API_BASE}/users`);
const usersData = await usersRes.json() const usersData = await usersRes.json();
if (usersData.success) { if (usersData.success) {
setUsers(Array.isArray(usersData.data) ? usersData.data : []) setUsers(Array.isArray(usersData.data) ? usersData.data : []);
} else { } else {
alert.error(usersData.error || 'Nepodařilo se načíst uživatele') alert.error(usersData.error || "Nepodařilo se načíst uživatele");
} }
// Roles fetch — gracefully handle 403 if user lacks settings.roles permission // Roles fetch — gracefully handle 403 if user lacks settings.roles permission
try { try {
const rolesRes = await apiFetch(`${API_BASE}/roles`) const rolesRes = await apiFetch(`${API_BASE}/roles`);
const rolesData = await rolesRes.json() const rolesData = await rolesRes.json();
if (rolesData.success) { if (rolesData.success) {
setRoles(Array.isArray(rolesData.data) ? rolesData.data : []) setRoles(Array.isArray(rolesData.data) ? rolesData.data : []);
} }
} catch { /* roles not accessible */ }
} catch { } catch {
alert.error('Chyba připojení') /* roles not accessible */
} finally {
setLoading(false)
} }
}, []) // eslint-disable-line react-hooks/exhaustive-deps } catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
fetchUsers() fetchUsers();
}, [fetchUsers]) }, [fetchUsers]);
if (!hasPermission('users.view')) return <Forbidden /> if (!hasPermission("users.view")) return <Forbidden />;
const openCreateModal = () => { const openCreateModal = () => {
setEditingUser(null) setEditingUser(null);
setFormData({ setFormData({
username: '', username: "",
email: '', email: "",
password: '', password: "",
first_name: '', first_name: "",
last_name: '', last_name: "",
role_id: roles[0]?.id || '', role_id: roles[0]?.id || "",
is_active: true is_active: true,
}) });
setShowModal(true) setShowModal(true);
} };
const openEditModal = (user: User) => { const openEditModal = (user: User) => {
setEditingUser(user) setEditingUser(user);
setFormData({ setFormData({
username: user.username, username: user.username,
email: user.email, email: user.email,
password: '', password: "",
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
role_id: user.role_id, role_id: user.role_id,
is_active: user.is_active is_active: user.is_active,
}) });
setShowModal(true) setShowModal(true);
} };
const closeModal = () => { const closeModal = () => {
setShowModal(false) setShowModal(false);
setEditingUser(null) setEditingUser(null);
} };
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault() e?.preventDefault();
const dataToSave = { ...formData } const dataToSave = { ...formData };
const wasEditing = editingUser const wasEditing = editingUser;
const editingId = editingUser?.id const editingId = editingUser?.id;
try { try {
const url = wasEditing const url = wasEditing
? `${API_BASE}/users/${editingId}` ? `${API_BASE}/users/${editingId}`
: `${API_BASE}/users` : `${API_BASE}/users`;
const method = wasEditing ? 'PUT' : 'POST' const method = wasEditing ? "PUT" : "POST";
const response = await apiFetch(url, { const response = await apiFetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave) body: JSON.stringify(dataToSave),
}) });
const data = await response.json() const data = await response.json();
if (data.success) { if (data.success) {
if (wasEditing && currentUser && Number(editingId) === Number(currentUser.id)) { if (
wasEditing &&
currentUser &&
Number(editingId) === Number(currentUser.id)
) {
updateUser({ updateUser({
username: dataToSave.username, username: dataToSave.username,
email: dataToSave.email, email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim() fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
}) });
} }
closeModal() closeModal();
await new Promise(resolve => setTimeout(resolve, 300)) await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(wasEditing ? 'Uživatel byl upraven' : 'Uživatel byl vytvořen') alert.success(
fetchUsers() wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
fetchUsers();
} else { } else {
alert.error(data.error || 'Nepodařilo se uložit uživatele') alert.error(data.error || "Nepodařilo se uložit uživatele");
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
}
} }
};
const openDeleteModal = (user: User) => { const openDeleteModal = (user: User) => {
setDeleteModal({ isOpen: true, user }) setDeleteModal({ isOpen: true, user });
} };
const closeDeleteModal = () => { const closeDeleteModal = () => {
setDeleteModal({ isOpen: false, user: null }) setDeleteModal({ isOpen: false, user: null });
} };
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteModal.user) return if (!deleteModal.user) return;
setDeleting(true) setDeleting(true);
try { try {
const response = await apiFetch(`${API_BASE}/users/${deleteModal.user.id}`, { const response = await apiFetch(
method: 'DELETE', `${API_BASE}/users/${deleteModal.user.id}`,
}) {
method: "DELETE",
},
);
const data = await response.json() const data = await response.json();
if (data.success) { if (data.success) {
closeDeleteModal() closeDeleteModal();
fetchUsers() fetchUsers();
alert.success('Uživatel byl smazán') alert.success("Uživatel byl smazán");
} else { } else {
alert.error(data.error || 'Nepodařilo se smazat uživatele') alert.error(data.error || "Nepodařilo se smazat uživatele");
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
} finally { } finally {
setDeleting(false) setDeleting(false);
}
} }
};
const toggleActive = async (user: User) => { const toggleActive = async (user: User) => {
try { try {
const response = await apiFetch(`${API_BASE}/users/${user.id}`, { const response = await apiFetch(`${API_BASE}/users/${user.id}`, {
method: 'PUT', method: "PUT",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
is_active: !user.is_active is_active: !user.is_active,
}) }),
}) });
const data = await response.json() const data = await response.json();
if (data.success) { if (data.success) {
fetchUsers() fetchUsers();
alert.success(user.is_active ? 'Uživatel byl deaktivován' : 'Uživatel byl aktivován') alert.success(
user.is_active
? "Uživatel byl deaktivován"
: "Uživatel byl aktivován",
);
} else { } else {
alert.error(data.error || 'Nepodařilo se změnit stav uživatele') alert.error(data.error || "Nepodařilo se změnit stav uživatele");
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
}
} }
};
const getRoleBadgeClass = (roleName: string): string => { const getRoleBadgeClass = (roleName: string): string => {
switch (roleName) { switch (roleName) {
case 'admin': return 'admin-badge admin-badge-admin' case "admin":
default: return 'admin-badge admin-badge-viewer' return "admin-badge admin-badge-admin";
} default:
return "admin-badge admin-badge-viewer";
} }
};
if (loading) { if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}> <div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div> <div>
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} /> <div
<div className="admin-skeleton-line" style={{ width: '140px' }} /> 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-skeleton-line h-10" style={{ width: '160px', borderRadius: '8px' }} /> <div
className="admin-skeleton-line h-10"
style={{ width: "160px", borderRadius: "8px" }}
/>
</div> </div>
<div className="admin-card"> <div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" /> <div className="admin-skeleton-line circle" />
<div className="flex-1"> <div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" /> <div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} /> <div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div> </div>
<div className="admin-skeleton-line w-1/4" /> <div className="admin-skeleton-line w-1/4" />
</div> </div>
@@ -254,7 +286,7 @@ export default function Users() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -267,10 +299,22 @@ export default function Users() {
> >
<div> <div>
<h1 className="admin-page-title">Uživatelé</h1> <h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">Správa uživatelských úč a oprávnění</p> <p className="admin-page-subtitle">
Správa uživatelských úč a oprávnění
</p>
</div> </div>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary"> <button
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> 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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -302,30 +346,43 @@ export default function Users() {
<td> <td>
<div className="admin-table-user"> <div className="admin-table-user">
<div className="admin-table-avatar"> <div className="admin-table-avatar">
{(user.first_name || user.username).charAt(0).toUpperCase()} {(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
</div> </div>
<div> <div>
<div className="admin-table-name"> <div className="admin-table-name">
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</div> </div>
<div className="admin-table-username">@{user.username}</div> <div className="admin-table-username">
@{user.username}
</div>
</div> </div>
</div> </div>
</td> </td>
<td>{user.email}</td> <td>{user.email}</td>
<td> <td>
<span className={getRoleBadgeClass(user.roles?.name ?? '')}> <span
{user.roles?.display_name || user.roles?.name || '—'} className={getRoleBadgeClass(user.roles?.name ?? "")}
>
{user.roles?.display_name || user.roles?.name || "—"}
</span> </span>
</td> </td>
<td> <td>
<button <button
onClick={() => user.id !== currentUser?.id && toggleActive(user)} onClick={() =>
user.id !== currentUser?.id && toggleActive(user)
}
disabled={user.id === currentUser?.id} disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`} className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
style={{ cursor: user.id === currentUser?.id ? 'not-allowed' : 'pointer' }} style={{
cursor:
user.id === currentUser?.id
? "not-allowed"
: "pointer",
}}
> >
{user.is_active ? 'Aktivní' : 'Neaktivní'} {user.is_active ? "Aktivní" : "Neaktivní"}
</button> </button>
</td> </td>
<td> <td>
@@ -336,7 +393,14 @@ export default function Users() {
title="Upravit" title="Upravit"
aria-label="Upravit" aria-label="Upravit"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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="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" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
@@ -348,7 +412,14 @@ export default function Users() {
title="Smazat" title="Smazat"
aria-label="Smazat" aria-label="Smazat"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" /> <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" /> <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> </svg>
@@ -383,7 +454,9 @@ export default function Users() {
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2 className="admin-modal-title">
{editingUser ? 'Upravit uživatele' : 'Přidat nového uživatele'} {editingUser
? "Upravit uživatele"
: "Přidat nového uživatele"}
</h2> </h2>
</div> </div>
@@ -394,7 +467,12 @@ export default function Users() {
<input <input
type="text" type="text"
value={formData.first_name} value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required required
className="admin-form-input" className="admin-form-input"
/> />
@@ -403,7 +481,12 @@ export default function Users() {
<input <input
type="text" type="text"
value={formData.last_name} value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required required
className="admin-form-input" className="admin-form-input"
/> />
@@ -414,7 +497,9 @@ export default function Users() {
<input <input
type="text" type="text"
value={formData.username} value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })} onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required required
className="admin-form-input" className="admin-form-input"
/> />
@@ -424,17 +509,23 @@ export default function Users() {
<input <input
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required required
className="admin-form-input" className="admin-form-input"
/> />
</FormField> </FormField>
<FormField label={`Heslo ${editingUser ? '(ponechte prázdné pro zachování stávajícího)' : ''}`}> <FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<input <input
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser} required={!editingUser}
className="admin-form-input" className="admin-form-input"
/> />
@@ -443,7 +534,9 @@ export default function Users() {
<FormField label="Role"> <FormField label="Role">
<select <select
value={formData.role_id} value={formData.role_id}
onChange={(e) => setFormData({ ...formData, role_id: e.target.value })} onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required required
className="admin-form-select" className="admin-form-select"
> >
@@ -459,7 +552,12 @@ export default function Users() {
<input <input
type="checkbox" type="checkbox"
checked={formData.is_active} checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })} onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/> />
<span>Účet je aktivní</span> <span>Účet je aktivní</span>
</label> </label>
@@ -467,11 +565,19 @@ export default function Users() {
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary"> <button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
>
Zrušit Zrušit
</button> </button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary"> <button
{editingUser ? 'Uložit změny' : 'Vytvořit uživatele'} type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
</button> </button>
</div> </div>
</motion.div> </motion.div>
@@ -491,5 +597,5 @@ export default function Users() {
loading={deleting} loading={deleting}
/> />
</div> </div>
) );
} }

View File

@@ -1,209 +1,234 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { useAlert } from '../context/AlertContext' import { useAlert } from "../context/AlertContext";
import { useAuth } from '../context/AuthContext' import { useAuth } from "../context/AuthContext";
import Forbidden from '../components/Forbidden' import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from "../components/ConfirmModal";
import useModalLock from '../hooks/useModalLock' import useModalLock from "../hooks/useModalLock";
import { formatKm } from '../utils/formatters' import { formatKm } from "../utils/formatters";
import apiFetch from '../utils/api' import apiFetch from "../utils/api";
import FormField from '../components/FormField' import FormField from "../components/FormField";
const API_BASE = '/api/admin' const API_BASE = "/api/admin";
interface Vehicle { interface Vehicle {
id: number id: number;
spz: string spz: string;
name: string name: string;
brand?: string brand?: string;
model?: string model?: string;
initial_km: number initial_km: number;
current_km: number current_km: number;
trip_count: number trip_count: number;
is_active: boolean | number is_active: boolean | number;
} }
interface VehicleForm { interface VehicleForm {
spz: string spz: string;
name: string name: string;
brand: string brand: string;
model: string model: string;
initial_km: number initial_km: number;
is_active: boolean is_active: boolean;
} }
export default function Vehicles() { export default function Vehicles() {
const alert = useAlert() const alert = useAlert();
const { hasPermission } = useAuth() const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [vehicles, setVehicles] = useState<Vehicle[]>([]) const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false);
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null) const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
const [form, setForm] = useState<VehicleForm>({ const [form, setForm] = useState<VehicleForm>({
spz: '', spz: "",
name: '', name: "",
brand: '', brand: "",
model: '', model: "",
initial_km: 0, initial_km: 0,
is_active: true is_active: true,
}) });
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({});
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; vehicle: Vehicle | null }>({ show: false, vehicle: null }) const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
vehicle: Vehicle | null;
}>({ show: false, vehicle: null });
const fetchData = useCallback(async (showLoading = true) => { const fetchData = useCallback(
if (showLoading) setLoading(true) async (showLoading = true) => {
if (showLoading) setLoading(true);
try { try {
const response = await apiFetch(`${API_BASE}/vehicles`) const response = await apiFetch(`${API_BASE}/vehicles`);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setVehicles(Array.isArray(result.data) ? result.data : []) setVehicles(Array.isArray(result.data) ? result.data : []);
} }
} catch { } catch {
alert.error('Nepodařilo se načíst data') alert.error("Nepodařilo se načíst data");
} finally { } finally {
if (showLoading) setLoading(false) if (showLoading) setLoading(false);
} }
}, [alert]) },
[alert],
);
useEffect(() => { useEffect(() => {
fetchData() fetchData();
}, [fetchData]) }, [fetchData]);
useModalLock(showModal) useModalLock(showModal);
if (!hasPermission('trips.vehicles')) return <Forbidden /> if (!hasPermission("trips.vehicles")) return <Forbidden />;
const openCreateModal = () => { const openCreateModal = () => {
setEditingVehicle(null) setEditingVehicle(null);
setForm({ setForm({
spz: '', spz: "",
name: '', name: "",
brand: '', brand: "",
model: '', model: "",
initial_km: 0, initial_km: 0,
is_active: true is_active: true,
}) });
setErrors({}) setErrors({});
setShowModal(true) setShowModal(true);
} };
const openEditModal = (vehicle: Vehicle) => { const openEditModal = (vehicle: Vehicle) => {
setEditingVehicle(vehicle) setEditingVehicle(vehicle);
setForm({ setForm({
spz: vehicle.spz, spz: vehicle.spz,
name: vehicle.name, name: vehicle.name,
brand: vehicle.brand || '', brand: vehicle.brand || "",
model: vehicle.model || '', model: vehicle.model || "",
initial_km: vehicle.initial_km, initial_km: vehicle.initial_km,
is_active: Boolean(vehicle.is_active) is_active: Boolean(vehicle.is_active),
}) });
setErrors({}) setErrors({});
setShowModal(true) setShowModal(true);
} };
const handleSubmit = async () => { const handleSubmit = async () => {
const newErrors: Record<string, string> = {} const newErrors: Record<string, string> = {};
if (!form.spz) newErrors.spz = 'Zadejte SPZ' if (!form.spz) newErrors.spz = "Zadejte SPZ";
if (!form.name) newErrors.name = 'Zadejte název' if (!form.name) newErrors.name = "Zadejte název";
setErrors(newErrors) setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return if (Object.keys(newErrors).length > 0) return;
try { try {
const url = editingVehicle const url = editingVehicle
? `${API_BASE}/vehicles/${editingVehicle.id}` ? `${API_BASE}/vehicles/${editingVehicle.id}`
: `${API_BASE}/vehicles` : `${API_BASE}/vehicles`;
const method = editingVehicle ? 'PUT' : 'POST' const method = editingVehicle ? "PUT" : "POST";
const response = await apiFetch(url, { const response = await apiFetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(form) body: JSON.stringify(form),
}) });
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setShowModal(false) setShowModal(false);
await fetchData(false) await fetchData(false);
await new Promise(resolve => setTimeout(resolve, 300)) await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
}
} }
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteConfirm.vehicle) return if (!deleteConfirm.vehicle) return;
try { try {
const response = await apiFetch(`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`, { const response = await apiFetch(
method: 'DELETE', `${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`,
}) {
method: "DELETE",
},
);
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, vehicle: null }) setDeleteConfirm({ show: false, vehicle: null });
await fetchData(false) await fetchData(false);
alert.success(result.message) alert.success(result.message);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
}
} }
};
const toggleActive = async (vehicle: Vehicle) => { const toggleActive = async (vehicle: Vehicle) => {
try { try {
const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, { const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, {
method: 'PUT', method: "PUT",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
spz: vehicle.spz, spz: vehicle.spz,
name: vehicle.name, name: vehicle.name,
brand: vehicle.brand || '', brand: vehicle.brand || "",
model: vehicle.model || '', model: vehicle.model || "",
initial_km: vehicle.initial_km, initial_km: vehicle.initial_km,
is_active: !vehicle.is_active is_active: !vehicle.is_active,
}) }),
}) });
const result = await response.json() const result = await response.json();
if (result.success) { if (result.success) {
fetchData(false) fetchData(false);
alert.success(vehicle.is_active ? 'Vozidlo bylo deaktivováno' : 'Vozidlo bylo aktivováno') alert.success(
vehicle.is_active
? "Vozidlo bylo deaktivováno"
: "Vozidlo bylo aktivováno",
);
} else { } else {
alert.error(result.error) alert.error(result.error);
} }
} catch { } catch {
alert.error('Chyba připojení') alert.error("Chyba připojení");
}
} }
};
if (loading) { if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}> <div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div> <div>
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} /> <div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div> </div>
<div className="admin-skeleton-line h-10" style={{ width: '150px', borderRadius: '8px' }} /> <div
className="admin-skeleton-line h-10"
style={{ width: "150px", borderRadius: "8px" }}
/>
</div> </div>
<div className="admin-card"> <div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}> <div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map(i => ( {[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row"> <div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" /> <div className="admin-skeleton-line circle" />
<div className="flex-1"> <div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" /> <div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} /> <div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div> </div>
<div className="admin-skeleton-line w-1/4" /> <div className="admin-skeleton-line w-1/4" />
</div> </div>
@@ -211,7 +236,7 @@ export default function Vehicles() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -226,8 +251,18 @@ export default function Vehicles() {
<h1 className="admin-page-title">Správa vozidel</h1> <h1 className="admin-page-title">Správa vozidel</h1>
</div> </div>
<div className="admin-page-actions"> <div className="admin-page-actions">
<button onClick={openCreateModal} className="admin-btn admin-btn-primary"> <button
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> 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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -246,7 +281,16 @@ export default function Vehicles() {
{vehicles.length === 0 && ( {vehicles.length === 0 && (
<div className="admin-empty-state"> <div className="admin-empty-state">
<div className="admin-empty-icon"> <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"> <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" /> <rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" /> <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="5.5" cy="18.5" r="2.5" />
@@ -254,7 +298,10 @@ export default function Vehicles() {
</svg> </svg>
</div> </div>
<p>Zatím nejsou žádná vozidla.</p> <p>Zatím nejsou žádná vozidla.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary"> <button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první vozidlo Přidat první vozidlo
</button> </button>
</div> </div>
@@ -276,23 +323,32 @@ export default function Vehicles() {
</thead> </thead>
<tbody> <tbody>
{vehicles.map((vehicle) => ( {vehicles.map((vehicle) => (
<tr key={vehicle.id} className={!vehicle.is_active ? 'admin-table-row-inactive' : ''}> <tr
key={vehicle.id}
className={
!vehicle.is_active ? "admin-table-row-inactive" : ""
}
>
<td className="admin-mono fw-500">{vehicle.spz}</td> <td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td> <td>{vehicle.name}</td>
<td> <td>
{vehicle.brand || vehicle.model {vehicle.brand || vehicle.model
? `${vehicle.brand || ''} ${vehicle.model || ''}`.trim() ? `${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>
<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 className="admin-mono">{vehicle.trip_count}</td>
<td> <td>
<button <button
onClick={() => toggleActive(vehicle)} onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`} className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
> >
{vehicle.is_active ? 'Aktivní' : 'Neaktivní'} {vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button> </button>
</td> </td>
<td> <td>
@@ -303,18 +359,38 @@ export default function Vehicles() {
title="Upravit" title="Upravit"
aria-label="Upravit" aria-label="Upravit"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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="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" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
</button> </button>
<button <button
onClick={() => setDeleteConfirm({ show: true, vehicle })} onClick={() =>
setDeleteConfirm({ show: true, vehicle })
}
className="admin-btn-icon danger" className="admin-btn-icon danger"
title="Smazat" title="Smazat"
aria-label="Smazat" aria-label="Smazat"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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" /> <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" /> <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> </svg>
@@ -340,7 +416,10 @@ export default function Vehicles() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} /> <div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div <motion.div
className="admin-modal" className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -350,7 +429,7 @@ export default function Vehicles() {
> >
<div className="admin-modal-header"> <div className="admin-modal-header">
<h2 className="admin-modal-title"> <h2 className="admin-modal-title">
{editingVehicle ? 'Upravit vozidlo' : 'Přidat vozidlo'} {editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
</h2> </h2>
</div> </div>
@@ -362,8 +441,11 @@ export default function Vehicles() {
type="text" type="text"
value={form.spz} value={form.spz}
onChange={(e) => { onChange={(e) => {
setForm({ ...form, spz: e.target.value.toUpperCase() }) setForm({
setErrors(prev => ({ ...prev, spz: '' })) ...form,
spz: e.target.value.toUpperCase(),
});
setErrors((prev) => ({ ...prev, spz: "" }));
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="1AB 2345" placeholder="1AB 2345"
@@ -376,8 +458,8 @@ export default function Vehicles() {
type="text" type="text"
value={form.name} value={form.name}
onChange={(e) => { onChange={(e) => {
setForm({ ...form, name: e.target.value }) setForm({ ...form, name: e.target.value });
setErrors(prev => ({ ...prev, name: '' })) setErrors((prev) => ({ ...prev, name: "" }));
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="Služební #1" placeholder="Služební #1"
@@ -391,7 +473,9 @@ export default function Vehicles() {
<input <input
type="text" type="text"
value={form.brand} value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })} onChange={(e) =>
setForm({ ...form, brand: e.target.value })
}
className="admin-form-input" className="admin-form-input"
placeholder="Škoda" placeholder="Škoda"
/> />
@@ -401,7 +485,9 @@ export default function Vehicles() {
<input <input
type="text" type="text"
value={form.model} value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })} onChange={(e) =>
setForm({ ...form, model: e.target.value })
}
className="admin-form-input" className="admin-form-input"
placeholder="Octavia Combi" placeholder="Octavia Combi"
/> />
@@ -409,12 +495,19 @@ export default function Vehicles() {
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label> <label className="admin-form-label">
Počáteční stav km
</label>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={form.initial_km} value={form.initial_km}
onChange={(e) => setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })} onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
/> />
@@ -427,7 +520,9 @@ export default function Vehicles() {
<input <input
type="checkbox" type="checkbox"
checked={form.is_active} checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })} onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
/> />
<span>Vozidlo je aktivní</span> <span>Vozidlo je aktivní</span>
</label> </label>
@@ -461,10 +556,14 @@ export default function Vehicles() {
onClose={() => setDeleteConfirm({ show: false, vehicle: null })} onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat vozidlo" title="Smazat vozidlo"
message={deleteConfirm.vehicle ? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?` : ''} message={
deleteConfirm.vehicle
? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?`
: ""
}
confirmText="Smazat" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
/> />
</div> </div>
) );
} }

View File

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

View File

@@ -1,169 +1,189 @@
interface AttendanceRecord { interface AttendanceRecord {
arrival_time?: string | null arrival_time?: string | null;
departure_time?: string | null departure_time?: string | null;
break_start?: string | null break_start?: string | null;
break_end?: string | null break_end?: string | null;
leave_type?: string leave_type?: string;
leave_hours?: number leave_hours?: number;
shift_date?: string shift_date?: string;
notes?: string notes?: string;
project_logs?: Array<{ project_logs?: Array<{
id?: number id?: number;
project_id?: number project_id?: number;
project_name?: string project_name?: string;
started_at?: string started_at?: string;
ended_at?: string | null ended_at?: string | null;
hours?: string | number | null hours?: string | number | null;
minutes?: string | number | null minutes?: string | number | null;
}> }>;
} }
export const formatDate = (dateStr: string | null | undefined): string => { export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '—' if (!dateStr) return "—";
const d = new Date(dateStr) const d = new Date(dateStr);
return d.toLocaleDateString('cs-CZ') return d.toLocaleDateString("cs-CZ");
} };
/** Extract time as HH:MM from a datetime string without timezone conversion */ /** Extract time as HH:MM from a datetime string without timezone conversion */
const extractTime = (datetime: string): string => { const extractTime = (datetime: string): string => {
// Try ISO format: "2026-03-23T08:00:00.000Z" or "2026-03-23T08:00:00" // Try ISO format: "2026-03-23T08:00:00.000Z" or "2026-03-23T08:00:00"
const tMatch = datetime.match(/T(\d{2}):(\d{2})/) const tMatch = datetime.match(/T(\d{2}):(\d{2})/);
if (tMatch) return `${tMatch[1]}:${tMatch[2]}` if (tMatch) return `${tMatch[1]}:${tMatch[2]}`;
// Try space format: "2026-03-23 08:00:00" // Try space format: "2026-03-23 08:00:00"
const sMatch = datetime.match(/\s(\d{2}):(\d{2})/) const sMatch = datetime.match(/\s(\d{2}):(\d{2})/);
if (sMatch) return `${sMatch[1]}:${sMatch[2]}` if (sMatch) return `${sMatch[1]}:${sMatch[2]}`;
// Fallback: try parsing time-only "08:00" // Fallback: try parsing time-only "08:00"
const hMatch = datetime.match(/^(\d{2}):(\d{2})/) const hMatch = datetime.match(/^(\d{2}):(\d{2})/);
if (hMatch) return `${hMatch[1]}:${hMatch[2]}` if (hMatch) return `${hMatch[1]}:${hMatch[2]}`;
return datetime return datetime;
} };
export const formatDatetime = (datetime: string | null | undefined): string => { export const formatDatetime = (datetime: string | null | undefined): string => {
if (!datetime) return '—' if (!datetime) return "—";
// Extract date part without timezone conversion // Extract date part without timezone conversion
const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/) const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const datePart = dMatch ? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.` : '' const datePart = dMatch
return `${datePart} ${extractTime(datetime)}` ? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.`
} : "";
return `${datePart} ${extractTime(datetime)}`;
};
export const formatTime = (datetime: string | null | undefined): string => { export const formatTime = (datetime: string | null | undefined): string => {
if (!datetime) return '—' if (!datetime) return "—";
return extractTime(datetime) return extractTime(datetime);
} };
export const calculateWorkMinutes = (record: AttendanceRecord): number => { export const calculateWorkMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0 if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime() const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime() const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000 let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) { if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime() const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime() const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000 minutes -= (breakEnd - breakStart) / 60000;
} }
return Math.max(0, Math.floor(minutes)) return Math.max(0, Math.floor(minutes));
} };
export const formatMinutes = (minutes: number, withUnit = false): string => { export const formatMinutes = (minutes: number, withUnit = false): string => {
const h = Math.floor(minutes / 60) const h = Math.floor(minutes / 60);
const m = minutes % 60 const m = minutes % 60;
return `${h}:${String(m).padStart(2, '0')}${withUnit ? ' h' : ''}` return `${h}:${String(m).padStart(2, "0")}${withUnit ? " h" : ""}`;
} };
export const getLeaveTypeName = (type: string): string => { export const getLeaveTypeName = (type: string): string => {
const types: Record<string, string> = { const types: Record<string, string> = {
work: 'Práce', work: "Práce",
vacation: 'Dovolená', vacation: "Dovolená",
sick: 'Nemoc', sick: "Nemoc",
holiday: 'Svátek', holiday: "Svátek",
unpaid: 'Neplacené volno', unpaid: "Neplacené volno",
} };
return types[type] || 'Práce' return types[type] || "Práce";
} };
export const getLeaveTypeBadgeClass = (type: string): string => { export const getLeaveTypeBadgeClass = (type: string): string => {
const classes: Record<string, string> = { const classes: Record<string, string> = {
vacation: 'badge-vacation', vacation: "badge-vacation",
sick: 'badge-sick', sick: "badge-sick",
holiday: 'badge-holiday', holiday: "badge-holiday",
unpaid: 'badge-unpaid', unpaid: "badge-unpaid",
} };
return classes[type] || '' return classes[type] || "";
} };
export const getDatePart = (datetime: string | null | undefined): string => { export const getDatePart = (datetime: string | null | undefined): string => {
if (!datetime) return '' if (!datetime) return "";
if (datetime.includes('T')) { if (datetime.includes("T")) {
return datetime.split('T')[0] return datetime.split("T")[0];
} }
return datetime.split(' ')[0] return datetime.split(" ")[0];
} };
export const getTimePart = (datetime: string | null | undefined): string => { export const getTimePart = (datetime: string | null | undefined): string => {
if (!datetime) return '' if (!datetime) return "";
const d = new Date(datetime) const d = new Date(datetime);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` 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 => { export const calcProjectMinutesTotal = (
return logs.filter(l => l.project_id).reduce((sum, l) => { logs: Array<{
return sum + (parseInt(String(l.hours)) || 0) * 60 + (parseInt(String(l.minutes)) || 0) project_id?: number;
}, 0) 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 { interface ShiftForm {
arrival_time?: string arrival_time?: string;
departure_time?: string departure_time?: string;
arrival_date?: string arrival_date?: string;
departure_date?: string departure_date?: string;
break_start_time?: string break_start_time?: string;
break_end_time?: string break_end_time?: string;
break_start_date?: string break_start_date?: string;
break_end_date?: string break_end_date?: string;
} }
export const calcFormWorkMinutes = (form: ShiftForm): number => { export const calcFormWorkMinutes = (form: ShiftForm): number => {
if (!form.arrival_time || !form.departure_time) return 0 if (!form.arrival_time || !form.departure_time) return 0;
const arrivalStr = `${form.arrival_date}T${form.arrival_time}` const arrivalStr = `${form.arrival_date}T${form.arrival_time}`;
const departureStr = `${form.departure_date}T${form.departure_time}` const departureStr = `${form.departure_date}T${form.departure_time}`;
let mins = (new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000 let mins =
(new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000;
if (form.break_start_time && form.break_end_time) { if (form.break_start_time && form.break_end_time) {
const bsStr = `${form.break_start_date}T${form.break_start_time}` const bsStr = `${form.break_start_date}T${form.break_start_time}`;
const beStr = `${form.break_end_date}T${form.break_end_time}` const beStr = `${form.break_end_date}T${form.break_end_time}`;
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000 mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000;
} }
return Math.max(0, Math.floor(mins)) return Math.max(0, Math.floor(mins));
} };
export const formatTimeOrDatetimePrint = (datetime: string | null | undefined, shiftDate: string): string => { export const formatTimeOrDatetimePrint = (
if (!datetime) return '—' datetime: string | null | undefined,
shiftDate: string,
): string => {
if (!datetime) return "—";
// Extract date from the datetime string directly (no timezone conversion) // Extract date from the datetime string directly (no timezone conversion)
const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/) const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const timeDate = dateMatch ? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}` : '' const timeDate = dateMatch
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
: "";
if (timeDate && timeDate !== shiftDate) { if (timeDate && timeDate !== shiftDate) {
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.` const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
return `${datePart} ${extractTime(datetime)}` return `${datePart} ${extractTime(datetime)}`;
} }
return extractTime(datetime) return extractTime(datetime);
} };
export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => { export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
const leaveType = record.leave_type || 'work' const leaveType = record.leave_type || "work";
if (leaveType !== 'work') { if (leaveType !== "work") {
return (Number(record.leave_hours) || 8) * 60 return (Number(record.leave_hours) || 8) * 60;
} }
if (!record.arrival_time || !record.departure_time) return 0 if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime() const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime() const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000 let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) { if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime() const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime() const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000 minutes -= (breakEnd - breakStart) / 60000;
} }
return Math.max(0, Math.floor(minutes)) return Math.max(0, Math.floor(minutes));
} };

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import dotenv from 'dotenv'; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
// Set timezone for Date operations — all attendance/time records are in Czech local time // Set timezone for Date operations — all attendance/time records are in Czech local time
process.env.TZ = process.env.TZ || 'Europe/Prague'; process.env.TZ = process.env.TZ || "Europe/Prague";
// Override Date.toJSON to serialize as local time instead of UTC // Override Date.toJSON to serialize as local time instead of UTC
// MySQL DATETIME stores local time, Prisma creates Date objects, // MySQL DATETIME stores local time, Prisma creates Date objects,
@@ -10,11 +10,11 @@ process.env.TZ = process.env.TZ || 'Europe/Prague';
// This causes times to shift by timezone offset on the frontend. // This causes times to shift by timezone offset on the frontend.
Date.prototype.toJSON = function () { Date.prototype.toJSON = function () {
const y = this.getFullYear(); const y = this.getFullYear();
const m = String(this.getMonth() + 1).padStart(2, '0'); const m = String(this.getMonth() + 1).padStart(2, "0");
const d = String(this.getDate()).padStart(2, '0'); const d = String(this.getDate()).padStart(2, "0");
const h = String(this.getHours()).padStart(2, '0'); const h = String(this.getHours()).padStart(2, "0");
const min = String(this.getMinutes()).padStart(2, '0'); const min = String(this.getMinutes()).padStart(2, "0");
const s = String(this.getSeconds()).padStart(2, '0'); const s = String(this.getSeconds()).padStart(2, "0");
return `${y}-${m}-${d}T${h}:${min}:${s}`; return `${y}-${m}-${d}T${h}:${min}:${s}`;
}; };
@@ -25,43 +25,49 @@ function required(key: string): string {
} }
export const config = { export const config = {
port: parseInt(process.env.PORT || '3001', 10), port: parseInt(process.env.PORT || "3001", 10),
host: process.env.HOST || '127.0.0.1', host: process.env.HOST || "127.0.0.1",
appEnv: process.env.APP_ENV || 'local', appEnv: process.env.APP_ENV || "local",
isProduction: process.env.APP_ENV === 'production', isProduction: process.env.APP_ENV === "production",
db: { db: {
url: required('DATABASE_URL'), url: required("DATABASE_URL"),
}, },
jwt: { jwt: {
secret: required('JWT_SECRET'), secret: required("JWT_SECRET"),
accessTokenExpiry: parseInt(process.env.ACCESS_TOKEN_EXPIRY || '900', 10), accessTokenExpiry: parseInt(process.env.ACCESS_TOKEN_EXPIRY || "900", 10),
refreshTokenSessionExpiry: parseInt(process.env.REFRESH_TOKEN_SESSION_EXPIRY || '3600', 10), refreshTokenSessionExpiry: parseInt(
refreshTokenRememberExpiry: parseInt(process.env.REFRESH_TOKEN_REMEMBER_EXPIRY || '2592000', 10), process.env.REFRESH_TOKEN_SESSION_EXPIRY || "3600",
10,
),
refreshTokenRememberExpiry: parseInt(
process.env.REFRESH_TOKEN_REMEMBER_EXPIRY || "2592000",
10,
),
}, },
totp: { totp: {
encryptionKey: required('TOTP_ENCRYPTION_KEY'), encryptionKey: required("TOTP_ENCRYPTION_KEY"),
}, },
nas: { nas: {
path: process.env.NAS_PATH || 'Z:/02_PROJEKTY', path: process.env.NAS_PATH || "Z:/02_PROJEKTY",
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || '52428800', 10), maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10),
}, },
email: { email: {
contactTo: process.env.CONTACT_EMAIL_TO || '', contactTo: process.env.CONTACT_EMAIL_TO || "",
contactFrom: process.env.CONTACT_EMAIL_FROM || '', contactFrom: process.env.CONTACT_EMAIL_FROM || "",
smtpFrom: process.env.SMTP_FROM || '', smtpFrom: process.env.SMTP_FROM || "",
smtpFromName: process.env.SMTP_FROM_NAME || 'BOHA Automation', smtpFromName: process.env.SMTP_FROM_NAME || "BOHA Automation",
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || '', leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || "",
}, },
appUrl: process.env.APP_URL || '', appUrl: process.env.APP_URL || "",
cors: { cors: {
origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean), origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean),
}, },
security: { security: {

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,25 @@
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from "fastify";
import { config } from '../config/env'; import { config } from "../config/env";
export async function securityHeaders( export async function securityHeaders(
_request: FastifyRequest, _request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply,
): Promise<void> { ): Promise<void> {
reply.header('X-Content-Type-Options', 'nosniff'); reply.header("X-Content-Type-Options", "nosniff");
reply.header('X-Frame-Options', 'DENY'); reply.header("X-Frame-Options", "DENY");
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin'); reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)'); reply.header(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(self)",
);
if (config.isProduction) { if (config.isProduction) {
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
reply.header( reply.header(
'Content-Security-Policy', "Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
);
reply.header(
"Content-Security-Policy",
[ [
"default-src 'self'", "default-src 'self'",
"script-src 'self' https://unpkg.com", "script-src 'self' https://unpkg.com",
@@ -21,7 +27,7 @@ export async function securityHeaders(
"font-src 'self' https://fonts.gstatic.com", "font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org", "img-src 'self' data: blob: https://*.tile.openstreetmap.org",
"connect-src 'self' https://nominatim.openstreetmap.org", "connect-src 'self' https://nominatim.openstreetmap.org",
].join('; '), ].join("; "),
); );
} }
} }

View File

@@ -1,9 +1,9 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import { requireAuth, requirePermission } from '../../middleware/auth'; import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from '../../services/audit'; import { logAudit } from "../../services/audit";
import { success, error, parseId } from '../../utils/response'; import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from '../../schemas/common'; import { parseBody } from "../../schemas/common";
import { import {
AttendanceNotesSchema, AttendanceNotesSchema,
AttendanceUpdateAddressSchema, AttendanceUpdateAddressSchema,
@@ -14,124 +14,158 @@ import {
AttendancePunchSchema, AttendancePunchSchema,
CreateAttendanceSchema, CreateAttendanceSchema,
UpdateAttendanceSchema, UpdateAttendanceSchema,
} from '../../schemas/attendance.schema'; } from "../../schemas/attendance.schema";
import * as attendanceService from '../../services/attendance.service'; import * as attendanceService from "../../services/attendance.service";
export default async function attendanceRoutes(fastify: FastifyInstance): Promise<void> {
export default async function attendanceRoutes(
fastify: FastifyInstance,
): Promise<void> {
// GET /api/admin/attendance/status — clock-in/out page data // GET /api/admin/attendance/status — clock-in/out page data
fastify.get('/status', { preHandler: requireAuth }, async (request, reply) => { fastify.get(
"/status",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!; const authData = request.authData!;
const data = await attendanceService.getStatus(authData.userId); const data = await attendanceService.getStatus(authData.userId);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
}); },
);
// POST /api/admin/attendance/notes — save shift notes // POST /api/admin/attendance/notes — save shift notes
fastify.post('/notes', { preHandler: requireAuth }, async (request, reply) => { fastify.post(
"/notes",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!; const authData = request.authData!;
const parsed = parseBody(AttendanceNotesSchema, request.body); const parsed = parseBody(AttendanceNotesSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const result = await attendanceService.saveNotes(authData.userId, body.notes ? String(body.notes) : null); const result = await attendanceService.saveNotes(
if ('error' in result) return error(reply, result.error!, 400); authData.userId,
return success(reply, null, 200, 'Poznámka uložena'); body.notes ? String(body.notes) : null,
}); );
if ("error" in result) return error(reply, result.error!, 400);
return success(reply, null, 200, "Poznámka uložena");
},
);
// POST /api/admin/attendance/update-address — update GPS address after punch // POST /api/admin/attendance/update-address — update GPS address after punch
fastify.post('/update-address', { preHandler: requireAuth }, async (request, reply) => { fastify.post(
"/update-address",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!; const authData = request.authData!;
const parsed = parseBody(AttendanceUpdateAddressSchema, request.body); const parsed = parseBody(AttendanceUpdateAddressSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const result = await attendanceService.updateAddress(authData.userId, body.address ?? null, body.punch_action); const result = await attendanceService.updateAddress(
if ('error' in result) return error(reply, result.error!, 404); authData.userId,
return success(reply, null, 200, 'Adresa aktualizována'); body.address ?? null,
}); body.punch_action,
);
if ("error" in result) return error(reply, result.error!, 404);
return success(reply, null, 200, "Adresa aktualizována");
},
);
// POST /api/admin/attendance/switch-project — switch active project on current shift // POST /api/admin/attendance/switch-project — switch active project on current shift
fastify.post('/switch-project', { preHandler: requireAuth }, async (request, reply) => { fastify.post(
"/switch-project",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!; const authData = request.authData!;
const parsed = parseBody(AttendanceSwitchProjectSchema, request.body); const parsed = parseBody(AttendanceSwitchProjectSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const newProjectId = body.project_id ? Number(body.project_id) : null; const newProjectId = body.project_id ? Number(body.project_id) : null;
const result = await attendanceService.switchProject(authData.userId, newProjectId); const result = await attendanceService.switchProject(
if ('error' in result) return error(reply, result.error!, 400); authData.userId,
return success(reply, null, 200, 'Projekt přepnut'); newProjectId,
}); );
if ("error" in result) return error(reply, result.error!, 400);
return success(reply, null, 200, "Projekt přepnut");
},
);
// GET /api/admin/attendance // GET /api/admin/attendance
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => { fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const authData = request.authData!; const authData = request.authData!;
const action = query.action ? String(query.action) : null; const action = query.action ? String(query.action) : null;
// --- action=balances: leave balance overview for all users --- // --- action=balances: leave balance overview for all users ---
if (action === 'balances') { if (action === "balances") {
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getBalances(yr); const data = await attendanceService.getBalances(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
} }
// --- action=workfund: monthly work fund overview --- // --- action=workfund: monthly work fund overview ---
if (action === 'workfund') { if (action === "workfund") {
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getWorkfund(yr); const data = await attendanceService.getWorkfund(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
} }
// --- action=project_report: monthly project hours --- // --- action=project_report: monthly project hours ---
if (action === 'project_report') { if (action === "project_report") {
const yr = Number(query.year) || new Date().getFullYear(); const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getProjectReport(yr); const data = await attendanceService.getProjectReport(yr);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
} }
// --- action=print: attendance print data for admin --- // --- action=print: attendance print data for admin ---
if (action === 'print') { if (action === "print") {
if (!authData.permissions.includes('attendance.admin')) { if (!authData.permissions.includes("attendance.admin")) {
return error(reply, 'Nedostatečná oprávnění', 403); return error(reply, "Nedostatečná oprávnění", 403);
} }
const monthStr = query.month ? String(query.month) : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; const monthStr = query.month
? String(query.month)
: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
const filterUserId = query.user_id ? Number(query.user_id) : null; const filterUserId = query.user_id ? Number(query.user_id) : null;
const data = await attendanceService.getPrintData(monthStr, filterUserId); const data = await attendanceService.getPrintData(monthStr, filterUserId);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
} }
// --- action=projects: active projects for attendance project switching --- // --- action=projects: active projects for attendance project switching ---
if (action === 'projects') { if (action === "projects") {
const data = await attendanceService.getActiveProjects(); const data = await attendanceService.getActiveProjects();
return reply.send({ success: true, data }); return reply.send({ success: true, data });
} }
// --- action=project_logs: get project logs for a specific attendance record --- // --- action=project_logs: get project logs for a specific attendance record ---
if (action === 'project_logs') { if (action === "project_logs") {
const attendanceId = Number(query.attendance_id); const attendanceId = Number(query.attendance_id);
if (!attendanceId) return error(reply, 'Missing attendance_id', 400); if (!attendanceId) return error(reply, "Missing attendance_id", 400);
const data = await attendanceService.getProjectLogs(attendanceId); const data = await attendanceService.getProjectLogs(attendanceId);
return reply.send({ success: true, data }); return reply.send({ success: true, data });
} }
// --- action=location: single record with GPS data --- // --- action=location: single record with GPS data ---
if (action === 'location') { if (action === "location") {
const id = Number(query.id); const id = Number(query.id);
if (!id) return error(reply, 'Missing id', 400); if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id); const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, 'Záznam nenalezen', 404); if (!record) return error(reply, "Záznam nenalezen", 404);
return reply.send({ success: true, data: record }); return reply.send({ success: true, data: record });
} }
// --- Default: paginated records list --- // --- Default: paginated records list ---
const { page, limit, skip, order } = parsePagination(query); const { page, limit, skip, order } = parsePagination(query);
const isAdmin = authData.permissions.includes('attendance.admin'); const isAdmin = authData.permissions.includes("attendance.admin");
const userId = query.user_id ? Number(query.user_id) : undefined; const userId = query.user_id ? Number(query.user_id) : undefined;
const result = await attendanceService.listAttendance({ const result = await attendanceService.listAttendance({
page, limit, skip, order, userId, isAdmin, page,
limit,
skip,
order,
userId,
isAdmin,
authUserId: authData.userId, authUserId: authData.userId,
month: query.month ? Number(query.month) : undefined, month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined, year: query.year ? Number(query.year) : undefined,
@@ -145,19 +179,19 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
}); });
// POST /api/admin/attendance // POST /api/admin/attendance
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => { fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
const rawBody = request.body as Record<string, unknown>; const rawBody = request.body as Record<string, unknown>;
const authData = request.authData!; const authData = request.authData!;
const postQuery = request.query as Record<string, unknown>; const postQuery = request.query as Record<string, unknown>;
// --- action=balances: edit or reset leave balance --- // --- action=balances: edit or reset leave balance ---
if (postQuery.action === 'balances') { if (postQuery.action === "balances") {
if (!authData.permissions.includes('attendance.balances')) { if (!authData.permissions.includes("attendance.balances")) {
return error(reply, 'Nedostatečná oprávnění', 403); return error(reply, "Nedostatečná oprávnění", 403);
} }
const balParsed = parseBody(AttendanceBalancesSchema, rawBody); const balParsed = parseBody(AttendanceBalancesSchema, rawBody);
if ('error' in balParsed) return error(reply, balParsed.error, 400); if ("error" in balParsed) return error(reply, balParsed.error, 400);
const balBody = balParsed.data; const balBody = balParsed.data;
const result = await attendanceService.handleBalances({ const result = await attendanceService.handleBalances({
@@ -169,12 +203,15 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
sick_used: balBody.sick_used, sick_used: balBody.sick_used,
}); });
if ('error' in result) return error(reply, result.error!, 400); if ("error" in result) return error(reply, result.error!, 400);
await logAudit({ await logAudit({
request, authData, action: 'update', entityType: 'leave_balance', request,
authData,
action: "update",
entityType: "leave_balance",
entityId: balBody.user_id, entityId: balBody.user_id,
description: result.message.includes('resetována') description: result.message.includes("resetována")
? `Resetována bilance pro rok ${result.year}` ? `Resetována bilance pro rok ${result.year}`
: `Upravena bilance dovolené pro rok ${result.year}`, : `Upravena bilance dovolené pro rok ${result.year}`,
}); });
@@ -182,13 +219,13 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
} }
// --- action=bulk_attendance: bulk fill month --- // --- action=bulk_attendance: bulk fill month ---
if (postQuery.action === 'bulk_attendance') { if (postQuery.action === "bulk_attendance") {
if (!authData.permissions.includes('attendance.admin')) { if (!authData.permissions.includes("attendance.admin")) {
return error(reply, 'Nedostatečná oprávnění', 403); return error(reply, "Nedostatečná oprávnění", 403);
} }
const bulkParsed = parseBody(AttendanceBulkSchema, rawBody); const bulkParsed = parseBody(AttendanceBulkSchema, rawBody);
if ('error' in bulkParsed) return error(reply, bulkParsed.error, 400); if ("error" in bulkParsed) return error(reply, bulkParsed.error, 400);
const bulkBody = bulkParsed.data; const bulkBody = bulkParsed.data;
const result = await attendanceService.bulkCreateAttendance({ const result = await attendanceService.bulkCreateAttendance({
@@ -201,36 +238,48 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
}); });
await logAudit({ await logAudit({
request, authData, action: 'create', entityType: 'attendance', request,
entityId: 0, description: `Hromadně vytvořeno ${result.inserted} záznamů docházky pro ${bulkBody.month}`, authData,
action: "create",
entityType: "attendance",
entityId: 0,
description: `Hromadně vytvořeno ${result.inserted} záznamů docházky pro ${bulkBody.month}`,
}); });
return success(reply, { inserted: result.inserted, skipped: result.skipped }, 200, result.message); return success(
reply,
{ inserted: result.inserted, skipped: result.skipped },
200,
result.message,
);
} }
// --- action=leave: add leave record directly --- // --- action=leave: add leave record directly ---
if (postQuery.action === 'leave') { if (postQuery.action === "leave") {
const leaveParsed = parseBody(AttendanceLeaveSchema, rawBody); const leaveParsed = parseBody(AttendanceLeaveSchema, rawBody);
if ('error' in leaveParsed) return error(reply, leaveParsed.error, 400); if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
const leaveBody = leaveParsed.data; const leaveBody = leaveParsed.data;
const result = await attendanceService.createLeave({ const result = await attendanceService.createLeave(
{
user_id: leaveBody.user_id, user_id: leaveBody.user_id,
date_from: leaveBody.date_from, date_from: leaveBody.date_from,
date_to: leaveBody.date_to, date_to: leaveBody.date_to,
leave_type: leaveBody.leave_type, leave_type: leaveBody.leave_type,
leave_hours: leaveBody.leave_hours, leave_hours: leaveBody.leave_hours,
notes: leaveBody.notes ?? undefined, notes: leaveBody.notes ?? undefined,
}, authData.userId); },
authData.userId,
);
if ('error' in result) return error(reply, result.error!, 400); if ("error" in result) return error(reply, result.error!, 400);
return success(reply, { created: result.created }, 200, result.message!); return success(reply, { created: result.created }, 200, result.message!);
} }
// Punch action (arrival / departure / break_start) from Dashboard or Attendance page // Punch action (arrival / departure / break_start) from Dashboard or Attendance page
if (rawBody.punch_action) { if (rawBody.punch_action) {
const punchParsed = parseBody(AttendancePunchSchema, rawBody); const punchParsed = parseBody(AttendancePunchSchema, rawBody);
if ('error' in punchParsed) return error(reply, punchParsed.error, 400); if ("error" in punchParsed) return error(reply, punchParsed.error, 400);
const punchBody = punchParsed.data; const punchBody = punchParsed.data;
const result = await attendanceService.punchAction(authData.userId, { const result = await attendanceService.punchAction(authData.userId, {
@@ -241,21 +290,26 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
address: punchBody.address, address: punchBody.address,
}); });
if ('error' in result) return error(reply, result.error!, 400); if ("error" in result) return error(reply, result.error!, 400);
await logAudit({ await logAudit({
request, authData, action: result.auditAction, entityType: 'attendance', request,
entityId: result.id, description: result.auditDescription, authData,
action: result.auditAction,
entityType: "attendance",
entityId: result.id,
description: result.auditDescription,
}); });
return success(reply, { id: result.id }, result.status, result.message); return success(reply, { id: result.id }, result.status, result.message);
} }
// Standard attendance record creation (from admin forms) // Standard attendance record creation (from admin forms)
const stdParsed = parseBody(CreateAttendanceSchema, rawBody); const stdParsed = parseBody(CreateAttendanceSchema, rawBody);
if ('error' in stdParsed) return error(reply, stdParsed.error, 400); if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
const body = stdParsed.data; const body = stdParsed.data;
const result = await attendanceService.createAttendance({ const result = await attendanceService.createAttendance(
{
user_id: body.user_id, user_id: body.user_id,
shift_date: body.shift_date, shift_date: body.shift_date,
arrival_time: body.arrival_time, arrival_time: body.arrival_time,
@@ -273,74 +327,95 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
leave_type: body.leave_type, leave_type: body.leave_type,
leave_hours: body.leave_hours, leave_hours: body.leave_hours,
project_logs: body.project_logs, project_logs: body.project_logs,
}, authData.userId); },
authData.userId,
);
await logAudit({ await logAudit({
request, request,
authData, authData,
action: 'create', action: "create",
entityType: 'attendance', entityType: "attendance",
entityId: result.id, entityId: result.id,
description: `Vytvořen záznam docházky`, description: `Vytvořen záznam docházky`,
}); });
return success(reply, { id: result.id }, 201, 'Záznam byl vytvořen'); return success(reply, { id: result.id }, 201, "Záznam byl vytvořen");
}); });
// PUT /api/admin/attendance/:id // PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => { fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const parsed = parseBody(UpdateAttendanceSchema, request.body); const parsed = parseBody(UpdateAttendanceSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const authData = request.authData!; const authData = request.authData!;
const isAdmin = authData.permissions.includes('attendance.admin'); const isAdmin = authData.permissions.includes("attendance.admin");
const result = await attendanceService.updateAttendance(id, { const result = await attendanceService.updateAttendance(
id,
{
arrival_time: body.arrival_time, arrival_time: body.arrival_time,
departure_time: body.departure_time, departure_time: body.departure_time,
break_start: body.break_start, break_start: body.break_start,
break_end: body.break_end, break_end: body.break_end,
notes: body.notes ?? undefined, notes: body.notes ?? undefined,
project_id: body.project_id != null ? Number(body.project_id) : (body.project_id as number | null | undefined), project_id:
body.project_id != null
? Number(body.project_id)
: (body.project_id as number | null | undefined),
leave_type: body.leave_type, leave_type: body.leave_type,
leave_hours: body.leave_hours != null ? Number(body.leave_hours) : (body.leave_hours as number | null | undefined), leave_hours:
body.leave_hours != null
? Number(body.leave_hours)
: (body.leave_hours as number | null | undefined),
project_logs: body.project_logs, project_logs: body.project_logs,
}, authData.userId, isAdmin); },
authData.userId,
isAdmin,
);
if ('error' in result) return error(reply, result.error!, result.status!); if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: 'update', action: "update",
entityType: 'attendance', entityType: "attendance",
entityId: id, entityId: id,
description: `Upraven záznam docházky`, description: `Upraven záznam docházky`,
}); });
return success(reply, { id }, 200, 'Záznam byl aktualizován'); return success(reply, { id }, 200, "Záznam byl aktualizován");
}); },
);
// DELETE /api/admin/attendance/:id // DELETE /api/admin/attendance/:id
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('attendance.admin') }, async (request, reply) => { fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.admin") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const result = await attendanceService.deleteAttendance(id); const result = await attendanceService.deleteAttendance(id);
if ('error' in result) return error(reply, result.error!, 404); if ("error" in result) return error(reply, result.error!, 404);
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: 'delete', action: "delete",
entityType: 'attendance', entityType: "attendance",
entityId: id, entityId: id,
description: `Smazán záznam docházky`, description: `Smazán záznam docházky`,
}); });
return success(reply, null, 200, 'Záznam smazán'); return success(reply, null, 200, "Záznam smazán");
}); },
);
} }

View File

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

View File

@@ -1,65 +1,82 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import { login, refreshAccessToken, logout, verifyAccessToken } from '../../services/auth'; import {
import { logAudit } from '../../services/audit'; login,
import { success, error } from '../../utils/response'; refreshAccessToken,
import { config } from '../../config/env'; logout,
import { LoginRequest, TotpVerifyRequest } from '../../types'; verifyAccessToken,
import prisma from '../../config/database'; } from "../../services/auth";
import crypto from 'crypto'; import { logAudit } from "../../services/audit";
import { OTPAuth } from '../../utils/totp'; import { success, error } from "../../utils/response";
import { parseBody } from '../../schemas/common'; import { config } from "../../config/env";
import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema'; import { LoginRequest, TotpVerifyRequest } from "../../types";
import prisma from "../../config/database";
import crypto from "crypto";
import { OTPAuth } from "../../utils/totp";
import { parseBody } from "../../schemas/common";
import { LoginSchema, TotpVerifySchema } from "../../schemas/auth.schema";
function setRefreshCookie(reply: import('fastify').FastifyReply, token: string, rememberMe: boolean) { function setRefreshCookie(
reply: import("fastify").FastifyReply,
token: string,
rememberMe: boolean,
) {
const maxAge = rememberMe const maxAge = rememberMe
? config.jwt.refreshTokenRememberExpiry ? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry; : config.jwt.refreshTokenSessionExpiry;
reply.setCookie('refresh_token', token, { reply.setCookie("refresh_token", token, {
httpOnly: true, httpOnly: true,
secure: config.isProduction, secure: config.isProduction,
sameSite: 'strict', sameSite: "strict",
path: '/api/admin', path: "/api/admin",
maxAge, maxAge,
}); });
} }
export default async function authRoutes(fastify: FastifyInstance): Promise<void> { export default async function authRoutes(
fastify: FastifyInstance,
): Promise<void> {
// POST /api/admin/login // POST /api/admin/login
fastify.post<{ Body: LoginRequest }>('/login', { fastify.post<{ Body: LoginRequest }>(
"/login",
{
config: { config: {
rateLimit: { rateLimit: {
max: 20, max: 20,
timeWindow: '1 minute', timeWindow: "1 minute",
}, },
}, },
bodyLimit: 10240, bodyLimit: 10240,
}, async (request, reply) => { },
async (request, reply) => {
const parsed = parseBody(LoginSchema, request.body); const parsed = parseBody(LoginSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const { username, password, remember_me } = parsed.data; const { username, password, remember_me } = parsed.data;
const result = await login(username, password, remember_me, request); const result = await login(username, password, remember_me, request);
if (result.type === 'error') { if (result.type === "error") {
await logAudit({ await logAudit({
request, request,
action: 'login_failed', action: "login_failed",
entityType: 'user', entityType: "user",
description: `Neúspěšný pokus o přihlášení: ${username}`, description: `Neúspěšný pokus o přihlášení: ${username}`,
}); });
return error(reply, result.message, result.status); return error(reply, result.message, result.status);
} }
if (result.type === 'totp_required') { if (result.type === "totp_required") {
return success(reply, { totp_required: true, login_token: result.loginToken }); return success(reply, {
totp_required: true,
login_token: result.loginToken,
});
} }
await logAudit({ await logAudit({
request, request,
authData: result.user, authData: result.user,
action: 'login', action: "login",
entityType: 'user', entityType: "user",
entityId: result.user.userId, entityId: result.user.userId,
description: `Přihlášení uživatele ${result.user.username}`, description: `Přihlášení uživatele ${result.user.username}`,
}); });
@@ -69,24 +86,32 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
access_token: result.accessToken, access_token: result.accessToken,
user: result.user, user: result.user,
}); });
}); },
);
// POST /api/admin/login/totp // POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>('/login/totp', { bodyLimit: 10240 }, async (request, reply) => { fastify.post<{ Body: TotpVerifyRequest }>(
"/login/totp",
{ bodyLimit: 10240 },
async (request, reply) => {
const parsed = parseBody(TotpVerifySchema, request.body); const parsed = parseBody(TotpVerifySchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const { login_token, totp_code } = parsed.data; const { login_token, totp_code } = parsed.data;
const rawBody = request.body as unknown as Record<string, unknown>; const rawBody = request.body as unknown as Record<string, unknown>;
const rememberMe = rawBody.remember_me === true || rawBody.remember_me === 'true'; const rememberMe =
rawBody.remember_me === true || rawBody.remember_me === "true";
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex'); const tokenHash = crypto
.createHash("sha256")
.update(login_token)
.digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({ const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash }, where: { token_hash: tokenHash },
}); });
if (!storedToken || new Date(storedToken.expires_at) < new Date()) { if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, 'Neplatný nebo expirovaný login token', 401); return error(reply, "Neplatný nebo expirovaný login token", 401);
} }
const user = await prisma.users.findUnique({ const user = await prisma.users.findUnique({
@@ -95,12 +120,12 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
}); });
if (!user || !user.totp_secret) { if (!user || !user.totp_secret) {
return error(reply, 'Uživatel nenalezen', 401); return error(reply, "Uživatel nenalezen", 401);
} }
const isValid = OTPAuth.verify(user.totp_secret, totp_code); const isValid = OTPAuth.verify(user.totp_secret, totp_code);
if (!isValid) { if (!isValid) {
return error(reply, 'Neplatný TOTP kód', 401); return error(reply, "Neplatný TOTP kód", 401);
} }
// Delete used login token // Delete used login token
@@ -109,25 +134,38 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
// Reset failed attempts and update last login (TOTP verified = successful login) // Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({ await prisma.users.update({
where: { id: user.id }, where: { id: user.id },
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() }, data: {
failed_login_attempts: 0,
locked_until: null,
last_login: new Date(),
},
}); });
// Create tokens directly — password was already verified before TOTP was requested // Create tokens directly — password was already verified before TOTP was requested
const authData = await (await import('../../services/auth')).loadAuthData(user.id); const authData = await (
await import("../../services/auth")
).loadAuthData(user.id);
if (!authData) { if (!authData) {
return error(reply, 'Chyba načítání uživatele', 500); return error(reply, "Chyba načítání uživatele", 500);
} }
// Create tokens manually since password was already verified // Create tokens manually since password was already verified
const jwt = await import('jsonwebtoken'); const jwt = await import("jsonwebtoken");
const accessToken = jwt.default.sign( const accessToken = jwt.default.sign(
{ sub: user.id, username: user.username, role: user.roles?.name ?? null }, {
sub: user.id,
username: user.username,
role: user.roles?.name ?? null,
},
config.jwt.secret, config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry }, { expiresIn: config.jwt.accessTokenExpiry },
); );
const refreshTokenRaw = crypto.randomBytes(32).toString('hex'); const refreshTokenRaw = crypto.randomBytes(32).toString("hex");
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex'); const refreshTokenHash = crypto
.createHash("sha256")
.update(refreshTokenRaw)
.digest("hex");
const expiresIn = rememberMe const expiresIn = rememberMe
? config.jwt.refreshTokenRememberExpiry ? config.jwt.refreshTokenRememberExpiry
@@ -140,25 +178,31 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
expires_at: new Date(Date.now() + expiresIn * 1000), expires_at: new Date(Date.now() + expiresIn * 1000),
remember_me: rememberMe, remember_me: rememberMe,
ip_address: request.ip, ip_address: request.ip,
user_agent: request.headers['user-agent'] ?? null, user_agent: request.headers["user-agent"] ?? null,
}, },
}); });
setRefreshCookie(reply, refreshTokenRaw, rememberMe); setRefreshCookie(reply, refreshTokenRaw, rememberMe);
return success(reply, { access_token: accessToken, user: authData }); return success(reply, { access_token: accessToken, user: authData });
}); },
);
// POST /api/admin/refresh // POST /api/admin/refresh
fastify.post('/refresh', { bodyLimit: 10240 }, async (request, reply) => { fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token; const refreshTokenRaw = request.cookies.refresh_token;
if (!refreshTokenRaw) { if (!refreshTokenRaw) {
return error(reply, 'Refresh token chybí', 401); return error(reply, "Refresh token chybí", 401);
} }
const result = await refreshAccessToken(refreshTokenRaw, request); const result = await refreshAccessToken(refreshTokenRaw, request);
if (result.type === 'error') { if (result.type === "error") {
reply.clearCookie('refresh_token', { path: '/api/admin', httpOnly: true, secure: config.isProduction, sameSite: 'strict' }); reply.clearCookie("refresh_token", {
path: "/api/admin",
httpOnly: true,
secure: config.isProduction,
sameSite: "strict",
});
return error(reply, result.message, result.status); return error(reply, result.message, result.status);
} }
@@ -171,28 +215,33 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
}); });
// POST /api/admin/logout // POST /api/admin/logout
fastify.post('/logout', async (request, reply) => { fastify.post("/logout", async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token; const refreshTokenRaw = request.cookies.refresh_token;
if (refreshTokenRaw) { if (refreshTokenRaw) {
await logout(refreshTokenRaw); await logout(refreshTokenRaw);
} }
reply.clearCookie('refresh_token', { path: '/api/admin', httpOnly: true, secure: config.isProduction, sameSite: 'strict' }); reply.clearCookie("refresh_token", {
return success(reply, null, 200, 'Odhlášení úspěšné'); path: "/api/admin",
httpOnly: true,
secure: config.isProduction,
sameSite: "strict",
});
return success(reply, null, 200, "Odhlášení úspěšné");
}); });
// GET /api/admin/session // GET /api/admin/session
fastify.get('/session', async (request, reply) => { fastify.get("/session", async (request, reply) => {
const authHeader = request.headers.authorization; const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith("Bearer ")) {
return error(reply, 'Vyžadována autentizace', 401); return error(reply, "Vyžadována autentizace", 401);
} }
const token = authHeader.slice(7); const token = authHeader.slice(7);
const authData = await verifyAccessToken(token); const authData = await verifyAccessToken(token);
if (!authData) { if (!authData) {
return error(reply, 'Neplatný token', 401); return error(reply, "Neplatný token", 401);
} }
return success(reply, { user: authData }); return success(reply, { user: authData });

View File

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

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

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

@@ -1,13 +1,23 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import prisma from '../../config/database'; import prisma from "../../config/database";
import { requireAuth } from '../../middleware/auth'; import { requireAuth } from "../../middleware/auth";
import { success } from '../../utils/response'; import { success } from "../../utils/response";
export default async function dashboardRoutes(fastify: FastifyInstance): Promise<void> { export default async function dashboardRoutes(
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => { fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const now = new Date(); const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStart = new Date(
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); 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 monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const authData = request.authData!; const authData = request.authData!;
@@ -18,65 +28,87 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
// My shift — always available for authenticated users with attendance.record // My shift — always available for authenticated users with attendance.record
if (has('attendance.record')) { if (has("attendance.record")) {
const myShift = await prisma.attendance.findFirst({ const myShift = await prisma.attendance.findFirst({
where: { user_id: userId, arrival_time: { not: null }, departure_time: null }, where: {
orderBy: { created_at: 'desc' }, user_id: userId,
arrival_time: { not: null },
departure_time: null,
},
orderBy: { created_at: "desc" },
}); });
result.my_shift = { has_ongoing: myShift !== null }; result.my_shift = { has_ongoing: myShift !== null };
} }
// Attendance admin — only for attendance.admin // Attendance admin — only for attendance.admin
if (has('attendance.admin')) { if (has("attendance.admin")) {
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([ const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
prisma.attendance.findMany({ prisma.attendance.findMany({
where: { where: {
shift_date: { gte: todayStart, lt: todayEnd }, shift_date: { gte: todayStart, lt: todayEnd },
OR: [{ leave_type: null }, { leave_type: 'work' }], OR: [{ leave_type: null }, { leave_type: "work" }],
}, },
include: { users: { select: { id: true, first_name: true, last_name: true } } }, include: {
orderBy: { arrival_time: 'asc' }, users: { select: { id: true, first_name: true, last_name: true } },
},
orderBy: { arrival_time: "asc" },
}), }),
prisma.attendance.findMany({ prisma.attendance.findMany({
where: { where: {
shift_date: { gte: todayStart, lt: todayEnd }, shift_date: { gte: todayStart, lt: todayEnd },
leave_type: { in: ['vacation', 'sick', 'holiday', 'unpaid'] }, leave_type: { in: ["vacation", "sick", "holiday", "unpaid"] },
},
include: {
users: { select: { id: true, first_name: true, last_name: true } },
}, },
include: { users: { select: { id: true, first_name: true, last_name: true } } },
}), }),
prisma.users.count({ where: { is_active: true } }), prisma.users.count({ where: { is_active: true } }),
]); ]);
const userAttendanceMap = new Map<number, typeof todayAttendance[0]>(); const userAttendanceMap = new Map<number, (typeof todayAttendance)[0]>();
for (const a of todayAttendance) { for (const a of todayAttendance) {
const existing = userAttendanceMap.get(a.users.id); const existing = userAttendanceMap.get(a.users.id);
if (!existing || (a.arrival_time && existing.arrival_time && a.arrival_time > existing.arrival_time)) { if (
!existing ||
(a.arrival_time &&
existing.arrival_time &&
a.arrival_time > existing.arrival_time)
) {
userAttendanceMap.set(a.users.id, a); userAttendanceMap.set(a.users.id, a);
} }
} }
let presentCount = 0; let presentCount = 0;
const attendanceUsers: Array<{ const attendanceUsers: Array<{
user_id: number; name: string; initials: string; user_id: number;
status: string; arrived_at: string | null; leave_type?: string; name: string;
initials: string;
status: string;
arrived_at: string | null;
leave_type?: string;
}> = []; }> = [];
for (const a of userAttendanceMap.values()) { for (const a of userAttendanceMap.values()) {
const user = a.users; const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? ''; const firstInitial = user.first_name?.charAt(0) ?? "";
const lastInitial = user.last_name?.charAt(0) ?? ''; const lastInitial = user.last_name?.charAt(0) ?? "";
let status = 'out'; let status = "out";
if (a.arrival_time) { if (a.arrival_time) {
if (a.departure_time) status = 'out'; if (a.departure_time) status = "out";
else if (a.break_start && !a.break_end) status = 'away'; else if (a.break_start && !a.break_end) status = "away";
else { status = 'in'; presentCount++; } else {
status = "in";
presentCount++;
}
} }
attendanceUsers.push({ attendanceUsers.push({
user_id: user.id, user_id: user.id,
name: `${user.first_name} ${user.last_name}`, name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(), initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status, status,
arrived_at: a.arrival_time ? `${String(a.arrival_time.getHours()).padStart(2, '0')}:${String(a.arrival_time.getMinutes()).padStart(2, '0')}` : null, arrived_at: a.arrival_time
? `${String(a.arrival_time.getHours()).padStart(2, "0")}:${String(a.arrival_time.getMinutes()).padStart(2, "0")}`
: null,
}); });
} }
@@ -88,10 +120,11 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
attendanceUsers.push({ attendanceUsers.push({
user_id: user.id, user_id: user.id,
name: `${user.first_name} ${user.last_name}`, name: `${user.first_name} ${user.last_name}`,
initials: `${user.first_name?.charAt(0) ?? ''}${user.last_name?.charAt(0) ?? ''}`.toUpperCase(), initials:
status: 'leave', `${user.first_name?.charAt(0) ?? ""}${user.last_name?.charAt(0) ?? ""}`.toUpperCase(),
status: "leave",
arrived_at: null, arrived_at: null,
leave_type: (a.leave_type as string) || 'vacation', leave_type: (a.leave_type as string) || "vacation",
}); });
} }
@@ -105,39 +138,49 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
} }
// Offers — only for offers.view // Offers — only for offers.view
if (has('offers.view')) { if (has("offers.view")) {
const [openCount, convertedCount, expiredCount, createdThisMonth] = await Promise.all([ const [openCount, convertedCount, expiredCount, createdThisMonth] =
prisma.quotations.count({ where: { status: 'active' } }), await Promise.all([
prisma.quotations.count({ where: { status: 'converted' } }), prisma.quotations.count({ where: { status: "active" } }),
prisma.quotations.count({ where: { status: 'expired' } }), prisma.quotations.count({ where: { status: "converted" } }),
prisma.quotations.count({ where: { created_at: { gte: monthStart, lt: monthEnd } } }), prisma.quotations.count({ where: { status: "expired" } }),
prisma.quotations.count({
where: { created_at: { gte: monthStart, lt: monthEnd } },
}),
]); ]);
result.offers = { open_count: openCount, converted_count: convertedCount, expired_count: expiredCount, created_this_month: createdThisMonth }; result.offers = {
open_count: openCount,
converted_count: convertedCount,
expired_count: expiredCount,
created_this_month: createdThisMonth,
};
} }
// Projects — only for projects.view // Projects — only for projects.view
if (has('projects.view')) { if (has("projects.view")) {
const [activeCount, activeList] = await Promise.all([ const [activeCount, activeList] = await Promise.all([
prisma.projects.count({ where: { status: 'aktivni' } }), prisma.projects.count({ where: { status: "aktivni" } }),
prisma.projects.findMany({ prisma.projects.findMany({
where: { status: 'aktivni' }, where: { status: "aktivni" },
include: { customers: { select: { name: true } } }, include: { customers: { select: { name: true } } },
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: 5, take: 5,
}), }),
]); ]);
result.active_projects = activeCount; result.active_projects = activeCount;
result.projects = { result.projects = {
active_projects: activeList.map(p => ({ active_projects: activeList.map((p) => ({
id: p.id, name: p.name ?? '', customer_name: p.customers?.name ?? null, id: p.id,
name: p.name ?? "",
customer_name: p.customers?.name ?? null,
})), })),
}; };
} }
// Invoices — only for invoices.view // Invoices — only for invoices.view
if (has('invoices.view')) { if (has("invoices.view")) {
const [unpaidCount, issuedThisMonth] = await Promise.all([ const [unpaidCount, issuedThisMonth] = await Promise.all([
prisma.invoices.count({ where: { status: 'issued' } }), prisma.invoices.count({ where: { status: "issued" } }),
prisma.invoices.findMany({ prisma.invoices.findMany({
where: { issue_date: { gte: monthStart, lt: monthEnd } }, where: { issue_date: { gte: monthStart, lt: monthEnd } },
include: { invoice_items: true }, include: { invoice_items: true },
@@ -146,48 +189,70 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
const revenueByCurrency: Record<string, number> = {}; const revenueByCurrency: Record<string, number> = {};
for (const inv of issuedThisMonth) { for (const inv of issuedThisMonth) {
const currency = inv.currency ?? 'CZK'; const currency = inv.currency ?? "CZK";
let total = 0; let total = 0;
for (const item of inv.invoice_items) { for (const item of inv.invoice_items) {
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); total +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
} }
revenueByCurrency[currency] = (revenueByCurrency[currency] ?? 0) + total; revenueByCurrency[currency] =
(revenueByCurrency[currency] ?? 0) + total;
} }
result.invoices = { result.invoices = {
revenue_this_month: Object.entries(revenueByCurrency).map(([currency, amount]) => ({ revenue_this_month: Object.entries(revenueByCurrency).map(
amount: Math.round(amount * 100) / 100, currency, ([currency, amount]) => ({
})), amount: Math.round(amount * 100) / 100,
currency,
}),
),
unpaid_count: unpaidCount, unpaid_count: unpaidCount,
revenue_czk: revenueByCurrency['CZK'] != null ? Math.round(revenueByCurrency['CZK'] * 100) / 100 : null, revenue_czk:
revenueByCurrency["CZK"] != null
? Math.round(revenueByCurrency["CZK"] * 100) / 100
: null,
}; };
result.unpaid_invoices = unpaidCount; result.unpaid_invoices = unpaidCount;
} }
// Orders — only for orders.view // Orders — only for orders.view
if (has('orders.view')) { if (has("orders.view")) {
result.pending_orders = await prisma.orders.count({ where: { status: 'prijata' } }); result.pending_orders = await prisma.orders.count({
where: { status: "prijata" },
});
} }
// Leave pending — only for attendance.approve // Leave pending — only for attendance.approve
if (has('attendance.approve')) { if (has("attendance.approve")) {
const count = await prisma.leave_requests.count({ where: { status: 'pending' } }); const count = await prisma.leave_requests.count({
where: { status: "pending" },
});
result.leave_pending = { count }; result.leave_pending = { count };
result.pending_leave_requests = count; result.pending_leave_requests = count;
} }
// Recent activity — only for settings.audit (admin) // Recent activity — only for settings.audit (admin)
if (has('settings.audit')) { if (has("settings.audit")) {
const logs = await prisma.audit_logs.findMany({ const logs = await prisma.audit_logs.findMany({
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: 8, take: 8,
where: { action: { in: ['create', 'update', 'delete', 'login'] } }, where: { action: { in: ["create", "update", "delete", "login"] } },
select: { id: true, action: true, entity_type: true, description: true, username: true, created_at: true }, select: {
id: true,
action: true,
entity_type: true,
description: true,
username: true,
created_at: true,
},
}); });
result.recent_activity = logs.map(log => ({ result.recent_activity = logs.map((log) => ({
id: log.id, action: log.action, entity_type: log.entity_type ?? '', id: log.id,
description: log.description ?? '', username: log.username ?? null, action: log.action,
created_at: log.created_at ? log.created_at.toISOString() : '', entity_type: log.entity_type ?? "",
description: log.description ?? "",
username: log.username ?? null,
created_at: log.created_at ? log.created_at.toISOString() : "",
})); }));
} }

View File

@@ -1,69 +1,85 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import QRCode from 'qrcode'; import QRCode from "qrcode";
import prisma from '../../config/database'; import prisma from "../../config/database";
import { requirePermission } from '../../middleware/auth'; import { requirePermission } from "../../middleware/auth";
/* ── Helpers ─────────────────────────────────────────────────────── */ /* ── Helpers ─────────────────────────────────────────────────────── */
function formatDate(date: Date | string | null | undefined): string { function formatDate(date: Date | string | null | undefined): string {
if (!date) return ''; if (!date) return "";
const d = new Date(date); const d = new Date(date);
if (isNaN(d.getTime())) return String(date); if (isNaN(d.getTime())) return String(date);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
} }
function formatNum(n: number, decimals = 2): string { function formatNum(n: number, decimals = 2): string {
const abs = Math.abs(n); const abs = Math.abs(n);
const fixed = abs.toFixed(decimals); const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split('.'); const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0'); const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
const result = decPart ? `${withSep},${decPart}` : withSep; const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result; return n < 0 ? `-${result}` : result;
} }
function escapeHtml(str: string | null | undefined): string { function escapeHtml(str: string | null | undefined): string {
if (!str) return ''; if (!str) return "";
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
function cleanQuillHtml(html: string | null | undefined): string { function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return ''; if (!html) return "";
let s = html; let s = html;
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, ''); s = s.replace(
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, ''); /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ''); "",
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ''); );
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, ' '); s = s.replace(/(&nbsp;)/g, " ");
let prev = ''; let prev = "";
while (prev !== s) { while (prev !== s) {
prev = s; prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2'); s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
} }
return s; return s;
} }
interface AddressResult { name: string; lines: string[] } interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines( function buildAddressLines(
entity: Record<string, unknown> | null, entity: Record<string, unknown> | null,
isSupplier: boolean, isSupplier: boolean,
tObj: Record<string, string>, tObj: Record<string, string>,
): AddressResult { ): AddressResult {
if (!entity) return { name: '', lines: [] }; if (!entity) return { name: "", lines: [] };
const nameKey = isSupplier ? 'company_name' : 'name'; const nameKey = isSupplier ? "company_name" : "name";
const name = String(entity[nameKey] || ''); const name = String(entity[nameKey] || "");
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = []; let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
[];
let fieldOrder: string[] | null = null; let fieldOrder: string[] | null = null;
const raw = entity.custom_fields; const raw = entity.custom_fields;
if (raw) { if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) { if ((parsed as Record<string, unknown>).fields) {
cfData = ((parsed as Record<string, unknown>).fields as typeof cfData) || []; cfData =
fieldOrder = ((parsed as Record<string, unknown>).field_order || (parsed as Record<string, unknown>).fieldOrder) as string[] | null; ((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) { } else if (Array.isArray(parsed)) {
cfData = parsed; cfData = parsed;
} }
@@ -72,29 +88,37 @@ function buildAddressLines(
if (Array.isArray(fieldOrder)) { if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = { const legacyMap: Record<string, string> = {
Name: 'name', CompanyName: 'company_name', Name: "name",
Street: 'street', CityPostal: 'city_postal', CompanyName: "company_name",
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id', Street: "street",
CityPostal: "city_postal",
Country: "country",
CompanyId: "company_id",
VatId: "vat_id",
}; };
fieldOrder = fieldOrder.map(k => legacyMap[k] || k); fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
} }
const fieldMap: Record<string, string> = {}; const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name; if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street); if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String); const cityParts = [entity.city || "", entity.postal_code || ""]
const cityPostal = cityParts.join(' ').trim(); .filter(Boolean)
.map(String);
const cityPostal = cityParts.join(" ").trim();
if (cityPostal) fieldMap.city_postal = cityPostal; if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country); if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id) fieldMap.company_id = `${tObj.ico}${entity.company_id}`; if (entity.company_id)
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`; if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
cfData.forEach((cf, i) => { cfData.forEach((cf, i) => {
const cfName = (cf.name || '').trim(); const cfName = (cf.name || "").trim();
const cfValue = (cf.value || '').trim(); const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false; const showLabel = cf.showLabel !== false;
if (cfValue) { if (cfValue) {
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue; fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
} }
}); });
@@ -122,101 +146,108 @@ function buildAddressLines(
const translations: Record<string, Record<string, string>> = { const translations: Record<string, Record<string, string>> = {
cs: { cs: {
title: 'Faktura', title: "Faktura",
heading: 'FAKTURA - DAŇOVÝ DOKLAD č.', heading: "FAKTURA - DAŇOVÝ DOKLAD č.",
supplier: 'Dodavatel', supplier: "Dodavatel",
customer: 'Odběratel', customer: "Odběratel",
bank: 'Banka:', bank: "Banka:",
swift: 'SWIFT:', swift: "SWIFT:",
iban: 'IBAN:', iban: "IBAN:",
account_no: 'Číslo účtu:', account_no: "Číslo účtu:",
var_symbol: 'Variabilní s.:', var_symbol: "Variabilní s.:",
const_symbol: 'Konstantní s.:', const_symbol: "Konstantní s.:",
order_no: 'Objednávka č.:', order_no: "Objednávka č.:",
issue_date: 'Datum vystavení:', issue_date: "Datum vystavení:",
due_date: 'Datum splatnosti:', due_date: "Datum splatnosti:",
tax_date: 'Datum uskutečnění plnění:', tax_date: "Datum uskutečnění plnění:",
payment_method: 'Forma úhrady:', payment_method: "Forma úhrady:",
billing: 'Fakturujeme Vám za:', billing: "Fakturujeme Vám za:",
col_no: 'Č.', col_no: "Č.",
col_desc: 'Popis', col_desc: "Popis",
col_qty: 'Množství', col_qty: "Množství",
col_unit_price: 'Jedn. cena', col_unit_price: "Jedn. cena",
col_price: 'Cena', col_price: "Cena",
col_vat_pct: '%DPH', col_vat_pct: "%DPH",
col_vat: 'DPH', col_vat: "DPH",
col_total: 'Celkem', col_total: "Celkem",
subtotal: 'Mezisoučet:', subtotal: "Mezisoučet:",
vat_label: 'DPH', vat_label: "DPH",
total: 'Celkem k úhradě', total: "Celkem k úhradě",
amounts_in: 'Částky jsou uvedeny v', amounts_in: "Částky jsou uvedeny v",
notes: 'Poznámky', notes: "Poznámky",
issued_by: 'Vystavil:', issued_by: "Vystavil:",
notice: 'Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti' notice:
+ ' uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp.' "Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti" +
+ ' zákonné výši a smluvní pokutu (byla-li sjednána).', " uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp." +
vat_recap: 'Rekapitulace DPH v Kč:', " zákonné výši a smluvní pokutu (byla-li sjednána).",
vat_base: 'Základ v Kč', vat_recap: "Rekapitulace DPH v Kč:",
vat_rate: 'Sazba', vat_base: "Základ v Kč",
vat_amount: 'DPH v Kč', vat_rate: "Sazba",
vat_with_total: 'Celkem s DPH v Kč', vat_amount: "DPH v Kč",
received_by: 'Převzal:', vat_with_total: "Celkem s DPH v Kč",
stamp: 'Razítko:', received_by: "Převzal:",
ico: 'IČ: ', stamp: "Razítko:",
dic: 'DIČ: ', ico: "IČ: ",
dic: "DIČ: ",
}, },
en: { en: {
title: 'Invoice', title: "Invoice",
heading: 'INVOICE - TAX DOCUMENT No.', heading: "INVOICE - TAX DOCUMENT No.",
supplier: 'Supplier', supplier: "Supplier",
customer: 'Customer', customer: "Customer",
bank: 'Bank:', bank: "Bank:",
swift: 'SWIFT:', swift: "SWIFT:",
iban: 'IBAN:', iban: "IBAN:",
account_no: 'Account No.:', account_no: "Account No.:",
var_symbol: 'Variable symbol:', var_symbol: "Variable symbol:",
const_symbol: 'Constant symbol:', const_symbol: "Constant symbol:",
order_no: 'Order No.:', order_no: "Order No.:",
issue_date: 'Issue date:', issue_date: "Issue date:",
due_date: 'Due date:', due_date: "Due date:",
tax_date: 'Tax point date:', tax_date: "Tax point date:",
payment_method: 'Payment method:', payment_method: "Payment method:",
billing: 'We invoice you for:', billing: "We invoice you for:",
col_no: 'No.', col_no: "No.",
col_desc: 'Description', col_desc: "Description",
col_qty: 'Quantity', col_qty: "Quantity",
col_unit_price: 'Unit price', col_unit_price: "Unit price",
col_price: 'Price', col_price: "Price",
col_vat_pct: 'VAT%', col_vat_pct: "VAT%",
col_vat: 'VAT', col_vat: "VAT",
col_total: 'Total', col_total: "Total",
subtotal: 'Subtotal:', subtotal: "Subtotal:",
vat_label: 'VAT', vat_label: "VAT",
total: 'Total to pay', total: "Total to pay",
amounts_in: 'Amounts are in', amounts_in: "Amounts are in",
notes: 'Notes', notes: "Notes",
issued_by: 'Issued by:', issued_by: "Issued by:",
notice: 'Please note that in case of late payment, we will charge default interest' notice:
+ ' at the agreed or statutory rate and a contractual penalty (if agreed).', "Please note that in case of late payment, we will charge default interest" +
vat_recap: 'VAT recapitulation in CZK:', " at the agreed or statutory rate and a contractual penalty (if agreed).",
vat_base: 'Tax base in CZK', vat_recap: "VAT recapitulation in CZK:",
vat_rate: 'Rate', vat_base: "Tax base in CZK",
vat_amount: 'VAT in CZK', vat_rate: "Rate",
vat_with_total: 'Total incl. VAT in CZK', vat_amount: "VAT in CZK",
received_by: 'Received by:', vat_with_total: "Total incl. VAT in CZK",
stamp: 'Stamp:', received_by: "Received by:",
ico: 'Reg. No.: ', stamp: "Stamp:",
dic: 'Tax ID: ', ico: "Reg. No.: ",
dic: "Tax ID: ",
}, },
}; };
/* ── Route ───────────────────────────────────────────────────────── */ /* ── Route ───────────────────────────────────────────────────────── */
export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promise<void> { export default async function invoicesPdfRoutes(
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.export') }, async (request, reply) => { fastify: FastifyInstance,
): Promise<void> {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.export") },
async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseInt(request.params.id, 10);
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const lang = query.lang === 'en' ? 'en' : 'cs'; const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang]; const t = translations[lang];
const invoice = await prisma.invoices.findUnique({ const invoice = await prisma.invoices.findUnique({
@@ -224,48 +255,62 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
}); });
if (!invoice) { if (!invoice) {
return reply.status(404).type('text/html').send('<html><body><h1>Faktura nenalezena</h1></body></html>'); return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
} }
const items = await prisma.invoice_items.findMany({ const items = await prisma.invoice_items.findMany({
where: { invoice_id: id }, where: { invoice_id: id },
orderBy: { position: 'asc' }, orderBy: { position: "asc" },
}); });
let customer: Record<string, unknown> | null = null; let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) { if (invoice.customer_id) {
customer = await prisma.customers.findUnique({ customer = (await prisma.customers.findUnique({
where: { id: invoice.customer_id }, where: { id: invoice.customer_id },
}) as Record<string, unknown> | null; })) as Record<string, unknown> | null;
} }
const settings = await prisma.company_settings.findFirst() as Record<string, unknown> | null; const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
// Order number lookup // Order number lookup
let orderNumber = ''; let orderNumber = "";
if (invoice.order_id) { if (invoice.order_id) {
const orderRow = await prisma.orders.findUnique({ const orderRow = await prisma.orders.findUnique({
where: { id: invoice.order_id }, where: { id: invoice.order_id },
select: { order_number: true, customer_order_number: true, created_at: true }, select: {
order_number: true,
customer_order_number: true,
created_at: true,
},
}); });
if (orderRow) { if (orderRow) {
orderNumber = escapeHtml(String(orderRow.customer_order_number || orderRow.order_number || '')); orderNumber = escapeHtml(
String(
orderRow.customer_order_number || orderRow.order_number || "",
),
);
} }
} }
// Logo // Logo
let logoImg = ''; let logoImg = "";
if (settings?.logo_data) { if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer); const buf = Buffer.from(settings.logo_data as Buffer);
let mime = 'image/png'; let mime = "image/png";
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg'; if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif'; else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp'; else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString('base64'); const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`; logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
} }
const currency = invoice.currency || 'CZK'; const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat; const applyVat = !!invoice.apply_vat;
// Calculations // Calculations
@@ -280,7 +325,7 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal; vatSummary[key].base += lineSubtotal;
if (applyVat) { if (applyVat) {
vatSummary[key].vat += lineSubtotal * rate / 100; vatSummary[key].vat += (lineSubtotal * rate) / 100;
} }
} }
@@ -291,21 +336,21 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
const totalToPay = subtotal + totalVat; const totalToPay = subtotal + totalVat;
// QR code - SPAYD payment format // QR code - SPAYD payment format
let qrSvg = ''; let qrSvg = "";
try { try {
const spaydParts = [ const spaydParts = [
'SPD*1.0', "SPD*1.0",
'ACC:' + (invoice.bank_iban || '').replace(/ /g, ''), "ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
'AM:' + totalToPay.toFixed(2), "AM:" + totalToPay.toFixed(2),
'CC:' + currency, "CC:" + currency,
'X-VS:' + (invoice.invoice_number || ''), "X-VS:" + (invoice.invoice_number || ""),
'X-KS:' + (invoice.constant_symbol || '0308'), "X-KS:" + (invoice.constant_symbol || "0308"),
'MSG:' + t.title + ' ' + (invoice.invoice_number || ''), "MSG:" + t.title + " " + (invoice.invoice_number || ""),
]; ];
const spaydString = spaydParts.join('*'); const spaydString = spaydParts.join("*");
qrSvg = await QRCode.toString(spaydString, { qrSvg = await QRCode.toString(spaydString, {
type: 'svg', type: "svg",
errorCorrectionLevel: 'M', errorCorrectionLevel: "M",
margin: 1, margin: 1,
width: 200, width: 200,
}); });
@@ -314,10 +359,15 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
} }
// VAT recapitulation (always in CZK) // VAT recapitulation (always in CZK)
const isForeign = currency.toUpperCase() !== 'CZK'; const isForeign = currency.toUpperCase() !== "CZK";
const cnbRate = 1.0; // Skip CNB rate conversion const cnbRate = 1.0; // Skip CNB rate conversion
const vatRates = [21, 12, 0]; const vatRates = [21, 12, 0];
const vatRecap: Array<{ rate: number; base: number; vat: number; total: number }> = []; const vatRecap: Array<{
rate: number;
base: number;
vat: number;
total: number;
}> = [];
for (const rate of vatRates) { for (const rate of vatRates) {
const key = String(rate); const key = String(rate);
const base = vatSummary[key]?.base ?? 0; const base = vatSummary[key]?.base ?? 0;
@@ -334,19 +384,23 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
const supp = buildAddressLines(settings, true, t); const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(customer, false, t); const cust = buildAddressLines(customer, false, t);
const suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join(''); const suppLinesHtml = supp.lines
const custLinesHtml = cust.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join(''); .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Supplier email/web from custom_fields // Supplier email/web from custom_fields
let suppEmail = ''; let suppEmail = "";
if (settings?.custom_fields) { if (settings?.custom_fields) {
const raw = settings.custom_fields; const raw = settings.custom_fields;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === "object") {
const fields = (parsed as Record<string, unknown>).fields; const fields = (parsed as Record<string, unknown>).fields;
if (Array.isArray(fields)) { if (Array.isArray(fields)) {
for (const f of fields) { for (const f of fields) {
if (f.name && f.name.toLowerCase() === 'email' && f.value) { if (f.name && f.name.toLowerCase() === "email" && f.value) {
suppEmail = String(f.value); suppEmail = String(f.value);
} }
} }
@@ -357,12 +411,13 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
const invoiceNumber = escapeHtml(invoice.invoice_number); const invoiceNumber = escapeHtml(invoice.invoice_number);
// Items HTML // Items HTML
const itemsHtml = items.map((item, i) => { const itemsHtml = items
.map((item, i) => {
const qty = Number(item.quantity); const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price); const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice; const lineSubtotal = qty * unitPrice;
const vatRate = Number(item.vat_rate); const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? lineSubtotal * vatRate / 100 : 0; const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat; const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
@@ -376,18 +431,23 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
<td class="right">${formatNum(lineVat)}</td> <td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td> <td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`; </tr>`;
}).join(''); })
.join("");
// VAT recap rows // VAT recap rows
const vatRecapHtml = vatRecap.map(vr => `<tr> const vatRecapHtml = vatRecap
.map(
(vr) => `<tr>
<td class="right">${formatNum(vr.base)}</td> <td class="right">${formatNum(vr.base)}</td>
<td class="center">${Math.floor(vr.rate)}%</td> <td class="center">${Math.floor(vr.rate)}%</td>
<td class="right">${formatNum(vr.vat)}</td> <td class="right">${formatNum(vr.vat)}</td>
<td class="right">${formatNum(vr.total)}</td> <td class="right">${formatNum(vr.total)}</td>
</tr>`).join(''); </tr>`,
)
.join("");
// VAT detail rows for totals section // VAT detail rows for totals section
let vatDetailHtml = ''; let vatDetailHtml = "";
if (applyVat) { if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) { for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) { if (data.vat > 0) {
@@ -401,8 +461,8 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
} }
// Notes section // Notes section
const notesRaw = invoice.notes ?? ''; const notesRaw = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, '').trim(); const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped const notesHtml = notesStripped
? ` ? `
<!-- Poznamky --> <!-- Poznamky -->
@@ -411,10 +471,10 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div> <div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
</div> </div>
` `
: ''; : "";
// Quill indent CSS // Quill indent CSS
let indentCSS = ''; let indentCSS = "";
for (let n = 1; n <= 9; n++) { for (let n = 1; n <= 9; n++) {
const pad = n * 3; const pad = n * 3;
const liPad = n * 3 + 1.5; const liPad = n * 3 + 1.5;
@@ -833,7 +893,7 @@ ${indentCSS}
<!-- Hlavicka --> <!-- Hlavicka -->
<div class="invoice-header"> <div class="invoice-header">
<div class="left"> <div class="left">
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ''} ${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
</div> </div>
<div class="invoice-title">${escapeHtml(t.heading)} ${invoiceNumber}</div> <div class="invoice-title">${escapeHtml(t.heading)} ${invoiceNumber}</div>
</div> </div>
@@ -866,7 +926,7 @@ ${indentCSS}
<div class="vs-block"> <div class="vs-block">
${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong> ${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong>
&nbsp;&nbsp;&nbsp; ${escapeHtml(t.const_symbol)} <strong>${escapeHtml(invoice.constant_symbol)}</strong><br> &nbsp;&nbsp;&nbsp; ${escapeHtml(t.const_symbol)} <strong>${escapeHtml(invoice.constant_symbol)}</strong><br>
${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ''} ${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ""}
</div> </div>
</div> </div>
<div class="col"> <div class="col">
@@ -923,8 +983,8 @@ ${indentCSS}
<!-- Vystavil --> <!-- Vystavil -->
<div class="issued-by"> <div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || '')} <span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
${suppEmail ? `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(suppEmail)}` : ''} ${suppEmail ? `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(suppEmail)}` : ""}
</div> </div>
<!-- Upozorneni --> <!-- Upozorneni -->
@@ -967,6 +1027,7 @@ ${indentCSS}
</body> </body>
</html>`; </html>`;
return reply.type('text/html').send(html); return reply.type("text/html").send(html);
}); },
);
} }

View File

@@ -1,10 +1,13 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import { requirePermission } from '../../middleware/auth'; import { requirePermission } from "../../middleware/auth";
import { logAudit } from '../../services/audit'; import { logAudit } from "../../services/audit";
import { success, error, parseId } from '../../utils/response'; import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from '../../schemas/common'; import { parseBody } from "../../schemas/common";
import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema'; import {
CreateInvoiceSchema,
UpdateInvoiceSchema,
} from "../../schemas/invoices.schema";
import { import {
markOverdueInvoices, markOverdueInvoices,
listInvoices, listInvoices,
@@ -15,18 +18,22 @@ import {
createInvoice, createInvoice,
updateInvoice, updateInvoice,
deleteInvoice, deleteInvoice,
} from '../../services/invoices.service'; } from "../../services/invoices.service";
export default async function invoicesRoutes(fastify: FastifyInstance): Promise<void> {
export default async function invoicesRoutes(
fastify: FastifyInstance,
): Promise<void> {
// Auto-update overdue invoices on GET requests only (matches PHP behavior) // Auto-update overdue invoices on GET requests only (matches PHP behavior)
fastify.addHook('onRequest', async (request) => { fastify.addHook("onRequest", async (request) => {
if (request.method !== 'GET') return; if (request.method !== "GET") return;
await markOverdueInvoices(); await markOverdueInvoices();
}); });
// GET /api/admin/invoices // GET /api/admin/invoices
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => { fastify.get(
"/",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query); const { page, limit, skip, order, search } = parsePagination(query);
@@ -34,89 +41,158 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise<
page, page,
limit, limit,
skip, skip,
sort: String(query.sort || ''), sort: String(query.sort || ""),
order, order,
search, search,
status: query.status ? String(query.status) : undefined, status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined, customer_id: query.customer_id ? Number(query.customer_id) : undefined,
}); });
return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, page, limit) }); return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(result.total, page, limit),
}); });
},
);
// GET /api/admin/invoices/next-number // GET /api/admin/invoices/next-number
fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => { fastify.get(
"/next-number",
{ preHandler: requirePermission("invoices.create") },
async (_request, reply) => {
const result = await getNextInvoiceNumberFormatted(); const result = await getNextInvoiceNumberFormatted();
return success(reply, result); return success(reply, result);
}); },
);
// GET /api/admin/invoices/stats // GET /api/admin/invoices/stats
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => { fastify.get(
"/stats",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const month = query.month ? Number(query.month) : undefined; const month = query.month ? Number(query.month) : undefined;
const year = query.year ? Number(query.year) : undefined; const year = query.year ? Number(query.year) : undefined;
const stats = await getInvoiceStats(month, year); const stats = await getInvoiceStats(month, year);
return success(reply, stats); return success(reply, stats);
}); },
);
// GET /api/admin/invoices/order-data/:id // GET /api/admin/invoices/order-data/:id
fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => { fastify.get<{ Params: { id: string } }>(
"/order-data/:id",
{ preHandler: requirePermission("invoices.create") },
async (request, reply) => {
const orderId = parseId(request.params.id, reply); const orderId = parseId(request.params.id, reply);
if (orderId === null) return; if (orderId === null) return;
const result = await getOrderDataForInvoice(orderId); const result = await getOrderDataForInvoice(orderId);
if (!result) return error(reply, 'Objednávka nenalezena', 404); if (!result) return error(reply, "Objednávka nenalezena", 404);
return success(reply, result); return success(reply, result);
}); },
);
// GET /api/admin/invoices/:id // GET /api/admin/invoices/:id
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => { fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const invoice = await getInvoice(id); const invoice = await getInvoice(id);
if (!invoice) return error(reply, 'Faktura nenalezena', 404); if (!invoice) return error(reply, "Faktura nenalezena", 404);
return success(reply, invoice); return success(reply, invoice);
}); },
);
// POST /api/admin/invoices // POST /api/admin/invoices
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => { fastify.post(
"/",
{ preHandler: requirePermission("invoices.create") },
async (request, reply) => {
const parsed = parseBody(CreateInvoiceSchema, request.body); const parsed = parseBody(CreateInvoiceSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const invoice = await createInvoice(body); const invoice = await createInvoice(body);
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena faktura ${invoice.invoice_number}` }); await logAudit({
// Return both invoice_id and id for frontend compatibility request,
return success(reply, { id: invoice.id, invoice_id: invoice.id, invoice_number: invoice.invoice_number }, 201, 'Faktura byla vystavena'); authData: request.authData,
action: "create",
entityType: "invoice",
entityId: invoice.id,
description: `Vytvořena faktura ${invoice.invoice_number}`,
}); });
// Return both invoice_id and id for frontend compatibility
return success(
reply,
{
id: invoice.id,
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
},
201,
"Faktura byla vystavena",
);
},
);
// PUT /api/admin/invoices/:id // PUT /api/admin/invoices/:id
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => { fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const parsed = parseBody(UpdateInvoiceSchema, request.body); const parsed = parseBody(UpdateInvoiceSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const result = await updateInvoice(id, body); const result = await updateInvoice(id, body);
if ('error' in result) { if ("error" in result) {
if (result.error === 'not_found') return error(reply, 'Faktura nenalezena', 404); if (result.error === "not_found")
if (result.error === 'invalid_transition') return error(reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400); return error(reply, "Faktura nenalezena", 404);
if (result.error === "invalid_transition")
return error(
reply,
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400,
);
} }
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${(result as any).invoice_number}` }); await logAudit({
return success(reply, { id }, 200, 'Faktura byla aktualizována'); request,
authData: request.authData,
action: "update",
entityType: "invoice",
entityId: id,
description: `Upravena faktura ${(result as any).invoice_number}`,
}); });
return success(reply, { id }, 200, "Faktura byla aktualizována");
},
);
// DELETE /api/admin/invoices/:id // DELETE /api/admin/invoices/:id
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => { fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.delete") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await deleteInvoice(id); const existing = await deleteInvoice(id);
if (!existing) return error(reply, 'Faktura nenalezena', 404); if (!existing) return error(reply, "Faktura nenalezena", 404);
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána faktura ${existing.invoice_number}` }); await logAudit({
return success(reply, null, 200, 'Faktura smazána'); request,
authData: request.authData,
action: "delete",
entityType: "invoice",
entityId: id,
description: `Smazána faktura ${existing.invoice_number}`,
}); });
return success(reply, null, 200, "Faktura smazána");
},
);
} }

View File

@@ -1,23 +1,32 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import { attendance_leave_type, leave_requests_leave_type, leave_requests_status } from '@prisma/client'; import {
import prisma from '../../config/database'; attendance_leave_type,
import { requireAuth, requirePermission } from '../../middleware/auth'; leave_requests_leave_type,
import { logAudit } from '../../services/audit'; leave_requests_status,
import { success, error, parseId } from '../../utils/response'; } from "@prisma/client";
import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import prisma from "../../config/database";
import { parseBody } from '../../schemas/common'; import { requireAuth, requirePermission } from "../../middleware/auth";
import { CreateLeaveRequestSchema, ReviewLeaveRequestSchema } from '../../schemas/leave-requests.schema'; import { logAudit } from "../../services/audit";
import { notifyNewLeaveRequest } from '../../services/leave-notification'; import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
CreateLeaveRequestSchema,
ReviewLeaveRequestSchema,
} from "../../schemas/leave-requests.schema";
import { notifyNewLeaveRequest } from "../../services/leave-notification";
const VALID_LEAVE_TYPES = ['vacation', 'sick', 'unpaid'] as const; const VALID_LEAVE_TYPES = ["vacation", "sick", "unpaid"] as const;
const VALID_REVIEW_STATUSES = ['approved', 'rejected'] as const; const VALID_REVIEW_STATUSES = ["approved", "rejected"] as const;
export default async function leaveRequestsRoutes(fastify: FastifyInstance): Promise<void> { export default async function leaveRequestsRoutes(
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => { fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query); const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!; const authData = request.authData!;
const isAdmin = authData.permissions.includes('attendance.approve'); const isAdmin = authData.permissions.includes("attendance.approve");
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId; if (!isAdmin) where.user_id = authData.userId;
@@ -26,37 +35,52 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
const [requests, total] = await Promise.all([ const [requests, total] = await Promise.all([
prisma.leave_requests.findMany({ prisma.leave_requests.findMany({
where, skip, take: limit, orderBy: { created_at: order }, where,
skip,
take: limit,
orderBy: { created_at: order },
include: { include: {
users_leave_requests_user_idTousers: { select: { id: true, first_name: true, last_name: true } }, users_leave_requests_user_idTousers: {
users_leave_requests_reviewer_idTousers: { select: { id: true, first_name: true, last_name: true } }, select: { id: true, first_name: true, last_name: true },
},
users_leave_requests_reviewer_idTousers: {
select: { id: true, first_name: true, last_name: true },
},
}, },
}), }),
prisma.leave_requests.count({ where }), prisma.leave_requests.count({ where }),
]); ]);
return reply.send({ success: true, data: requests, pagination: buildPaginationMeta(total, page, limit) }); return reply.send({
success: true,
data: requests,
pagination: buildPaginationMeta(total, page, limit),
});
}); });
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => { fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
const parsed = parseBody(CreateLeaveRequestSchema, request.body); const parsed = parseBody(CreateLeaveRequestSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const authData = request.authData!; const authData = request.authData!;
const leaveType = body.leave_type; const leaveType = body.leave_type;
if (!VALID_LEAVE_TYPES.includes(leaveType as typeof VALID_LEAVE_TYPES[number])) { if (
return error(reply, 'Neplatný typ nepřítomnosti', 400); !VALID_LEAVE_TYPES.includes(
leaveType as (typeof VALID_LEAVE_TYPES)[number],
)
) {
return error(reply, "Neplatný typ nepřítomnosti", 400);
} }
const dateFrom = new Date(body.date_from); const dateFrom = new Date(body.date_from);
const dateTo = new Date(body.date_to); const dateTo = new Date(body.date_to);
if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) { if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) {
return error(reply, 'Neplatné datum', 400); return error(reply, "Neplatné datum", 400);
} }
if (dateTo < dateFrom) { if (dateTo < dateFrom) {
return error(reply, 'Datum do musí být po datu od', 400); return error(reply, "Datum do musí být po datu od", 400);
} }
// Compute business days server-side (matching PHP logic) // Compute business days server-side (matching PHP logic)
@@ -69,7 +93,7 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
} }
if (businessDays === 0) { if (businessDays === 0) {
return error(reply, 'Zvolený rozsah neobsahuje žádné pracovní dny', 400); return error(reply, "Zvolený rozsah neobsahuje žádné pracovní dny", 400);
} }
const leaveRequest = await prisma.leave_requests.create({ const leaveRequest = await prisma.leave_requests.create({
@@ -81,59 +105,89 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
total_hours: businessDays * 8, total_hours: businessDays * 8,
total_days: businessDays, total_days: businessDays,
notes: body.notes ? String(body.notes) : null, notes: body.notes ? String(body.notes) : null,
status: 'pending', status: "pending",
}, },
}); });
await logAudit({ request, authData, action: 'create', entityType: 'leave_request', entityId: leaveRequest.id, description: `Vytvořena žádost o nepřítomnost` }); await logAudit({
request,
authData,
action: "create",
entityType: "leave_request",
entityId: leaveRequest.id,
description: `Vytvořena žádost o nepřítomnost`,
});
// Send email notification (non-blocking) // Send email notification (non-blocking)
try { try {
const employeeName = `${authData.firstName} ${authData.lastName}`.trim() || authData.username; const employeeName =
notifyNewLeaveRequest({ `${authData.firstName} ${authData.lastName}`.trim() ||
authData.username;
notifyNewLeaveRequest(
{
leave_type: leaveType, leave_type: leaveType,
date_from: body.date_from, date_from: body.date_from,
date_to: body.date_to, date_to: body.date_to,
total_days: businessDays, total_days: businessDays,
total_hours: businessDays * 8, total_hours: businessDays * 8,
notes: body.notes, notes: body.notes,
}, employeeName).catch(err => request.log.error(err, 'Leave notification error')); },
employeeName,
).catch((err) => request.log.error(err, "Leave notification error"));
} catch (err) { } catch (err) {
request.log.error(err, 'Leave notification error'); request.log.error(err, "Leave notification error");
} }
return success(reply, { id: leaveRequest.id }, 201, 'Žádost byla odeslána ke schválení'); return success(
reply,
{ id: leaveRequest.id },
201,
"Žádost byla odeslána ke schválení",
);
}); });
// PUT /api/admin/leave-requests/:id (approve/reject) // PUT /api/admin/leave-requests/:id (approve/reject)
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('attendance.approve') }, async (request, reply) => { fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.approve") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const parsed = parseBody(ReviewLeaveRequestSchema, request.body); const parsed = parseBody(ReviewLeaveRequestSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const authData = request.authData!; const authData = request.authData!;
const status = body.status; const status = body.status;
if (!VALID_REVIEW_STATUSES.includes(status as typeof VALID_REVIEW_STATUSES[number])) { if (
return error(reply, 'Neplatný stav', 400); !VALID_REVIEW_STATUSES.includes(
status as (typeof VALID_REVIEW_STATUSES)[number],
)
) {
return error(reply, "Neplatný stav", 400);
} }
const existing = await prisma.leave_requests.findUnique({ where: { id } }); const existing = await prisma.leave_requests.findUnique({
if (!existing) return error(reply, 'Žádost nenalezena', 404); where: { id },
});
if (!existing) return error(reply, "Žádost nenalezena", 404);
if (existing.status !== 'pending') { if (existing.status !== "pending") {
return error(reply, 'Lze schválit/zamítnout pouze čekající žádosti', 400); return error(
reply,
"Lze schválit/zamítnout pouze čekající žádosti",
400,
);
} }
if (status === 'approved') { if (status === "approved") {
// --- APPROVAL: create attendance records + update leave balance (matching PHP) --- // --- APPROVAL: create attendance records + update leave balance (matching PHP) ---
const leaveType = existing.leave_type as string; const leaveType = existing.leave_type as string;
const dateFrom = new Date(existing.date_from); const dateFrom = new Date(existing.date_from);
const dateTo = new Date(existing.date_to); const dateTo = new Date(existing.date_to);
// For vacation: re-check balance at approval time // For vacation: re-check balance at approval time
if (leaveType === 'vacation') { if (leaveType === "vacation") {
const year = dateFrom.getFullYear(); const year = dateFrom.getFullYear();
const balance = await prisma.leave_balances.findFirst({ const balance = await prisma.leave_balances.findFirst({
where: { user_id: existing.user_id, year }, where: { user_id: existing.user_id, year },
@@ -143,7 +197,11 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
const vacRemaining = vacTotal - vacUsed; const vacRemaining = vacTotal - vacUsed;
const totalHours = Number(existing.total_hours) || 0; const totalHours = Number(existing.total_hours) || 0;
if (totalHours > vacRemaining) { if (totalHours > vacRemaining) {
return error(reply, `Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`, 400); return error(
reply,
`Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`,
400,
);
} }
} }
@@ -164,7 +222,16 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
totalBusinessDays++; totalBusinessDays++;
attendanceCreates.push({ attendanceCreates.push({
user_id: existing.user_id, user_id: existing.user_id,
shift_date: new Date(Date.UTC(current.getFullYear(), current.getMonth(), current.getDate(), 12, 0, 0)), shift_date: new Date(
Date.UTC(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
),
),
leave_type: leaveType as attendance_leave_type, leave_type: leaveType as attendance_leave_type,
leave_hours: 8, leave_hours: 8,
notes: `Schválená žádost #${id}`, notes: `Schválená žádost #${id}`,
@@ -183,28 +250,35 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
} }
// 2. Update leave balance (vacation/sick only — not unpaid) // 2. Update leave balance (vacation/sick only — not unpaid)
if (leaveType === 'vacation' || leaveType === 'sick') { if (leaveType === "vacation" || leaveType === "sick") {
const year = dateFrom.getFullYear(); const year = dateFrom.getFullYear();
const existingBalance = await tx.leave_balances.findFirst({ const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: existing.user_id, year }, where: { user_id: existing.user_id, year },
}); });
if (existingBalance) { if (existingBalance) {
const updateData: Record<string, unknown> = { updated_at: new Date() }; const updateData: Record<string, unknown> = {
if (leaveType === 'vacation') { updated_at: new Date(),
updateData.vacation_used = Number(existingBalance.vacation_used) + totalHours; };
if (leaveType === "vacation") {
updateData.vacation_used =
Number(existingBalance.vacation_used) + totalHours;
} else { } else {
updateData.sick_used = Number(existingBalance.sick_used) + totalHours; updateData.sick_used =
Number(existingBalance.sick_used) + totalHours;
} }
await tx.leave_balances.update({ where: { id: existingBalance.id }, data: updateData }); await tx.leave_balances.update({
where: { id: existingBalance.id },
data: updateData,
});
} else { } else {
await tx.leave_balances.create({ await tx.leave_balances.create({
data: { data: {
user_id: existing.user_id, user_id: existing.user_id,
year, year,
vacation_total: 160, vacation_total: 160,
vacation_used: leaveType === 'vacation' ? totalHours : 0, vacation_used: leaveType === "vacation" ? totalHours : 0,
sick_used: leaveType === 'sick' ? totalHours : 0, sick_used: leaveType === "sick" ? totalHours : 0,
}, },
}); });
} }
@@ -214,44 +288,75 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
await tx.leave_requests.update({ await tx.leave_requests.update({
where: { id }, where: { id },
data: { data: {
status: 'approved' as leave_requests_status, status: "approved" as leave_requests_status,
reviewer_id: authData.userId, reviewer_id: authData.userId,
reviewed_at: new Date(), reviewed_at: new Date(),
}, },
}); });
}); });
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: `Žádost schválena — vytvořeno ${totalBusinessDays} záznamů (${totalHours}h)` }); await logAudit({
return success(reply, { id }, 200, 'Žádost byla schválena'); request,
authData,
action: "update",
entityType: "leave_request",
entityId: id,
description: `Žádost schválena — vytvořeno ${totalBusinessDays} záznamů (${totalHours}h)`,
});
return success(reply, { id }, 200, "Žádost byla schválena");
} }
// --- REJECTION: just update status --- // --- REJECTION: just update status ---
await prisma.leave_requests.update({ await prisma.leave_requests.update({
where: { id }, where: { id },
data: { data: {
status: 'rejected' as leave_requests_status, status: "rejected" as leave_requests_status,
reviewer_id: authData.userId, reviewer_id: authData.userId,
reviewer_note: body.reviewer_note ? String(body.reviewer_note) : null, reviewer_note: body.reviewer_note ? String(body.reviewer_note) : null,
reviewed_at: new Date(), reviewed_at: new Date(),
}, },
}); });
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: 'Žádost zamítnuta' }); await logAudit({
return success(reply, { id }, 200, 'Žádost byla zamítnuta'); request,
authData,
action: "update",
entityType: "leave_request",
entityId: id,
description: "Žádost zamítnuta",
}); });
return success(reply, { id }, 200, "Žádost byla zamítnuta");
},
);
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => { fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await prisma.leave_requests.findUnique({ where: { id } }); const existing = await prisma.leave_requests.findUnique({
if (!existing) return error(reply, 'Žádost nenalezena', 404); where: { id },
});
if (!existing) return error(reply, "Žádost nenalezena", 404);
if (existing.status !== 'pending') { if (existing.status !== "pending") {
return error(reply, 'Lze zrušit pouze čekající žádosti', 400); return error(reply, "Lze zrušit pouze čekající žádosti", 400);
} }
await prisma.leave_requests.update({ where: { id }, data: { status: 'cancelled' } }); await prisma.leave_requests.update({
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'leave_request', entityId: id, description: `Žádost zrušena` }); where: { id },
return success(reply, null, 200, 'Žádost zrušena'); data: { status: "cancelled" },
}); });
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "leave_request",
entityId: id,
description: `Žádost zrušena`,
});
return success(reply, null, 200, "Žádost zrušena");
},
);
} }

View File

@@ -1,20 +1,20 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import prisma from '../../config/database'; import prisma from "../../config/database";
import { requirePermission } from '../../middleware/auth'; import { requirePermission } from "../../middleware/auth";
function formatDate(date: Date | string | null | undefined): string { function formatDate(date: Date | string | null | undefined): string {
if (!date) return ''; if (!date) return "";
const d = new Date(date); const d = new Date(date);
if (isNaN(d.getTime())) return String(date); if (isNaN(d.getTime())) return String(date);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
} }
/** Format number with comma decimal separator and non-breaking space thousands separator */ /** Format number with comma decimal separator and non-breaking space thousands separator */
function formatNum(n: number, decimals: number): string { function formatNum(n: number, decimals: number): string {
const abs = Math.abs(n); const abs = Math.abs(n);
const fixed = abs.toFixed(decimals); const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split('.'); const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0'); const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
const result = decPart ? `${withSep},${decPart}` : withSep; const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result; return n < 0 ? `-${result}` : result;
} }
@@ -22,66 +22,92 @@ function formatNum(n: number, decimals: number): string {
function formatCurrency(amount: number, currency: string): string { function formatCurrency(amount: number, currency: string): string {
const n = Number(amount) || 0; const n = Number(amount) || 0;
switch (currency) { switch (currency) {
case 'EUR': return `${formatNum(n, 2)} \u20AC`; case "EUR":
case 'USD': return `$${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`; return `${formatNum(n, 2)} \u20AC`;
case 'CZK': return `${formatNum(n, 2)} K\u010D`; case "USD":
case 'GBP': return `\u00A3${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`; return `$${Math.abs(n)
default: return `${formatNum(n, 2)} ${currency}`; .toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
case "CZK":
return `${formatNum(n, 2)} K\u010D`;
case "GBP":
return `\u00A3${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
default:
return `${formatNum(n, 2)} ${currency}`;
} }
} }
function escapeHtml(str: string | null | undefined): string { function escapeHtml(str: string | null | undefined): string {
if (!str) return ''; if (!str) return "";
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */ /** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
function cleanQuillHtml(html: string | null | undefined): string { function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return ''; if (!html) return "";
const allowedTags = '<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>'; const allowedTags =
"<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>";
// Simple strip_tags equivalent: remove tags not in allowed list // Simple strip_tags equivalent: remove tags not in allowed list
let s = html; let s = html;
// Remove dangerous tags with content // Remove dangerous tags with content
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, ''); s = s.replace(
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, ''); /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
// Strip event handlers // Strip event handlers
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ''); s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ''); s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
// Strip javascript: in href // Strip javascript: in href
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace &nbsp; with regular space (outside of tags) // Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, ' '); s = s.replace(/(&nbsp;)/g, " ");
// Merge adjacent spans with same attributes // Merge adjacent spans with same attributes
let prev = ''; let prev = "";
while (prev !== s) { while (prev !== s) {
prev = s; prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2'); s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
} }
return s; return s;
} }
interface AddressResult { name: string; lines: string[] } interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines( function buildAddressLines(
entity: Record<string, unknown> | null, entity: Record<string, unknown> | null,
isSupplier: boolean, isSupplier: boolean,
t: (key: string) => string, t: (key: string) => string,
): AddressResult { ): AddressResult {
if (!entity) return { name: '', lines: [] }; if (!entity) return { name: "", lines: [] };
const nameKey = isSupplier ? 'company_name' : 'name'; const nameKey = isSupplier ? "company_name" : "name";
const name = String(entity[nameKey] || ''); const name = String(entity[nameKey] || "");
// Parse custom_fields // Parse custom_fields
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = []; let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
[];
let fieldOrder: string[] | null = null; let fieldOrder: string[] | null = null;
const raw = entity.custom_fields; const raw = entity.custom_fields;
if (raw) { if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) { if ((parsed as Record<string, unknown>).fields) {
cfData = ((parsed as Record<string, unknown>).fields as typeof cfData) || []; cfData =
fieldOrder = ((parsed as Record<string, unknown>).field_order || (parsed as Record<string, unknown>).fieldOrder) as string[] | null; ((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) { } else if (Array.isArray(parsed)) {
cfData = parsed; cfData = parsed;
} }
@@ -91,29 +117,37 @@ function buildAddressLines(
// Legacy PascalCase key compat // Legacy PascalCase key compat
if (Array.isArray(fieldOrder)) { if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = { const legacyMap: Record<string, string> = {
Name: 'name', CompanyName: 'company_name', Name: "name",
Street: 'street', CityPostal: 'city_postal', CompanyName: "company_name",
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id', Street: "street",
CityPostal: "city_postal",
Country: "country",
CompanyId: "company_id",
VatId: "vat_id",
}; };
fieldOrder = fieldOrder.map(k => legacyMap[k] || k); fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
} }
const fieldMap: Record<string, string> = {}; const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name; if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street); if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String); const cityParts = [entity.city || "", entity.postal_code || ""]
const cityPostal = cityParts.join(' ').trim(); .filter(Boolean)
.map(String);
const cityPostal = cityParts.join(" ").trim();
if (cityPostal) fieldMap.city_postal = cityPostal; if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country); if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id) fieldMap.company_id = `${t('ico')}: ${entity.company_id}`; if (entity.company_id)
if (entity.vat_id) fieldMap.vat_id = `${t('dic')}: ${entity.vat_id}`; fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
cfData.forEach((cf, i) => { cfData.forEach((cf, i) => {
const cfName = (cf.name || '').trim(); const cfName = (cf.name || "").trim();
const cfValue = (cf.value || '').trim(); const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false; const showLabel = cf.showLabel !== false;
if (cfValue) { if (cfValue) {
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue; fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
} }
}); });
@@ -138,29 +172,34 @@ function buildAddressLines(
} }
const TRANSLATIONS: Record<string, Record<string, string>> = { const TRANSLATIONS: Record<string, Record<string, string>> = {
title: { EN: 'PRICE QUOTATION', CZ: 'CENOV\u00C1 NAB\u00CDDKA' }, title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
scope_title: { EN: 'SCOPE OF THE PROJECT', CZ: 'ROZSAH PROJEKTU' }, scope_title: { EN: "SCOPE OF THE PROJECT", CZ: "ROZSAH PROJEKTU" },
valid_until: { EN: 'Valid until', CZ: 'Platnost do' }, valid_until: { EN: "Valid until", CZ: "Platnost do" },
customer: { EN: 'Customer', CZ: 'Z\u00E1kazn\u00EDk' }, customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
supplier: { EN: 'Supplier', CZ: 'Dodavatel' }, supplier: { EN: "Supplier", CZ: "Dodavatel" },
no: { EN: 'N.', CZ: '\u010C.' }, no: { EN: "N.", CZ: "\u010C." },
description: { EN: 'Description', CZ: 'Popis' }, description: { EN: "Description", CZ: "Popis" },
qty: { EN: 'Qty', CZ: 'Mn.' }, qty: { EN: "Qty", CZ: "Mn." },
unit_price: { EN: 'Unit Price', CZ: 'Jedn. cena' }, unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
included: { EN: 'Included', CZ: 'Zahrnuto' }, included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: 'Total', CZ: 'Celkem' }, total: { EN: "Total", CZ: "Celkem" },
subtotal: { EN: 'Subtotal', CZ: 'Mezisou\u010Det' }, subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: 'VAT', CZ: 'DPH' }, vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: 'Total to pay', CZ: 'Celkem k \u00FAhrad\u011B' }, total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
exchange_rate: { EN: 'Exchange rate', CZ: 'Sm\u011Bnn\u00FD kurz' }, exchange_rate: { EN: "Exchange rate", CZ: "Sm\u011Bnn\u00FD kurz" },
ico: { EN: 'ID', CZ: 'I\u010CO' }, ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: 'VAT ID', CZ: 'DI\u010C' }, dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: 'Page', CZ: 'Strana' }, page: { EN: "Page", CZ: "Strana" },
of: { EN: 'of', CZ: 'z' }, of: { EN: "of", CZ: "z" },
}; };
export default async function offersPdfRoutes(fastify: FastifyInstance): Promise<void> { export default async function offersPdfRoutes(
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => { fastify: FastifyInstance,
): Promise<void> {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseInt(request.params.id, 10);
try { try {
@@ -168,30 +207,33 @@ export default async function offersPdfRoutes(fastify: FastifyInstance): Promise
where: { id }, where: { id },
include: { include: {
customers: true, customers: true,
quotation_items: { orderBy: { position: 'asc' } }, quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: "asc" } },
}, },
}); });
if (!quotation) { if (!quotation) {
return reply.status(404).type('text/html').send('<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>'); return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>");
} }
const settings = await prisma.company_settings.findFirst(); const settings = await prisma.company_settings.findFirst();
const isCzech = (quotation.language ?? 'EN') !== 'EN'; const isCzech = (quotation.language ?? "EN") !== "EN";
const langKey = isCzech ? 'CZ' : 'EN'; const langKey = isCzech ? "CZ" : "EN";
const currency = quotation.currency || 'EUR'; const currency = quotation.currency || "EUR";
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key; const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
// Logo // Logo
let logoImg = ''; let logoImg = "";
if (settings?.logo_data) { if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data); const buf = Buffer.from(settings.logo_data);
let mime = 'image/png'; let mime = "image/png";
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg'; if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif'; else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp'; else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString('base64')}" class="logo" />`; logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
} }
// Calculations // Calculations
@@ -199,7 +241,8 @@ export default async function offersPdfRoutes(fastify: FastifyInstance): Promise
let subtotal = 0; let subtotal = 0;
for (const item of items) { for (const item of items) {
if (item.is_included_in_total !== false) { if (item.is_included_in_total !== false) {
subtotal += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); subtotal +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
} }
} }
const applyVat = !!quotation.apply_vat; const applyVat = !!quotation.apply_vat;
@@ -211,21 +254,33 @@ export default async function offersPdfRoutes(fastify: FastifyInstance): Promise
// Scope content check // Scope content check
let hasScopeContent = false; let hasScopeContent = false;
for (const s of quotation.scope_sections) { for (const s of quotation.scope_sections) {
if ((s.content || '').trim() || (s.title || '').trim()) { if ((s.content || "").trim() || (s.title || "").trim()) {
hasScopeContent = true; hasScopeContent = true;
break; break;
} }
} }
// Addresses // Addresses
const cust = buildAddressLines(quotation.customers as unknown as Record<string, unknown>, false, t); const cust = buildAddressLines(
const supp = buildAddressLines(settings as unknown as Record<string, unknown>, true, t); quotation.customers as unknown as Record<string, unknown>,
false,
t,
);
const supp = buildAddressLines(
settings as unknown as Record<string, unknown>,
true,
t,
);
const custLinesHtml = cust.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join(''); const custLinesHtml = cust.lines
const suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join(''); .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Indentation CSS for Quill // Indentation CSS for Quill
let indentCSS = ''; let indentCSS = "";
for (let n = 1; n <= 9; n++) { for (let n = 1; n <= 9; n++) {
const pad = n * 3; const pad = n * 3;
const liPad = n * 3 + 1.5; const liPad = n * 3 + 1.5;
@@ -234,76 +289,82 @@ export default async function offersPdfRoutes(fastify: FastifyInstance): Promise
} }
// Items HTML // Items HTML
let itemsHtml = ''; let itemsHtml = "";
items.forEach((item, i) => { items.forEach((item, i) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); const lineTotal =
const subDesc = item.item_description || ''; (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const evenClass = (i % 2 === 1) ? ' class="even"' : ''; const subDesc = item.item_description || "";
const evenClass = i % 2 === 1 ? ' class="even"' : "";
itemsHtml += `<tr${evenClass}> itemsHtml += `<tr${evenClass}>
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ''}</td> <td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || '').trim() ? ` / ${escapeHtml((item.unit || '').trim())}` : ''}</td> <td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td> <td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td> <td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`; </tr>`;
}); });
// Totals HTML // Totals HTML
let totalsHtml = ''; let totalsHtml = "";
if (applyVat) { if (applyVat) {
totalsHtml += `<div class="detail-rows"> totalsHtml += `<div class="detail-rows">
<div class="row"> <div class="row">
<span class="label">${escapeHtml(t('subtotal'))}:</span> <span class="label">${escapeHtml(t("subtotal"))}:</span>
<span class="value">${formatCurrency(subtotal, currency)}</span> <span class="value">${formatCurrency(subtotal, currency)}</span>
</div> </div>
<div class="row"> <div class="row">
<span class="label">${escapeHtml(t('vat'))} (${Math.round(vatRate)}%):</span> <span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
<span class="value">${formatCurrency(vatAmount, currency)}</span> <span class="value">${formatCurrency(vatAmount, currency)}</span>
</div> </div>
</div>`; </div>`;
} }
totalsHtml += `<div class="grand"> totalsHtml += `<div class="grand">
<span class="label">${escapeHtml(t('total_to_pay'))}</span> <span class="label">${escapeHtml(t("total_to_pay"))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span> <span class="value">${formatCurrency(totalToPay, currency)}</span>
</div>`; </div>`;
if (exchangeRate > 0) { if (exchangeRate > 0) {
totalsHtml += `<div class="exchange-rate">${escapeHtml(t('exchange_rate'))}: ${formatNum(exchangeRate, 4)}</div>`; totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
} }
const quotationNumber = escapeHtml(quotation.quotation_number); const quotationNumber = escapeHtml(quotation.quotation_number);
// Scope HTML // Scope HTML
let scopeHtml = ''; let scopeHtml = "";
if (hasScopeContent) { if (hasScopeContent) {
scopeHtml += '<div class="scope-page">'; scopeHtml += '<div class="scope-page">';
scopeHtml += `<div class="page-header"> scopeHtml += `<div class="page-header">
<div class="left"> <div class="left">
<div class="page-title">${escapeHtml(t('title'))}</div> <div class="page-title">${escapeHtml(t("title"))}</div>
<div class="quotation-number">${quotationNumber}</div> <div class="quotation-number">${quotationNumber}</div>
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ''} ${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t('valid_until'))}: ${escapeHtml(formatDate(quotation.valid_until))}</div> <div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div> </div>
${logoImg ? `<div class="right">${logoImg}</div>` : ''} ${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div> </div>
<hr class="separator" />`; <hr class="separator" />`;
for (const section of quotation.scope_sections) { for (const section of quotation.scope_sections) {
const title = isCzech && (section.title_cz || '').trim() ? section.title_cz : (section.title || ''); const title =
const content = (section.content || '').trim(); isCzech && (section.title_cz || "").trim()
? section.title_cz
: section.title || "";
const content = (section.content || "").trim();
if (!title && !content) continue; if (!title && !content) continue;
scopeHtml += '<div class="scope-section">'; scopeHtml += '<div class="scope-section">';
if (title) scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`; if (title)
if (content) scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`; scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
scopeHtml += '</div>'; if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
scopeHtml += "</div>";
} }
scopeHtml += '</div>'; scopeHtml += "</div>";
} }
const pageLabel = escapeHtml(t('page')); const pageLabel = escapeHtml(t("page"));
const ofLabel = escapeHtml(t('of')); const ofLabel = escapeHtml(t("of"));
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${isCzech ? 'cs' : 'en'}"> <html lang="${isCzech ? "cs" : "en"}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>${quotationNumber}</title> <title>${quotationNumber}</title>
@@ -655,22 +716,22 @@ ${indentCSS}
<div class="first-content"> <div class="first-content">
<div class="page-header"> <div class="page-header">
<div class="left"> <div class="left">
<div class="page-title">${escapeHtml(t('title'))}</div> <div class="page-title">${escapeHtml(t("title"))}</div>
<div class="quotation-number">${quotationNumber}</div> <div class="quotation-number">${quotationNumber}</div>
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ''} ${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t('valid_until'))}: ${escapeHtml(formatDate(quotation.valid_until))}</div> <div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div> </div>
</div> </div>
<hr class="separator" /> <hr class="separator" />
<div class="addresses"> <div class="addresses">
<div class="address-block left"> <div class="address-block left">
<div class="address-label">${escapeHtml(t('customer'))}</div> <div class="address-label">${escapeHtml(t("customer"))}</div>
<div class="address-name">${escapeHtml(cust.name)}</div> <div class="address-name">${escapeHtml(cust.name)}</div>
${custLinesHtml} ${custLinesHtml}
</div> </div>
<div class="address-block right"> <div class="address-block right">
<div class="address-label">${escapeHtml(t('supplier'))}</div> <div class="address-label">${escapeHtml(t("supplier"))}</div>
<div class="address-name">${escapeHtml(supp.name)}</div> <div class="address-name">${escapeHtml(supp.name)}</div>
${suppLinesHtml} ${suppLinesHtml}
</div> </div>
@@ -679,11 +740,11 @@ ${indentCSS}
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
<th class="center" style="width:5%">${escapeHtml(t('no'))}</th> <th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
<th style="width:44%">${escapeHtml(t('description'))}</th> <th style="width:44%">${escapeHtml(t("description"))}</th>
<th class="center" style="width:13%">${escapeHtml(t('qty'))}</th> <th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
<th class="right" style="width:18%">${escapeHtml(t('unit_price'))}</th> <th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
<th class="right" style="width:20%">${escapeHtml(t('total'))}</th> <th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -707,11 +768,16 @@ ${indentCSS}
</body> </body>
</html>`; </html>`;
return reply.type('text/html').send(html); return reply.type("text/html").send(html);
} catch (err) { } catch (err) {
request.log.error(err, 'PDF generation failed'); request.log.error(err, "PDF generation failed");
return reply.status(500).type('text/html').send('<html><body><h1>Chyba p\u0159i generov\u00E1n\u00ED PDF</h1></body></html>'); return reply
.status(500)
.type("text/html")
.send(
"<html><body><h1>Chyba p\u0159i generov\u00E1n\u00ED PDF</h1></body></html>",
);
} }
}); },
);
} }

View File

@@ -1,10 +1,14 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import { requirePermission } from '../../middleware/auth'; import { requirePermission } from "../../middleware/auth";
import { logAudit } from '../../services/audit'; import { logAudit } from "../../services/audit";
import { success, error, parseId } from '../../utils/response'; import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from '../../schemas/common'; import { parseBody } from "../../schemas/common";
import { CreateOrderFromQuotationSchema, CreateOrderSchema, UpdateOrderSchema } from '../../schemas/orders.schema'; import {
CreateOrderFromQuotationSchema,
CreateOrderSchema,
UpdateOrderSchema,
} from "../../schemas/orders.schema";
import { import {
listOrders, listOrders,
getOrder, getOrder,
@@ -14,56 +18,93 @@ import {
updateOrder, updateOrder,
deleteOrder, deleteOrder,
getNextOrderNumber, getNextOrderNumber,
} from '../../services/orders.service'; } from "../../services/orders.service";
import multipart from '@fastify/multipart'; import multipart from "@fastify/multipart";
export default async function ordersRoutes(fastify: FastifyInstance): Promise<void> { export default async function ordersRoutes(
fastify: FastifyInstance,
): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } }); await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
// GET /api/admin/orders/next-number // GET /api/admin/orders/next-number
fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => { fastify.get(
"/next-number",
{ preHandler: requirePermission("orders.create") },
async (_request, reply) => {
const number = await getNextOrderNumber(); const number = await getNextOrderNumber();
return success(reply, { number, next_number: number }); return success(reply, { number, next_number: number });
}); },
);
fastify.get('/', { preHandler: requirePermission('orders.view') }, async (request, reply) => { fastify.get(
"/",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order } = parsePagination(query); const { page, limit, skip, sort, order } = parsePagination(query);
const result = await listOrders({ const result = await listOrders({
page, limit, skip, sort, order, page,
limit,
skip,
sort,
order,
status: query.status ? String(query.status) : undefined, status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined, customer_id: query.customer_id ? Number(query.customer_id) : undefined,
}); });
return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, result.page, result.limit) }); return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(
result.total,
result.page,
result.limit,
),
}); });
},
);
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.view') }, async (request, reply) => { fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const order = await getOrder(id); const order = await getOrder(id);
if (!order) return error(reply, 'Objednávka nenalezena', 404); if (!order) return error(reply, "Objednávka nenalezena", 404);
return success(reply, order); return success(reply, order);
}); },
);
// GET /api/admin/orders/:id/attachment // GET /api/admin/orders/:id/attachment
fastify.get<{ Params: { id: string } }>('/:id/attachment', { preHandler: requirePermission('orders.view') }, async (request, reply) => { fastify.get<{ Params: { id: string } }>(
"/:id/attachment",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const attachment = await getOrderAttachment(id); const attachment = await getOrderAttachment(id);
if (!attachment) return error(reply, 'Příloha nenalezena', 404); if (!attachment) return error(reply, "Příloha nenalezena", 404);
return reply return reply
.type('application/pdf') .type("application/pdf")
.header('Content-Disposition', `inline; filename="${attachment.filename}"`) .header(
"Content-Disposition",
`inline; filename="${attachment.filename}"`,
)
.send(attachment.data); .send(attachment.data);
}); },
);
// POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation) // POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation)
fastify.post('/', { preHandler: requirePermission('orders.create') }, async (request, reply) => { fastify.post(
const isMultipart = request.headers['content-type']?.includes('multipart'); "/",
{ preHandler: requirePermission("orders.create") },
async (request, reply) => {
const isMultipart =
request.headers["content-type"]?.includes("multipart");
if (isMultipart) { if (isMultipart) {
// === Order from quotation flow (multipart) === // === Order from quotation flow (multipart) ===
@@ -73,26 +114,48 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise<vo
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === 'field') { if (part.type === "field") {
fields[part.fieldname] = String(part.value); fields[part.fieldname] = String(part.value);
} else if (part.type === 'file' && part.fieldname === 'attachment') { } else if (part.type === "file" && part.fieldname === "attachment") {
attachmentBuffer = await part.toBuffer(); attachmentBuffer = await part.toBuffer();
attachmentName = part.filename; attachmentName = part.filename;
} }
} }
const quotationId = parseInt(fields.quotationId, 10); const quotationId = parseInt(fields.quotationId, 10);
const customerOrderNumber = fields.customerOrderNumber || ''; const customerOrderNumber = fields.customerOrderNumber || "";
if (!quotationId || isNaN(quotationId)) { if (!quotationId || isNaN(quotationId)) {
return error(reply, 'Chybí ID nabídky', 400); return error(reply, "Chybí ID nabídky", 400);
} }
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber, attachmentBuffer, attachmentName }); const result = await createOrderFromQuotation({
if ('error' in result) return error(reply, result.error!, result.status!); quotationId,
customerOrderNumber,
attachmentBuffer,
attachmentName,
});
if ("error" in result)
return error(reply, result.error!, result.status!);
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.data.order_id, description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}` }); await logAudit({
return success(reply, { order_id: result.data.order_id, id: result.data.id, order_number: result.data.order_number }, 201, 'Objednávka byla vytvořena'); request,
authData: request.authData,
action: "create",
entityType: "order",
entityId: result.data.order_id,
description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}`,
});
return success(
reply,
{
order_id: result.data.order_id,
id: result.data.id,
order_number: result.data.order_number,
},
201,
"Objednávka byla vytvořena",
);
} }
// === JSON body — either from-quotation (no attachment) or manual order === // === JSON body — either from-quotation (no attachment) or manual order ===
@@ -100,54 +163,113 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise<vo
// From-quotation flow via JSON (no attachment) // From-quotation flow via JSON (no attachment)
if (rawBody.quotationId) { if (rawBody.quotationId) {
const fromQuotParsed = parseBody(CreateOrderFromQuotationSchema, rawBody); const fromQuotParsed = parseBody(
if ('error' in fromQuotParsed) return error(reply, fromQuotParsed.error, 400); CreateOrderFromQuotationSchema,
rawBody,
);
if ("error" in fromQuotParsed)
return error(reply, fromQuotParsed.error, 400);
const quotationId = fromQuotParsed.data.quotationId; const quotationId = fromQuotParsed.data.quotationId;
const customerOrderNumber = fromQuotParsed.data.customerOrderNumber; const customerOrderNumber = fromQuotParsed.data.customerOrderNumber;
if (!quotationId || isNaN(quotationId)) { if (!quotationId || isNaN(quotationId)) {
return error(reply, 'Chybí ID nabídky', 400); return error(reply, "Chybí ID nabídky", 400);
} }
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber }); const result = await createOrderFromQuotation({
if ('error' in result) return error(reply, result.error!, result.status!); quotationId,
customerOrderNumber,
});
if ("error" in result)
return error(reply, result.error!, result.status!);
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.data.order_id, description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}` }); await logAudit({
return success(reply, { order_id: result.data.order_id, id: result.data.id, order_number: result.data.order_number }, 201, 'Objednávka byla vytvořena'); request,
authData: request.authData,
action: "create",
entityType: "order",
entityId: result.data.order_id,
description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}`,
});
return success(
reply,
{
order_id: result.data.order_id,
id: result.data.id,
order_number: result.data.order_number,
},
201,
"Objednávka byla vytvořena",
);
} }
// Manual order creation // Manual order creation
const manualParsed = parseBody(CreateOrderSchema, rawBody); const manualParsed = parseBody(CreateOrderSchema, rawBody);
if ('error' in manualParsed) return error(reply, manualParsed.error, 400); if ("error" in manualParsed) return error(reply, manualParsed.error, 400);
const body = manualParsed.data; const body = manualParsed.data;
const result = await createOrder(body as any); const result = await createOrder(body as any);
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.id, description: `Vytvořena objednávka ${result.order_number}` }); await logAudit({
return success(reply, { id: result.id }, 201, 'Objednávka byla vytvořena'); request,
authData: request.authData,
action: "create",
entityType: "order",
entityId: result.id,
description: `Vytvořena objednávka ${result.order_number}`,
}); });
return success(
reply,
{ id: result.id },
201,
"Objednávka byla vytvořena",
);
},
);
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.edit') }, async (request, reply) => { fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const parsed = parseBody(UpdateOrderSchema, request.body); const parsed = parseBody(UpdateOrderSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const result = await updateOrder(id, parsed.data as any); const result = await updateOrder(id, parsed.data as any);
if ('error' in result) return error(reply, result.error!, result.status!); if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'order', entityId: id, description: `Upravena objednávka ${result.data.order_number}` }); await logAudit({
return success(reply, { id }, 200, 'Objednávka byla uložena'); request,
authData: request.authData,
action: "update",
entityType: "order",
entityId: id,
description: `Upravena objednávka ${result.data.order_number}`,
}); });
return success(reply, { id }, 200, "Objednávka byla uložena");
},
);
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.delete') }, async (request, reply) => { fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.delete") },
async (request, reply) => {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const result = await deleteOrder(id); const result = await deleteOrder(id);
if ('error' in result) return error(reply, result.error!, result.status!); if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'order', entityId: id, description: `Smazána objednávka ${result.data.order_number}` }); await logAudit({
return success(reply, null, 200, 'Objednávka smazána'); request,
authData: request.authData,
action: "delete",
entityType: "order",
entityId: id,
description: `Smazána objednávka ${result.data.order_number}`,
}); });
return success(reply, null, 200, "Objednávka smazána");
},
);
} }

View File

@@ -1,38 +1,48 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import prisma from '../../config/database'; import prisma from "../../config/database";
import { requireAuth } from '../../middleware/auth'; import { requireAuth } from "../../middleware/auth";
import { success, error } from '../../utils/response'; import { success, error } from "../../utils/response";
import bcrypt from 'bcryptjs'; import bcrypt from "bcryptjs";
import { config } from '../../config/env'; import { config } from "../../config/env";
import { logAudit } from '../../services/audit'; import { logAudit } from "../../services/audit";
import { parseBody } from '../../schemas/common'; import { parseBody } from "../../schemas/common";
import { UpdateProfileSchema } from '../../schemas/profile.schema'; import { UpdateProfileSchema } from "../../schemas/profile.schema";
export default async function profileRoutes(fastify: FastifyInstance): Promise<void> { export default async function profileRoutes(
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => { fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const user = await prisma.users.findUnique({ const user = await prisma.users.findUnique({
where: { id: request.authData!.userId }, where: { id: request.authData!.userId },
select: { select: {
id: true, username: true, email: true, first_name: true, last_name: true, id: true,
totp_enabled: true, last_login: true, password_changed_at: true, username: true,
email: true,
first_name: true,
last_name: true,
totp_enabled: true,
last_login: true,
password_changed_at: true,
roles: { select: { id: true, name: true, display_name: true } }, roles: { select: { id: true, name: true, display_name: true } },
}, },
}); });
if (!user) return error(reply, 'Uživatel nenalezen', 404); if (!user) return error(reply, "Uživatel nenalezen", 404);
return success(reply, user); return success(reply, user);
}); });
fastify.put('/', { preHandler: requireAuth }, async (request, reply) => { fastify.put("/", { preHandler: requireAuth }, async (request, reply) => {
const parsed = parseBody(UpdateProfileSchema, request.body); const parsed = parseBody(UpdateProfileSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data; const body = parsed.data;
const userId = request.authData!.userId; const userId = request.authData!.userId;
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
if (body.email) { if (body.email) {
const newEmail = String(body.email).trim(); const newEmail = String(body.email).trim();
const existing = await prisma.users.findFirst({ where: { email: newEmail, id: { not: userId } } }); const existing = await prisma.users.findFirst({
if (existing) return error(reply, 'E-mail již existuje', 409); where: { email: newEmail, id: { not: userId } },
});
if (existing) return error(reply, "E-mail již existuje", 409);
data.email = newEmail; data.email = newEmail;
} }
if (body.first_name) data.first_name = String(body.first_name); if (body.first_name) data.first_name = String(body.first_name);
@@ -40,18 +50,31 @@ export default async function profileRoutes(fastify: FastifyInstance): Promise<v
if (body.current_password && body.new_password) { if (body.current_password && body.new_password) {
const user = await prisma.users.findUnique({ where: { id: userId } }); const user = await prisma.users.findUnique({ where: { id: userId } });
if (!user) return error(reply, 'Uživatel nenalezen', 404); if (!user) return error(reply, "Uživatel nenalezen", 404);
const valid = await bcrypt.compare(String(body.current_password), user.password_hash); const valid = await bcrypt.compare(
if (!valid) return error(reply, 'Nesprávné aktuální heslo', 400); String(body.current_password),
user.password_hash,
);
if (!valid) return error(reply, "Nesprávné aktuální heslo", 400);
data.password_hash = await bcrypt.hash(String(body.new_password), config.security.bcryptCost); data.password_hash = await bcrypt.hash(
String(body.new_password),
config.security.bcryptCost,
);
data.password_changed_at = new Date(); data.password_changed_at = new Date();
await logAudit({ request, authData: request.authData, action: 'password_change', entityType: 'user', entityId: userId, description: 'Změna hesla' }); await logAudit({
request,
authData: request.authData,
action: "password_change",
entityType: "user",
entityId: userId,
description: "Změna hesla",
});
} }
await prisma.users.update({ where: { id: userId }, data }); await prisma.users.update({ where: { id: userId }, data });
return success(reply, null, 200, 'Profil aktualizován'); return success(reply, null, 200, "Profil aktualizován");
}); });
} }

View File

@@ -1,15 +1,19 @@
import fs from 'fs'; import fs from "fs";
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from "fastify";
import multipart from '@fastify/multipart'; import multipart from "@fastify/multipart";
import prisma from '../../config/database'; import prisma from "../../config/database";
import { config } from '../../config/env'; import { config } from "../../config/env";
import { requirePermission } from '../../middleware/auth'; import { requirePermission } from "../../middleware/auth";
import { logAudit } from '../../services/audit'; import { logAudit } from "../../services/audit";
import { success, error } from '../../utils/response'; import { success, error } from "../../utils/response";
import { NasFileManager } from '../../services/nas-file-manager'; import { NasFileManager } from "../../services/nas-file-manager";
export default async function projectFilesRoutes(fastify: FastifyInstance): Promise<void> { export default async function projectFilesRoutes(
await fastify.register(multipart, { limits: { fileSize: config.nas.maxUploadSize } }); fastify: FastifyInstance,
): Promise<void> {
await fastify.register(multipart, {
limits: { fileSize: config.nas.maxUploadSize },
});
const fm = new NasFileManager(); const fm = new NasFileManager();
@@ -22,39 +26,47 @@ export default async function projectFilesRoutes(fastify: FastifyInstance): Prom
} }
// GET / — list files or download // GET / — list files or download
fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => { fastify.get(
"/",
{ preHandler: requirePermission("projects.view") },
async (request, reply) => {
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const projectId = Number(query.project_id); const projectId = Number(query.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, 'Projekt nebyl nalezen', 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!fm.isConfigured()) { if (!fm.isConfigured()) {
return error(reply, 'Souborový systém není nakonfigurován', 500); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
const subPath = query.path || ''; const subPath = query.path || "";
if (query.action === 'download') { if (query.action === "download") {
if (!subPath) return error(reply, 'Cesta k souboru je povinná'); if (!subPath) return error(reply, "Cesta k souboru je povinná");
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu");
const result = fm.downloadFile(project.project_number, subPath); const result = fm.downloadFile(project.project_number, subPath);
if (!result) return error(reply, 'Soubor nebyl nalezen', 404); if (!result) return error(reply, "Soubor nebyl nalezen", 404);
const stream = fs.createReadStream(result.filePath); const stream = fs.createReadStream(result.filePath);
return reply return reply
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(result.fileName)}"`) .header(
.header('Content-Type', result.mime) "Content-Disposition",
.header('X-Content-Type-Options', 'nosniff') `attachment; filename="${encodeURIComponent(result.fileName)}"`,
)
.header("Content-Type", result.mime)
.header("X-Content-Type-Options", "nosniff")
.send(stream); .send(stream);
} }
// List files // List files
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu");
const result = fm.listFiles(project.project_number, subPath); const result = fm.listFiles(project.project_number, subPath);
if (result === null) { if (result === null) {
return error(reply, 'Složka nebyla nalezena', 404); return error(reply, "Složka nebyla nalezena", 404);
} }
return success(reply, { return success(reply, {
@@ -62,30 +74,36 @@ export default async function projectFilesRoutes(fastify: FastifyInstance): Prom
project_number: project.project_number, project_number: project.project_number,
folder_exists: true, folder_exists: true,
}); });
}); },
);
// POST / — create folder (JSON body) // POST / — create folder (JSON body)
fastify.post('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => { fastify.post(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const projectId = Number(query.project_id); const projectId = Number(query.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, 'Projekt nebyl nalezen', 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu");
if (!fm.isConfigured()) { if (!fm.isConfigured()) {
return error(reply, 'Souborový systém není nakonfigurován', 500); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
const body = request.body as Record<string, unknown>; const body = request.body as Record<string, unknown>;
const folderName = String(body.folder_name || '').trim(); const folderName = String(body.folder_name || "").trim();
const path = String(body.path || ''); const path = String(body.path || "");
if (!folderName) return error(reply, 'Název složky je povinný'); if (!folderName) return error(reply, "Název složky je povinný");
if ([...folderName].length > 100) return error(reply, 'Název složky je příliš dlouhý (max 100 znaků)'); if ([...folderName].length > 100)
return error(reply, "Název složky je příliš dlouhý (max 100 znaků)");
// Auto-create project folder if it doesn't exist // Auto-create project folder if it doesn't exist
if (!fm.projectFolderExists(project.project_number)) { if (!fm.projectFolderExists(project.project_number)) {
fm.createProjectFolder(project.project_number, project.name || ''); fm.createProjectFolder(project.project_number, project.name || "");
} }
const err = fm.createFolder(project.project_number, path, folderName); const err = fm.createFolder(project.project_number, path, folderName);
@@ -94,76 +112,92 @@ export default async function projectFilesRoutes(fastify: FastifyInstance): Prom
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: 'create', action: "create",
entityType: 'project_file', entityType: "project_file",
entityId: project.id, entityId: project.id,
description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`, description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`,
newValues: { folder: folderName, path }, newValues: { folder: folderName, path },
}); });
return success(reply, null, 200, 'Složka byla vytvořena'); return success(reply, null, 200, "Složka byla vytvořena");
}); },
);
// POST /upload — upload file (multipart) // POST /upload — upload file (multipart)
fastify.post('/upload', { fastify.post(
preHandler: requirePermission('projects.files'), "/upload",
{
preHandler: requirePermission("projects.files"),
bodyLimit: config.nas.maxUploadSize, bodyLimit: config.nas.maxUploadSize,
}, async (request, reply) => { },
async (request, reply) => {
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const projectId = Number(query.project_id); const projectId = Number(query.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, 'Projekt nebyl nalezen', 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu");
if (!fm.isConfigured()) { if (!fm.isConfigured()) {
return error(reply, 'Souborový systém není nakonfigurován', 500); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
// Auto-create project folder if it doesn't exist // Auto-create project folder if it doesn't exist
if (!fm.projectFolderExists(project.project_number)) { if (!fm.projectFolderExists(project.project_number)) {
fm.createProjectFolder(project.project_number, project.name || ''); fm.createProjectFolder(project.project_number, project.name || "");
} }
const file = await request.file(); const file = await request.file();
if (!file) return error(reply, 'Nebyl nahrán žádný soubor'); if (!file) return error(reply, "Nebyl nahrán žádný soubor");
const subPath = query.path || ''; const subPath = query.path || "";
const fileBuffer = await file.toBuffer(); const fileBuffer = await file.toBuffer();
const fileName = file.filename; const fileName = file.filename;
const err = await fm.uploadFile(project.project_number, subPath, fileBuffer, fileName); const err = await fm.uploadFile(
project.project_number,
subPath,
fileBuffer,
fileName,
);
if (err !== null) return error(reply, err); if (err !== null) return error(reply, err);
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: 'create', action: "create",
entityType: 'project_file', entityType: "project_file",
entityId: project.id, entityId: project.id,
description: `Nahrán soubor do projektu '${project.project_number}'`, description: `Nahrán soubor do projektu '${project.project_number}'`,
newValues: { file: fileName, path: subPath }, newValues: { file: fileName, path: subPath },
}); });
return success(reply, null, 200, 'Soubor byl nahrán'); return success(reply, null, 200, "Soubor byl nahrán");
}); },
);
// PUT / — move/rename // PUT / — move/rename
fastify.put('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => { fastify.put(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const projectId = Number(query.project_id); const projectId = Number(query.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, 'Projekt nebyl nalezen', 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu");
if (!fm.isConfigured()) { if (!fm.isConfigured()) {
return error(reply, 'Souborový systém není nakonfigurován', 500); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
const body = request.body as Record<string, unknown>; const body = request.body as Record<string, unknown>;
const fromPath = String(body.from_path || ''); const fromPath = String(body.from_path || "");
const toPath = String(body.to_path || ''); const toPath = String(body.to_path || "");
if (!fromPath || !toPath) return error(reply, 'Zdrojová i cílová cesta jsou povinné'); if (!fromPath || !toPath)
return error(reply, "Zdrojová i cílová cesta jsou povinné");
const err = fm.moveItem(project.project_number, fromPath, toPath); const err = fm.moveItem(project.project_number, fromPath, toPath);
if (err !== null) return error(reply, err); if (err !== null) return error(reply, err);
@@ -171,31 +205,36 @@ export default async function projectFilesRoutes(fastify: FastifyInstance): Prom
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: 'update', action: "update",
entityType: 'project_file', entityType: "project_file",
entityId: project.id, entityId: project.id,
description: `Přesun/přejmenování v projektu '${project.project_number}'`, description: `Přesun/přejmenování v projektu '${project.project_number}'`,
oldValues: { path: fromPath }, oldValues: { path: fromPath },
newValues: { path: toPath }, newValues: { path: toPath },
}); });
return success(reply, null, 200, 'Soubor byl přesunut'); return success(reply, null, 200, "Soubor byl přesunut");
}); },
);
// DELETE / — delete file/folder // DELETE / — delete file/folder
fastify.delete('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => { fastify.delete(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const projectId = Number(query.project_id); const projectId = Number(query.project_id);
const project = await getProjectForFiles(projectId); const project = await getProjectForFiles(projectId);
if (!project) return error(reply, 'Projekt nebyl nalezen', 404); if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu");
if (!fm.isConfigured()) { if (!fm.isConfigured()) {
return error(reply, 'Souborový systém není nakonfigurován', 500); return error(reply, "Souborový systém není nakonfigurován", 500);
} }
const filePath = query.path || ''; const filePath = query.path || "";
if (!filePath) return error(reply, 'Cesta k souboru je povinná'); if (!filePath) return error(reply, "Cesta k souboru je povinná");
const err = await fm.deleteItem(project.project_number, filePath); const err = await fm.deleteItem(project.project_number, filePath);
if (err !== null) return error(reply, err); if (err !== null) return error(reply, err);
@@ -203,13 +242,14 @@ export default async function projectFilesRoutes(fastify: FastifyInstance): Prom
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: 'delete', action: "delete",
entityType: 'project_file', entityType: "project_file",
entityId: project.id, entityId: project.id,
description: `Smazán soubor/složka v projektu '${project.project_number}'`, description: `Smazán soubor/složka v projektu '${project.project_number}'`,
oldValues: { path: filePath }, oldValues: { path: filePath },
}); });
return success(reply, null, 200, 'Soubor byl smazán'); return success(reply, null, 200, "Soubor byl smazán");
}); },
);
} }

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