style: run prettier on entire codebase
This commit is contained in:
14
.claude/hooks/block-env.js
Normal file
14
.claude/hooks/block-env.js
Normal 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 {}
|
||||
});
|
||||
13
.claude/hooks/format-on-save.js
Normal file
13
.claude/hooks/format-on-save.js
Normal 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
30
.claude/settings.json
Normal 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..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
43
.claude/skills/compare-php/SKILL.md
Normal file
43
.claude/skills/compare-php/SKILL.md
Normal 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\`
|
||||
@@ -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
1
.gitignore
vendored
@@ -2,6 +2,7 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.test
|
||||
.env.production
|
||||
*.log
|
||||
dist-client/
|
||||
*.css.map
|
||||
|
||||
385
docs/deployment-guide.md
Normal file
385
docs/deployment-guide.md
Normal 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) |
|
||||
517
docs/superpowers/plans/2026-03-23-project-files.md
Normal file
517
docs/superpowers/plans/2026-03-23-project-files.md
Normal 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"
|
||||
```
|
||||
177
docs/superpowers/specs/2026-03-23-project-files-design.md
Normal file
177
docs/superpowers/specs/2026-03-23-project-files-design.md
Normal 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
|
||||
26
src/App.tsx
26
src/App.tsx
@@ -1,19 +1,21 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import AdminApp from './admin/AdminApp'
|
||||
import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AdminApp from "./admin/AdminApp";
|
||||
|
||||
function AdminLoader() {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100dvh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-primary)',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--bg-primary)",
|
||||
}}
|
||||
>
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
@@ -23,5 +25,5 @@ export default function App() {
|
||||
<Route path="/*" element={<AdminApp />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp, extractCookie } from './helpers';
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { buildApp, extractCookie } from "./helpers";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
|
||||
beforeAll(async () => { app = await buildApp(); });
|
||||
afterAll(async () => { await app.close(); });
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('POST /api/admin/login', () => {
|
||||
it('returns 401 for invalid credentials', async () => {
|
||||
describe("POST /api/admin/login", () => {
|
||||
it("returns 401 for invalid credentials", async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/admin/login',
|
||||
payload: { username: 'nonexistent', password: 'wrong' },
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: { username: "nonexistent", password: "wrong" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
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({
|
||||
method: 'POST',
|
||||
url: '/api/admin/login',
|
||||
method: "POST",
|
||||
url: "/api/admin/login",
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/refresh', () => {
|
||||
it('returns 401 without refresh token', async () => {
|
||||
describe("POST /api/admin/refresh", () => {
|
||||
it("returns 401 without refresh token", async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/admin/refresh',
|
||||
method: "POST",
|
||||
url: "/api/admin/refresh",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/logout', () => {
|
||||
it('clears refresh token cookie', async () => {
|
||||
describe("POST /api/admin/logout", () => {
|
||||
it("clears refresh token cookie", async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/admin/logout',
|
||||
method: "POST",
|
||||
url: "/api/admin/logout",
|
||||
});
|
||||
expect(res.statusCode).toBeLessThan(500);
|
||||
});
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import authRoutes from '../routes/admin/auth';
|
||||
import totpRoutes from '../routes/admin/totp';
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import authRoutes from "../routes/admin/auth";
|
||||
import totpRoutes from "../routes/admin/totp";
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { max: 1000, timeWindow: '1 minute' });
|
||||
await app.register(authRoutes, { prefix: '/api/admin' });
|
||||
await app.register(totpRoutes, { prefix: '/api/admin/totp' });
|
||||
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
await app.register(authRoutes, { prefix: "/api/admin" });
|
||||
await app.register(totpRoutes, { prefix: "/api/admin/totp" });
|
||||
return app;
|
||||
}
|
||||
|
||||
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;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
for (const c of arr) {
|
||||
if (c.startsWith(`${name}=`)) {
|
||||
return c.split(';')[0].split('=')[1];
|
||||
return c.split(";")[0].split("=")[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateSharedNumber, generateOfferNumber } from '../services/numbering.service';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
generateOfferNumber,
|
||||
} from "../services/numbering.service";
|
||||
|
||||
describe('generateSharedNumber', () => {
|
||||
it('returns correct format (YYtypeCode + 4 digits)', async () => {
|
||||
describe("generateSharedNumber", () => {
|
||||
it("returns correct format (YYtypeCode + 4 digits)", async () => {
|
||||
const num = await generateSharedNumber();
|
||||
const yy = String(new Date().getFullYear()).slice(-2);
|
||||
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateOfferNumber', () => {
|
||||
it('returns correct format (YEAR/PREFIX/NNN)', async () => {
|
||||
describe("generateOfferNumber", () => {
|
||||
it("returns correct format (YEAR/PREFIX/NNN)", async () => {
|
||||
const num = await generateOfferNumber();
|
||||
const year = new Date().getFullYear();
|
||||
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config({ path: '.env.test' });
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config({ path: ".env.test" });
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { AlertProvider } from './context/AlertContext'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import AdminLayout from './components/AdminLayout'
|
||||
import AlertContainer from './components/AlertContainer'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import './admin.css'
|
||||
import './login.css'
|
||||
import './dashboard.css'
|
||||
import './attendance.css'
|
||||
import './settings.css'
|
||||
import './offers.css'
|
||||
import './invoices.css'
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { AlertProvider } from "./context/AlertContext";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import AlertContainer from "./components/AlertContainer";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import "./admin.css";
|
||||
import "./login.css";
|
||||
import "./dashboard.css";
|
||||
import "./attendance.css";
|
||||
import "./settings.css";
|
||||
import "./offers.css";
|
||||
import "./invoices.css";
|
||||
|
||||
const Users = lazy(() => import('./pages/Users'))
|
||||
const Attendance = lazy(() => import('./pages/Attendance'))
|
||||
const AttendanceHistory = lazy(() => import('./pages/AttendanceHistory'))
|
||||
const AttendanceAdmin = lazy(() => import('./pages/AttendanceAdmin'))
|
||||
const AttendanceBalances = lazy(() => import('./pages/AttendanceBalances'))
|
||||
const AttendanceCreate = lazy(() => import('./pages/AttendanceCreate'))
|
||||
const LeaveRequests = lazy(() => import('./pages/LeaveRequests'))
|
||||
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
||||
const AttendanceLocation = lazy(() => import('./pages/AttendanceLocation'))
|
||||
const Trips = lazy(() => import('./pages/Trips'))
|
||||
const TripsHistory = lazy(() => import('./pages/TripsHistory'))
|
||||
const TripsAdmin = lazy(() => import('./pages/TripsAdmin'))
|
||||
const Vehicles = lazy(() => import('./pages/Vehicles'))
|
||||
const Offers = lazy(() => import('./pages/Offers'))
|
||||
const OfferDetail = lazy(() => import('./pages/OfferDetail'))
|
||||
const OffersCustomers = lazy(() => import('./pages/OffersCustomers'))
|
||||
const OffersTemplates = lazy(() => import('./pages/OffersTemplates'))
|
||||
const CompanySettings = lazy(() => import('./pages/CompanySettings'))
|
||||
const Orders = lazy(() => import('./pages/Orders'))
|
||||
const OrderDetail = lazy(() => import('./pages/OrderDetail'))
|
||||
const Projects = lazy(() => import('./pages/Projects'))
|
||||
const ProjectCreate = lazy(() => import('./pages/ProjectCreate'))
|
||||
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
|
||||
const Invoices = lazy(() => import('./pages/Invoices'))
|
||||
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const AuditLog = lazy(() => import('./pages/AuditLog'))
|
||||
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
||||
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin"));
|
||||
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances"));
|
||||
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
||||
const Trips = lazy(() => import("./pages/Trips"));
|
||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
||||
const Vehicles = lazy(() => import("./pages/Vehicles"));
|
||||
const Offers = lazy(() => import("./pages/Offers"));
|
||||
const OfferDetail = lazy(() => import("./pages/OfferDetail"));
|
||||
const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
|
||||
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
||||
const CompanySettings = lazy(() => import("./pages/CompanySettings"));
|
||||
const Orders = lazy(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
||||
const Projects = lazy(() => import("./pages/Projects"));
|
||||
const ProjectCreate = lazy(() => import("./pages/ProjectCreate"));
|
||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
||||
const Settings = lazy(() => import("./pages/Settings"));
|
||||
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||
|
||||
export default function AdminApp() {
|
||||
return (
|
||||
@@ -50,20 +50,38 @@ export default function AdminApp() {
|
||||
<AlertProvider>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="admin-loading"><div className="admin-spinner" /></div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route path="attendance/history" element={<AttendanceHistory />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<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/approval" element={<LeaveApproval />} />
|
||||
<Route path="attendance/create" element={<AttendanceCreate />} />
|
||||
<Route path="attendance/location/:id" element={<AttendanceLocation />} />
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
@@ -91,5 +109,5 @@ export default function AdminApp() {
|
||||
</ErrorBoundary>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
import { forwardRef, useMemo } from 'react'
|
||||
import DatePicker, { registerLocale } from 'react-datepicker'
|
||||
import { cs } from 'date-fns/locale'
|
||||
import { parse, format } from 'date-fns'
|
||||
import 'react-datepicker/dist/react-datepicker.css'
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import DatePicker, { registerLocale } from "react-datepicker";
|
||||
import { cs } from "date-fns/locale";
|
||||
import { parse, format } from "date-fns";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
registerLocale('cs', cs)
|
||||
registerLocale("cs", cs);
|
||||
|
||||
// Ensure portal root exists
|
||||
if (typeof document !== 'undefined' && !document.getElementById('datepicker-portal')) {
|
||||
const el = document.createElement('div')
|
||||
el.id = 'datepicker-portal'
|
||||
document.body.appendChild(el)
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
!document.getElementById("datepicker-portal")
|
||||
) {
|
||||
const el = document.createElement("div");
|
||||
el.id = "datepicker-portal";
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
const isTouchDevice = () =>
|
||||
typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
typeof window !== "undefined" &&
|
||||
("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||
|
||||
interface CustomInputProps {
|
||||
value?: string
|
||||
onClick?: () => void
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
readOnly?: boolean
|
||||
disabled?: boolean
|
||||
value?: string;
|
||||
onClick?: () => void;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
({ value, onClick, onChange, placeholder, required, readOnly, disabled }, ref) => (
|
||||
(
|
||||
{ value, onClick, onChange, placeholder, required, readOnly, disabled },
|
||||
ref,
|
||||
) => (
|
||||
<input
|
||||
className="admin-form-input"
|
||||
onClick={onClick}
|
||||
@@ -40,28 +47,39 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
interface NativeInputProps {
|
||||
mode: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
required?: boolean
|
||||
minDate?: string
|
||||
maxDate?: string
|
||||
disabled?: boolean
|
||||
mode: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const modeToInputType: Record<string, string> = { month: 'month', time: 'time' }
|
||||
const modeToInputType: Record<string, string> = {
|
||||
month: "month",
|
||||
time: "time",
|
||||
};
|
||||
|
||||
function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabled }: NativeInputProps) {
|
||||
const type = modeToInputType[mode] || 'date'
|
||||
function NativeInput({
|
||||
mode,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabled,
|
||||
}: NativeInputProps) {
|
||||
const type = modeToInputType[mode] || "date";
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
lang="cs"
|
||||
value={value || ''}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="admin-form-input"
|
||||
required={required}
|
||||
@@ -69,22 +87,22 @@ function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabl
|
||||
min={minDate || undefined}
|
||||
max={maxDate || undefined}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminDatePickerProps {
|
||||
mode?: 'date' | 'month' | 'datetime' | 'time'
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
minDate?: string
|
||||
maxDate?: string
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
mode?: "date" | "month" | "datetime" | "time";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminDatePicker({
|
||||
mode = 'date',
|
||||
mode = "date",
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
@@ -93,7 +111,7 @@ export default function AdminDatePicker({
|
||||
disabled,
|
||||
placeholder,
|
||||
}: AdminDatePickerProps) {
|
||||
const useNative = useMemo(() => isTouchDevice(), [])
|
||||
const useNative = useMemo(() => isTouchDevice(), []);
|
||||
|
||||
if (useNative) {
|
||||
return (
|
||||
@@ -106,53 +124,66 @@ export default function AdminDatePicker({
|
||||
maxDate={maxDate}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const toDate = (val: string | null | undefined): Date | null => {
|
||||
if (!val) return null
|
||||
if (!val) return null;
|
||||
try {
|
||||
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
|
||||
if (mode === 'time') {
|
||||
const [h, m] = val.split(':')
|
||||
const d = new Date()
|
||||
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0)
|
||||
return d
|
||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
||||
if (mode === "time") {
|
||||
const [h, m] = val.split(":");
|
||||
const d = new Date();
|
||||
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
|
||||
return d;
|
||||
}
|
||||
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
|
||||
} catch { return null }
|
||||
return null
|
||||
}
|
||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleChange = (date: Date | null) => {
|
||||
if (!date) { onChange(''); return }
|
||||
if (mode === 'date') onChange(format(date, 'yyyy-MM-dd'))
|
||||
else if (mode === 'time') onChange(format(date, 'HH:mm'))
|
||||
else if (mode === 'month') onChange(format(date, 'yyyy-MM'))
|
||||
}
|
||||
if (!date) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
if (mode === "date") onChange(format(date, "yyyy-MM-dd"));
|
||||
else if (mode === "time") onChange(format(date, "HH:mm"));
|
||||
else if (mode === "month") onChange(format(date, "yyyy-MM"));
|
||||
};
|
||||
|
||||
const parseMinMax = (val: string | undefined): Date | undefined => {
|
||||
if (!val) return undefined
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
|
||||
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
|
||||
} catch { return undefined }
|
||||
return undefined
|
||||
}
|
||||
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
|
||||
if (mode === "month") return parse(val, "yyyy-MM", new Date());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
selected: toDate(value),
|
||||
onChange: handleChange,
|
||||
locale: 'cs',
|
||||
customInput: <CustomInput required={required} placeholder={placeholder} disabled={disabled} />,
|
||||
locale: "cs",
|
||||
customInput: (
|
||||
<CustomInput
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
minDate: parseMinMax(minDate),
|
||||
maxDate: parseMinMax(maxDate),
|
||||
popperPlacement: 'bottom-start' as const,
|
||||
portalId: 'datepicker-portal',
|
||||
popperPlacement: "bottom-start" as const,
|
||||
portalId: "datepicker-portal",
|
||||
disabled,
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'time') {
|
||||
if (mode === "time") {
|
||||
return (
|
||||
<DatePicker
|
||||
{...commonProps}
|
||||
@@ -163,23 +194,14 @@ export default function AdminDatePicker({
|
||||
dateFormat="HH:mm"
|
||||
timeFormat="HH:mm"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'month') {
|
||||
if (mode === "month") {
|
||||
return (
|
||||
<DatePicker
|
||||
{...commonProps}
|
||||
showMonthYearPicker
|
||||
dateFormat="MM/yyyy"
|
||||
/>
|
||||
)
|
||||
<DatePicker {...commonProps} showMonthYearPicker dateFormat="MM/yyyy" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
{...commonProps}
|
||||
dateFormat="dd.MM.yyyy"
|
||||
/>
|
||||
)
|
||||
return <DatePicker {...commonProps} dateFormat="dd.MM.yyyy" />;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,69 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Outlet, Navigate, useLocation } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTheme } from '../../context/ThemeContext'
|
||||
import { setLogoutAlert } from '../utils/api'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import Sidebar from './Sidebar'
|
||||
import ShortcutsHelp from './ShortcutsHelp'
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { setLogoutAlert } from "../utils/api";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ShortcutsHelp from "./ShortcutsHelp";
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { isAuthenticated, loading, user, logout } = useAuth()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [loggingOut, setLoggingOut] = useState(false)
|
||||
const location = useLocation()
|
||||
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Session is managed by AuthProvider (initial check + proactive refresh via setTimeout).
|
||||
// Do not call checkSession on route changes — concurrent refresh calls with token rotation
|
||||
// would invalidate each other and kick the user out.
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setLoggingOut(true)
|
||||
setSidebarOpen(false)
|
||||
setLogoutAlert()
|
||||
setTimeout(() => logout(), 400)
|
||||
}, [logout])
|
||||
setLoggingOut(true);
|
||||
setSidebarOpen(false);
|
||||
setLogoutAlert();
|
||||
setTimeout(() => logout(), 400);
|
||||
}, [logout]);
|
||||
|
||||
useModalLock(sidebarOpen)
|
||||
useModalLock(sidebarOpen);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<div className="admin-loading" style={{ width: '100%' }}>
|
||||
<div className="admin-loading" style={{ width: "100%" }}>
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
const needs2FASetup = user?.require2FA && !user?.totpEnabled
|
||||
if (needs2FASetup && location.pathname !== '/') {
|
||||
return <Navigate to="/" replace />
|
||||
const needs2FASetup = user?.require2FA && !user?.totpEnabled;
|
||||
if (needs2FASetup && location.pathname !== "/") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="admin-layout"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={loggingOut
|
||||
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
|
||||
: { scale: 1, opacity: 1, filter: 'none' }
|
||||
animate={
|
||||
loggingOut
|
||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||
: { scale: 1, opacity: 1, filter: "none" }
|
||||
}
|
||||
transition={{ duration: loggingOut ? 0.4 : 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} onLogout={handleLogout} />
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<div className="admin-main">
|
||||
<header className="admin-header">
|
||||
@@ -67,7 +72,14 @@ export default function AdminLayout() {
|
||||
className="admin-menu-btn"
|
||||
aria-label="Otevřít menu"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
@@ -79,22 +91,39 @@ export default function AdminLayout() {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="admin-header-theme-btn"
|
||||
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
|
||||
aria-label={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
|
||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||
>
|
||||
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
</header>
|
||||
|
||||
<main className="admin-content">
|
||||
@@ -103,5 +132,5 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
<ShortcutsHelp />
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,72 @@
|
||||
import React from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAlertState } from '../context/AlertContext'
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAlertState } from "../context/AlertContext";
|
||||
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
success: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
export default function AlertContainer() {
|
||||
const { alerts, removeAlert } = useAlertState()
|
||||
const { alerts, removeAlert } = useAlertState();
|
||||
|
||||
return (
|
||||
<div className="admin-alert-container" role="status" aria-live="polite">
|
||||
<AnimatePresence>
|
||||
{alerts.map(alert => (
|
||||
{alerts.map((alert) => (
|
||||
<motion.div
|
||||
key={alert.id}
|
||||
className={`admin-toast admin-toast-${alert.type}`}
|
||||
@@ -54,7 +82,14 @@ export default function AlertContainer() {
|
||||
onClick={() => removeAlert(alert.id)}
|
||||
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="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
@@ -63,5 +98,5 @@ export default function AlertContainer() {
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,93 +1,123 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
formatDate, formatDatetime, formatTime,
|
||||
calculateWorkMinutes, formatMinutes,
|
||||
getLeaveTypeName, getLeaveTypeBadgeClass
|
||||
} from '../utils/attendanceHelpers'
|
||||
formatDate,
|
||||
formatDatetime,
|
||||
formatTime,
|
||||
calculateWorkMinutes,
|
||||
formatMinutes,
|
||||
getLeaveTypeName,
|
||||
getLeaveTypeBadgeClass,
|
||||
} from "../utils/attendanceHelpers";
|
||||
|
||||
interface ProjectLog {
|
||||
id?: number
|
||||
project_id?: number
|
||||
project_name?: string
|
||||
started_at?: string
|
||||
ended_at?: string | null
|
||||
hours?: string | number | null
|
||||
minutes?: string | number | null
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
project_name?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string | null;
|
||||
hours?: string | number | null;
|
||||
minutes?: string | number | null;
|
||||
}
|
||||
|
||||
interface AttendanceRecord {
|
||||
id: number
|
||||
shift_date: string
|
||||
user_name: string
|
||||
leave_type?: string
|
||||
leave_hours?: number
|
||||
arrival_time?: string | null
|
||||
departure_time?: string | null
|
||||
break_start?: string | null
|
||||
break_end?: string | null
|
||||
arrival_lat?: number | string | null
|
||||
arrival_lng?: number | string | null
|
||||
departure_lat?: number | string | null
|
||||
departure_lng?: number | string | null
|
||||
project_name?: string
|
||||
project_logs?: ProjectLog[]
|
||||
notes?: string | null
|
||||
id: number;
|
||||
shift_date: string;
|
||||
user_name: string;
|
||||
leave_type?: string;
|
||||
leave_hours?: number;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
break_start?: string | null;
|
||||
break_end?: string | null;
|
||||
arrival_lat?: number | string | null;
|
||||
arrival_lng?: number | string | null;
|
||||
departure_lat?: number | string | null;
|
||||
departure_lng?: number | string | null;
|
||||
project_name?: string;
|
||||
project_logs?: ProjectLog[];
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface AttendanceShiftTableProps {
|
||||
records: AttendanceRecord[]
|
||||
onEdit: (record: AttendanceRecord) => void
|
||||
onDelete: (record: AttendanceRecord) => void
|
||||
records: AttendanceRecord[];
|
||||
onEdit: (record: AttendanceRecord) => void;
|
||||
onDelete: (record: AttendanceRecord) => void;
|
||||
}
|
||||
|
||||
function formatBreak(record: AttendanceRecord): string {
|
||||
if (record.break_start && record.break_end) {
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||
}
|
||||
if (record.break_start) {
|
||||
return `${formatTime(record.break_start)} - ?`
|
||||
return `${formatTime(record.break_start)} - ?`;
|
||||
}
|
||||
return '\u2014'
|
||||
return "\u2014";
|
||||
}
|
||||
|
||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
if (record.project_logs && record.project_logs.length > 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
||||
>
|
||||
{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) {
|
||||
h = parseInt(String(log.hours)) || 0
|
||||
m = parseInt(String(log.minutes)) || 0
|
||||
h = parseInt(String(log.hours)) || 0;
|
||||
m = parseInt(String(log.minutes)) || 0;
|
||||
} else {
|
||||
isActive = !log.ended_at
|
||||
const end = log.ended_at ? new Date(log.ended_at) : new Date()
|
||||
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
|
||||
h = Math.floor(mins / 60)
|
||||
m = mins % 60
|
||||
isActive = !log.ended_at;
|
||||
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
||||
const mins = Math.floor(
|
||||
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
||||
);
|
||||
h = Math.floor(mins / 60);
|
||||
m = mins % 60;
|
||||
}
|
||||
return (
|
||||
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' \u25B8' : ''})
|
||||
<span
|
||||
key={log.id || i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:
|
||||
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
|
||||
</span>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,40 +140,65 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || 'work'
|
||||
const isLeave = leaveType !== 'work'
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record)
|
||||
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
|
||||
: calculateWorkMinutes(record);
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
||||
<td>{record.user_name}</td>
|
||||
<td>
|
||||
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.arrival_time)}</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? '\u2014' : formatBreak(record)}
|
||||
{isLeave ? "\u2014" : formatDatetime(record.arrival_time)}
|
||||
</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 className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatBreak(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "\u2014" : formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? `${formatMinutes(workMinutes)} h`
|
||||
: "\u2014"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td>
|
||||
{hasLocation ? (
|
||||
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
|
||||
{'\uD83D\uDCCD'}
|
||||
<Link
|
||||
to={`/attendance/location/${record.id}`}
|
||||
className="attendance-gps-link"
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
>
|
||||
{"\uD83D\uDCCD"}
|
||||
</Link>
|
||||
) : '\u2014'}
|
||||
) : (
|
||||
"\u2014"
|
||||
)}
|
||||
</td>
|
||||
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
|
||||
{record.notes || ''}
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={record.notes || ""}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
@@ -153,7 +208,14 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
|
||||
title="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="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
@@ -164,7 +226,14 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
|
||||
title="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" />
|
||||
<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>
|
||||
@@ -172,10 +241,10 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import AdminDatePicker from './AdminDatePicker'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
|
||||
interface BulkAttendanceForm {
|
||||
month: string
|
||||
user_ids: string[]
|
||||
arrival_time: string
|
||||
departure_time: string
|
||||
break_start_time: string
|
||||
break_end_time: string
|
||||
month: string;
|
||||
user_ids: string[];
|
||||
arrival_time: string;
|
||||
departure_time: string;
|
||||
break_start_time: string;
|
||||
break_end_time: string;
|
||||
}
|
||||
|
||||
interface BulkAttendanceUser {
|
||||
id: number | string
|
||||
name: string
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BulkAttendanceModalProps {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
form: BulkAttendanceForm
|
||||
setForm: (form: BulkAttendanceForm) => void
|
||||
users: BulkAttendanceUser[]
|
||||
onSubmit: () => void
|
||||
submitting: boolean
|
||||
toggleUser: (userId: number | string) => void
|
||||
toggleAllUsers: () => void
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
form: BulkAttendanceForm;
|
||||
setForm: (form: BulkAttendanceForm) => void;
|
||||
users: BulkAttendanceUser[];
|
||||
onSubmit: () => void;
|
||||
submitting: boolean;
|
||||
toggleUser: (userId: number | string) => void;
|
||||
toggleAllUsers: () => void;
|
||||
}
|
||||
|
||||
export default function BulkAttendanceModal({
|
||||
@@ -39,7 +39,7 @@ export default function BulkAttendanceModal({
|
||||
toggleUser,
|
||||
toggleAllUsers,
|
||||
}: BulkAttendanceModalProps) {
|
||||
useModalLock(show)
|
||||
useModalLock(show);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -51,7 +51,10 @@ export default function BulkAttendanceModal({
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={() => !submitting && onClose()} />
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => !submitting && onClose()}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -61,8 +64,15 @@ export default function BulkAttendanceModal({
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Existující záznamy se přeskočí.
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
|
||||
označí. Existující záznamy se přeskočí.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -84,30 +94,32 @@ export default function BulkAttendanceModal({
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
style={{
|
||||
marginLeft: '0.75rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--accent-color)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8125rem',
|
||||
marginLeft: "0.75rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent-color)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length ? 'Odznačit vše' : 'Vybrat vše'}
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 'var(--border-radius-sm)',
|
||||
border: '1px solid var(--border-color)',
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius-sm)",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
@@ -132,7 +144,9 @@ export default function BulkAttendanceModal({
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
@@ -140,7 +154,9 @@ export default function BulkAttendanceModal({
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +167,9 @@ export default function BulkAttendanceModal({
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
@@ -159,7 +177,9 @@ export default function BulkAttendanceModal({
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,12 +201,12 @@ export default function BulkAttendanceModal({
|
||||
className="admin-btn admin-btn-primary"
|
||||
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>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: ReactNode
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
type?: 'danger' | 'warning' | 'default' | 'info'
|
||||
confirmVariant?: 'danger' | 'primary'
|
||||
loading?: boolean
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
type?: "danger" | "warning" | "default" | "info";
|
||||
confirmVariant?: "danger" | "primary";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({ isOpen, onClose, onConfirm, title, message, confirmText = 'Potvrdit', cancelText = 'Zrušit', type = 'default', confirmVariant, loading }: ConfirmModalProps) {
|
||||
export default function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Potvrdit",
|
||||
cancelText = "Zrušit",
|
||||
type = "default",
|
||||
confirmVariant,
|
||||
loading,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-confirm-modal"
|
||||
@@ -29,7 +46,14 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
|
||||
>
|
||||
<div className="admin-modal-body admin-confirm-content">
|
||||
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
@@ -39,14 +63,26 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag
|
||||
<p className="admin-confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button type="button" onClick={onClose} className="admin-btn admin-btn-secondary" disabled={loading}>{cancelText}</button>
|
||||
<button type="button" onClick={onConfirm} className="admin-btn admin-btn-primary" disabled={loading}>
|
||||
{loading ? 'Zpracování...' : confirmText}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Zpracování..." : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
import { Component, type ReactNode, type ErrorInfo } from 'react'
|
||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||
|
||||
interface Props { children: ReactNode }
|
||||
interface State { hasError: boolean; error: Error | null }
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null }
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info)
|
||||
console.error("ErrorBoundary caught:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
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>
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function Forbidden() {
|
||||
return (
|
||||
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
|
||||
<div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
>
|
||||
<h2>403</h2>
|
||||
<p>Nemáte oprávnění pro přístup k této stránce.</p>
|
||||
<Link to="/" className="admin-btn admin-btn-primary">Zpět na Dashboard</Link>
|
||||
<Link to="/" className="admin-btn admin-btn-primary">
|
||||
Zpět na Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
interface FormFieldProps {
|
||||
label: ReactNode
|
||||
children: ReactNode
|
||||
error?: string
|
||||
required?: boolean
|
||||
style?: React.CSSProperties
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function FormField({ label, children, error, required, style }: FormFieldProps) {
|
||||
export default function FormField({
|
||||
label,
|
||||
children,
|
||||
error,
|
||||
required,
|
||||
style,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div className="admin-form-group" style={style}>
|
||||
<label className="admin-form-label">
|
||||
@@ -18,5 +24,5 @@ export default function FormField({ label, children, error, required, style }: F
|
||||
{children}
|
||||
{error && <span className="admin-form-error">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,105 @@
|
||||
interface PaginationProps {
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
total_pages: number
|
||||
} | null
|
||||
onPageChange: (page: number) => void
|
||||
onPerPageChange?: (perPage: number) => void
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
} | null;
|
||||
onPageChange: (page: number) => void;
|
||||
onPerPageChange?: (perPage: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({ pagination, onPageChange, onPerPageChange }: PaginationProps) {
|
||||
if (!pagination || pagination.total_pages <= 1) return null
|
||||
export default function Pagination({
|
||||
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 pages: (number | string)[] = []
|
||||
const delta = 2
|
||||
const pages: (number | string)[] = [];
|
||||
const delta = 2;
|
||||
for (let i = 1; i <= total_pages; i++) {
|
||||
if (i === 1 || i === total_pages || (i >= page - delta && i <= page + delta)) {
|
||||
pages.push(i)
|
||||
} else if (pages[pages.length - 1] !== '...') {
|
||||
pages.push('...')
|
||||
if (
|
||||
i === 1 ||
|
||||
i === total_pages ||
|
||||
(i >= page - delta && i <= page + delta)
|
||||
) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== "...") {
|
||||
pages.push("...");
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-pagination">
|
||||
<div className="admin-pagination-info">
|
||||
{total} záznamů
|
||||
</div>
|
||||
<div className="admin-pagination-info">{total} záznamů</div>
|
||||
<div className="admin-pagination-controls">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="admin-pagination-page"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 18l-6-6 6-6" /></svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{getPages().map((p, i) =>
|
||||
typeof p === 'string' ? (
|
||||
<span key={`dots-${i}`} className="admin-pagination-ellipsis">...</span>
|
||||
typeof p === "string" ? (
|
||||
<span key={`dots-${i}`} className="admin-pagination-ellipsis">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`admin-pagination-page ${p === page ? 'active' : ''}`}
|
||||
className={`admin-pagination-page ${p === page ? "active" : ""}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
),
|
||||
)}
|
||||
<button
|
||||
disabled={page >= total_pages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="admin-pagination-page"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6" /></svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{onPerPageChange && (
|
||||
<select
|
||||
value={pagination.per_page}
|
||||
onChange={e => onPerPageChange(Number(e.target.value))}
|
||||
onChange={(e) => onPerPageChange(Number(e.target.value))}
|
||||
className="admin-pagination-select"
|
||||
>
|
||||
{[10, 25, 50, 100].map(n => (
|
||||
<option key={n} value={n}>{n} / stránka</option>
|
||||
{[10, 25, 50, 100].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} / stránka
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,173 @@
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
import ReactQuill from 'react-quill-new'
|
||||
import 'react-quill-new/dist/quill.snow.css'
|
||||
import { useMemo, useRef, useCallback } from "react";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
const Quill = ReactQuill.Quill
|
||||
const Quill = ReactQuill.Quill;
|
||||
|
||||
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 = [
|
||||
'arial', 'tahoma', 'verdana', 'georgia', 'times-new-roman',
|
||||
'courier-new', 'trebuchet-ms', 'impact', 'comic-sans-ms',
|
||||
'lucida-console', 'palatino-linotype', 'garamond'
|
||||
]
|
||||
Quill.register(Font, true)
|
||||
"arial",
|
||||
"tahoma",
|
||||
"verdana",
|
||||
"georgia",
|
||||
"times-new-roman",
|
||||
"courier-new",
|
||||
"trebuchet-ms",
|
||||
"impact",
|
||||
"comic-sans-ms",
|
||||
"lucida-console",
|
||||
"palatino-linotype",
|
||||
"garamond",
|
||||
];
|
||||
Quill.register(Font, true);
|
||||
|
||||
const SizeStyle = Quill.import('attributors/style/size') as any
|
||||
const SizeStyle = Quill.import("attributors/style/size") as any;
|
||||
SizeStyle.whitelist = [
|
||||
'8px', '9px', '10px', '11px', '12px', '14px', '16px',
|
||||
'18px', '20px', '24px', '28px', '32px', '36px', '48px'
|
||||
]
|
||||
Quill.register(SizeStyle, true)
|
||||
;(Quill as any).__bohaRegistered = true
|
||||
"8px",
|
||||
"9px",
|
||||
"10px",
|
||||
"11px",
|
||||
"12px",
|
||||
"14px",
|
||||
"16px",
|
||||
"18px",
|
||||
"20px",
|
||||
"24px",
|
||||
"28px",
|
||||
"32px",
|
||||
"36px",
|
||||
"48px",
|
||||
];
|
||||
Quill.register(SizeStyle, true);
|
||||
(Quill as any).__bohaRegistered = true;
|
||||
}
|
||||
|
||||
const Font = Quill.import('attributors/class/font') as any
|
||||
const Font = Quill.import("attributors/class/font") as any;
|
||||
const SIZE_WHITELIST = [
|
||||
'8px', '9px', '10px', '11px', '12px', '14px', '16px',
|
||||
'18px', '20px', '24px', '28px', '32px', '36px', '48px'
|
||||
]
|
||||
"8px",
|
||||
"9px",
|
||||
"10px",
|
||||
"11px",
|
||||
"12px",
|
||||
"14px",
|
||||
"16px",
|
||||
"18px",
|
||||
"20px",
|
||||
"24px",
|
||||
"28px",
|
||||
"32px",
|
||||
"36px",
|
||||
"48px",
|
||||
];
|
||||
|
||||
const COLORS = [
|
||||
'#000000', '#1a1a1a', '#333333', '#555555', '#777777', '#999999', '#bbbbbb', '#dddddd', '#ffffff',
|
||||
'#de3a3a', '#e57373', '#c62828',
|
||||
'#1565c0', '#42a5f5', '#0d47a1',
|
||||
'#2e7d32', '#66bb6a', '#1b5e20',
|
||||
'#f57f17', '#ffca28', '#e65100',
|
||||
'#6a1b9a', '#ab47bc', '#4a148c',
|
||||
'#00695c', '#26a69a', '#004d40',
|
||||
'#37474f', '#78909c', '#263238',
|
||||
]
|
||||
"#000000",
|
||||
"#1a1a1a",
|
||||
"#333333",
|
||||
"#555555",
|
||||
"#777777",
|
||||
"#999999",
|
||||
"#bbbbbb",
|
||||
"#dddddd",
|
||||
"#ffffff",
|
||||
"#de3a3a",
|
||||
"#e57373",
|
||||
"#c62828",
|
||||
"#1565c0",
|
||||
"#42a5f5",
|
||||
"#0d47a1",
|
||||
"#2e7d32",
|
||||
"#66bb6a",
|
||||
"#1b5e20",
|
||||
"#f57f17",
|
||||
"#ffca28",
|
||||
"#e65100",
|
||||
"#6a1b9a",
|
||||
"#ab47bc",
|
||||
"#4a148c",
|
||||
"#00695c",
|
||||
"#26a69a",
|
||||
"#004d40",
|
||||
"#37474f",
|
||||
"#78909c",
|
||||
"#263238",
|
||||
];
|
||||
|
||||
const TOOLBAR = [
|
||||
[{ font: Font.whitelist }],
|
||||
[{ size: SIZE_WHITELIST }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ color: COLORS }, { background: COLORS }],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ indent: '-1' }, { indent: '+1' }],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
[{ indent: "-1" }, { indent: "+1" }],
|
||||
[{ align: [] }],
|
||||
['link'],
|
||||
['clean']
|
||||
]
|
||||
["link"],
|
||||
["clean"],
|
||||
];
|
||||
|
||||
const FORMATS = [
|
||||
'font', 'size',
|
||||
'bold', 'italic', 'underline', 'strike',
|
||||
'color', 'background',
|
||||
'list', 'indent', 'align',
|
||||
'link'
|
||||
]
|
||||
"font",
|
||||
"size",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"color",
|
||||
"background",
|
||||
"list",
|
||||
"indent",
|
||||
"align",
|
||||
"link",
|
||||
];
|
||||
|
||||
interface RichEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
minHeight?: string
|
||||
readOnly?: boolean
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
minHeight?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function RichEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Obsah...',
|
||||
minHeight = '120px',
|
||||
placeholder = "Obsah...",
|
||||
minHeight = "120px",
|
||||
readOnly = false,
|
||||
}: RichEditorProps) {
|
||||
const quillRef = useRef<ReactQuill>(null)
|
||||
const lastValueRef = useRef(value)
|
||||
const quillRef = useRef<ReactQuill>(null);
|
||||
const lastValueRef = useRef(value);
|
||||
|
||||
const modules = useMemo(() => ({
|
||||
toolbar: readOnly ? false : TOOLBAR,
|
||||
clipboard: {
|
||||
matchVisual: false,
|
||||
const modules = useMemo(
|
||||
() => ({
|
||||
toolbar: readOnly ? false : TOOLBAR,
|
||||
clipboard: {
|
||||
matchVisual: false,
|
||||
},
|
||||
}),
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(content: string, _delta: any, source: string) => {
|
||||
if (source !== "user") return;
|
||||
if (content === lastValueRef.current) return;
|
||||
lastValueRef.current = content;
|
||||
onChange(content);
|
||||
},
|
||||
}), [readOnly])
|
||||
|
||||
const handleChange = useCallback((content: string, _delta: any, source: string) => {
|
||||
if (source !== 'user') return
|
||||
if (content === lastValueRef.current) return
|
||||
lastValueRef.current = content
|
||||
onChange(content)
|
||||
}, [onChange])
|
||||
[onChange],
|
||||
);
|
||||
|
||||
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
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={value || ''}
|
||||
value={value || ""}
|
||||
onChange={handleChange}
|
||||
modules={modules}
|
||||
formats={FORMATS}
|
||||
@@ -104,5 +175,5 @@ export default function RichEditor({
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,119 +1,123 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import AdminDatePicker from './AdminDatePicker'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import { calcFormWorkMinutes, calcProjectMinutesTotal, formatDate } from '../utils/attendanceHelpers'
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import {
|
||||
calcFormWorkMinutes,
|
||||
calcProjectMinutesTotal,
|
||||
formatDate,
|
||||
} from "../utils/attendanceHelpers";
|
||||
|
||||
let _logKeyCounter = 0
|
||||
let _logKeyCounter = 0;
|
||||
|
||||
// ---------- Shared types ----------
|
||||
|
||||
export interface ShiftFormData {
|
||||
user_id: string
|
||||
shift_date: string
|
||||
leave_type: string
|
||||
leave_hours: number
|
||||
arrival_date: string
|
||||
arrival_time: string
|
||||
break_start_date: string
|
||||
break_start_time: string
|
||||
break_end_date: string
|
||||
break_end_time: string
|
||||
departure_date: string
|
||||
departure_time: string
|
||||
notes: string
|
||||
user_id: string;
|
||||
shift_date: string;
|
||||
leave_type: string;
|
||||
leave_hours: number;
|
||||
arrival_date: string;
|
||||
arrival_time: string;
|
||||
break_start_date: string;
|
||||
break_start_time: string;
|
||||
break_end_date: string;
|
||||
break_end_time: string;
|
||||
departure_date: string;
|
||||
departure_time: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface ProjectLog {
|
||||
_key?: string
|
||||
id?: number
|
||||
project_id: string | number
|
||||
hours: string | number
|
||||
minutes: string | number
|
||||
_key?: string;
|
||||
id?: number;
|
||||
project_id: string | number;
|
||||
hours: string | number;
|
||||
minutes: string | number;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number | string
|
||||
project_number: string
|
||||
name: string
|
||||
id: number | string;
|
||||
project_number: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number | string
|
||||
name: string
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EditingRecord {
|
||||
user_name: string
|
||||
shift_date: string
|
||||
user_name: string;
|
||||
shift_date: string;
|
||||
}
|
||||
|
||||
// ---------- Sub-component props ----------
|
||||
|
||||
interface ProjectTimeStatusProps {
|
||||
form: ShiftFormData
|
||||
projectLogs: ProjectLog[]
|
||||
form: ShiftFormData;
|
||||
projectLogs: ProjectLog[];
|
||||
}
|
||||
|
||||
interface ProjectLogRowProps {
|
||||
log: ProjectLog
|
||||
index: number
|
||||
projectList: Project[]
|
||||
onUpdate: (index: number, field: string, value: string) => void
|
||||
onRemove: (index: number) => void
|
||||
log: ProjectLog;
|
||||
index: number;
|
||||
projectList: Project[];
|
||||
onUpdate: (index: number, field: string, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface ShiftFormModalProps {
|
||||
mode: 'create' | 'edit'
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
form: ShiftFormData
|
||||
setForm: (form: ShiftFormData) => void
|
||||
projectLogs: ProjectLog[]
|
||||
setProjectLogs: (logs: ProjectLog[]) => void
|
||||
projectList: Project[]
|
||||
users: User[]
|
||||
onShiftDateChange: (value: string) => void
|
||||
editingRecord: EditingRecord | null
|
||||
mode: "create" | "edit";
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
form: ShiftFormData;
|
||||
setForm: (form: ShiftFormData) => void;
|
||||
projectLogs: ProjectLog[];
|
||||
setProjectLogs: (logs: ProjectLog[]) => void;
|
||||
projectList: Project[];
|
||||
users: User[];
|
||||
onShiftDateChange: (value: string) => void;
|
||||
editingRecord: EditingRecord | null;
|
||||
}
|
||||
|
||||
// ---------- ProjectTimeStatus ----------
|
||||
|
||||
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||
const totalWork = calcFormWorkMinutes(form)
|
||||
const totalProject = calcProjectMinutesTotal(projectLogs)
|
||||
const remaining = totalWork - totalProject
|
||||
const hasLogs = projectLogs.some((l) => l.project_id)
|
||||
const totalWork = calcFormWorkMinutes(form);
|
||||
const totalProject = calcProjectMinutesTotal(projectLogs);
|
||||
const remaining = totalWork - totalProject;
|
||||
const hasLogs = projectLogs.some((l) => l.project_id);
|
||||
|
||||
if (!hasLogs || totalWork <= 0) return null
|
||||
if (!hasLogs || totalWork <= 0) return null;
|
||||
|
||||
const isMatch = remaining === 0
|
||||
const isMatch = remaining === 0;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8rem',
|
||||
padding: "0.5rem 0.75rem",
|
||||
marginBottom: "0.5rem",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.8rem",
|
||||
background: isMatch
|
||||
? 'var(--success-bg, rgba(34,197,94,0.1))'
|
||||
: 'var(--danger-bg, rgba(239,68,68,0.1))',
|
||||
? "var(--success-bg, rgba(34,197,94,0.1))"
|
||||
: "var(--danger-bg, rgba(239,68,68,0.1))",
|
||||
color: isMatch
|
||||
? 'var(--success-color, #16a34a)'
|
||||
: 'var(--danger-color, #dc2626)',
|
||||
? "var(--success-color, #16a34a)"
|
||||
: "var(--danger-color, #dc2626)",
|
||||
border: `1px solid ${
|
||||
isMatch
|
||||
? 'var(--success-border, rgba(34,197,94,0.3))'
|
||||
: 'var(--danger-border, rgba(239,68,68,0.3))'
|
||||
? "var(--success-border, rgba(34,197,94,0.3))"
|
||||
: "var(--danger-border, rgba(239,68,68,0.3))"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
|
||||
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
|
||||
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{' '}
|
||||
{Math.abs(remaining) % 60}m {remaining < 0 ? '(překročeno)' : ''}
|
||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
|
||||
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
|
||||
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
|
||||
{remaining < 0 ? "(překročeno)" : ""}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- ProjectLogRow ----------
|
||||
@@ -129,7 +133,7 @@ function ProjectLogRow({
|
||||
<div className="flex-row gap-2 mb-2">
|
||||
<select
|
||||
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"
|
||||
style={{ flex: 3, marginBottom: 0 }}
|
||||
>
|
||||
@@ -145,12 +149,12 @@ function ProjectLogRow({
|
||||
min="0"
|
||||
max="24"
|
||||
value={log.hours}
|
||||
onChange={(e) => onUpdate(index, 'hours', e.target.value)}
|
||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="h"
|
||||
/>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
h
|
||||
</span>
|
||||
<input
|
||||
@@ -158,19 +162,19 @@ function ProjectLogRow({
|
||||
min="0"
|
||||
max="59"
|
||||
value={log.minutes}
|
||||
onChange={(e) => onUpdate(index, 'minutes', e.target.value)}
|
||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="m"
|
||||
/>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
m
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
style={{ padding: '0.375rem', flexShrink: 0 }}
|
||||
style={{ padding: "0.375rem", flexShrink: 0 }}
|
||||
title="Odebrat"
|
||||
>
|
||||
<svg
|
||||
@@ -185,7 +189,7 @@ function ProjectLogRow({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- ShiftFormModal ----------
|
||||
@@ -204,30 +208,35 @@ export default function ShiftFormModal({
|
||||
onShiftDateChange,
|
||||
editingRecord,
|
||||
}: ShiftFormModalProps) {
|
||||
useModalLock(show)
|
||||
const isCreate = mode === 'create'
|
||||
const isWorkType = form.leave_type === 'work'
|
||||
useModalLock(show);
|
||||
const isCreate = mode === "create";
|
||||
const isWorkType = form.leave_type === "work";
|
||||
|
||||
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 updated = [...projectLogs]
|
||||
updated[index] = { ...updated[index], [field]: value }
|
||||
setProjectLogs(updated)
|
||||
}
|
||||
const updated = [...projectLogs];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setProjectLogs(updated);
|
||||
};
|
||||
|
||||
const removeProjectLog = (index: number) => {
|
||||
setProjectLogs(projectLogs.filter((_, j) => j !== index))
|
||||
}
|
||||
setProjectLogs(projectLogs.filter((_, j) => j !== index));
|
||||
};
|
||||
|
||||
const addProjectLog = () => {
|
||||
setProjectLogs([
|
||||
...projectLogs,
|
||||
{ _key: `log-${++_logKeyCounter}`, project_id: '', hours: '', minutes: '' },
|
||||
])
|
||||
}
|
||||
{
|
||||
_key: `log-${++_logKeyCounter}`,
|
||||
project_id: "",
|
||||
hours: "",
|
||||
minutes: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -249,16 +258,16 @@ export default function ShiftFormModal({
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<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>
|
||||
{!isCreate && editingRecord && (
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '0.25rem',
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{editingRecord.user_name} —{' '}
|
||||
{editingRecord.user_name} —{" "}
|
||||
{formatDate(editingRecord.shift_date)}
|
||||
</p>
|
||||
)}
|
||||
@@ -274,9 +283,7 @@ export default function ShiftFormModal({
|
||||
</label>
|
||||
<select
|
||||
value={form.user_id}
|
||||
onChange={(e) =>
|
||||
updateField('user_id', e.target.value)
|
||||
}
|
||||
onChange={(e) => updateField("user_id", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
@@ -304,7 +311,7 @@ export default function ShiftFormModal({
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField('shift_date', val)}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -313,9 +320,7 @@ export default function ShiftFormModal({
|
||||
<label className="admin-form-label">Typ záznamu</label>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) =>
|
||||
updateField('leave_type', e.target.value)
|
||||
}
|
||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
@@ -334,7 +339,7 @@ export default function ShiftFormModal({
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField('leave_hours', parseFloat(e.target.value))
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
@@ -359,9 +364,7 @@ export default function ShiftFormModal({
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val) =>
|
||||
updateField('arrival_date', val)
|
||||
}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
@@ -371,9 +374,7 @@ export default function ShiftFormModal({
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) =>
|
||||
updateField('arrival_time', val)
|
||||
}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +388,7 @@ export default function ShiftFormModal({
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val) =>
|
||||
updateField('break_start_date', val)
|
||||
updateField("break_start_date", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -399,7 +400,7 @@ export default function ShiftFormModal({
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
updateField('break_start_time', val)
|
||||
updateField("break_start_time", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -413,9 +414,7 @@ export default function ShiftFormModal({
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val) =>
|
||||
updateField('break_end_date', val)
|
||||
}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
@@ -425,9 +424,7 @@ export default function ShiftFormModal({
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) =>
|
||||
updateField('break_end_time', val)
|
||||
}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,21 +437,15 @@ export default function ShiftFormModal({
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val) =>
|
||||
updateField('departure_date', val)
|
||||
}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Odchod - čas
|
||||
</label>
|
||||
<label className="admin-form-label">Odchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) =>
|
||||
updateField('departure_time', val)
|
||||
}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,7 +480,7 @@ export default function ShiftFormModal({
|
||||
<label className="admin-form-label">Poznámka</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField('notes', e.target.value)}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -517,5 +508,5 @@ export default function ShiftFormModal({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default function ShortcutsHelp() {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,363 +1,488 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTheme } from '../../context/ThemeContext'
|
||||
import { type ReactNode } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
interface MenuItem {
|
||||
path: string
|
||||
label: string
|
||||
end?: boolean
|
||||
permission?: string | string[]
|
||||
matchPrefix?: string
|
||||
matchAlso?: string[]
|
||||
matchExclude?: string[]
|
||||
icon: ReactNode
|
||||
path: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
permission?: string | string[];
|
||||
matchPrefix?: string;
|
||||
matchAlso?: string[];
|
||||
matchExclude?: string[];
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface MenuSection {
|
||||
label: string
|
||||
items: MenuItem[]
|
||||
label: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
const menuSections: MenuSection[] = [
|
||||
{
|
||||
label: 'Přehled',
|
||||
label: "Přehled",
|
||||
items: [
|
||||
{
|
||||
path: '/',
|
||||
label: 'Přehled',
|
||||
path: "/",
|
||||
label: "Přehled",
|
||||
end: true,
|
||||
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="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
]
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Docházka',
|
||||
label: "Docházka",
|
||||
items: [
|
||||
{
|
||||
path: '/attendance',
|
||||
label: 'Záznam',
|
||||
permission: 'attendance.record',
|
||||
path: "/attendance",
|
||||
label: "Záznam",
|
||||
permission: "attendance.record",
|
||||
end: true,
|
||||
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" />
|
||||
<polyline points="12 7 12 12 15 15" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/attendance/history',
|
||||
label: 'Moje historie',
|
||||
permission: 'attendance.history',
|
||||
path: "/attendance/history",
|
||||
label: "Moje historie",
|
||||
permission: "attendance.history",
|
||||
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" />
|
||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/attendance/requests',
|
||||
label: 'Žádosti',
|
||||
permission: 'attendance.record',
|
||||
path: "/attendance/requests",
|
||||
label: "Žádosti",
|
||||
permission: "attendance.record",
|
||||
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" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/attendance/approval',
|
||||
label: 'Schvalování',
|
||||
permission: 'attendance.approve',
|
||||
path: "/attendance/approval",
|
||||
label: "Schvalování",
|
||||
permission: "attendance.approve",
|
||||
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" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/attendance/admin',
|
||||
label: 'Správa',
|
||||
permission: 'attendance.admin',
|
||||
matchPrefix: '/attendance/admin',
|
||||
matchAlso: ['/attendance/create', '/attendance/location'],
|
||||
path: "/attendance/admin",
|
||||
label: "Správa",
|
||||
permission: "attendance.admin",
|
||||
matchPrefix: "/attendance/admin",
|
||||
matchAlso: ["/attendance/create", "/attendance/location"],
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/attendance/balances',
|
||||
label: 'Správa bilancí',
|
||||
permission: 'attendance.balances',
|
||||
path: "/attendance/balances",
|
||||
label: "Správa bilancí",
|
||||
permission: "attendance.balances",
|
||||
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="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
]
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Kniha jízd',
|
||||
label: "Kniha jízd",
|
||||
items: [
|
||||
{
|
||||
path: '/trips',
|
||||
label: 'Záznam',
|
||||
permission: 'trips.record',
|
||||
path: "/trips",
|
||||
label: "Záznam",
|
||||
permission: "trips.record",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="5" cy="18" r="3" /><circle cx="19" cy="18" r="3" />
|
||||
<path d="M5 18V12L8 5h8l3 7v6" /><path d="M10 18h4" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="5" cy="18" r="3" />
|
||||
<circle cx="19" cy="18" r="3" />
|
||||
<path d="M5 18V12L8 5h8l3 7v6" />
|
||||
<path d="M10 18h4" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/trips/history',
|
||||
label: 'Moje historie',
|
||||
permission: 'trips.history',
|
||||
path: "/trips/history",
|
||||
label: "Moje historie",
|
||||
permission: "trips.history",
|
||||
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" />
|
||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/trips/admin',
|
||||
label: 'Správa',
|
||||
permission: 'trips.admin',
|
||||
path: "/trips/admin",
|
||||
label: "Správa",
|
||||
permission: "trips.admin",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/vehicles',
|
||||
label: 'Vozidla',
|
||||
permission: 'trips.vehicles',
|
||||
path: "/vehicles",
|
||||
label: "Vozidla",
|
||||
permission: "trips.vehicles",
|
||||
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" />
|
||||
<path d="M16 8h4l3 3v5h-7V8z" />
|
||||
<circle cx="5.5" cy="18.5" r="2.5" />
|
||||
<circle cx="18.5" cy="18.5" r="2.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
]
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administrativa',
|
||||
label: "Administrativa",
|
||||
items: [
|
||||
{
|
||||
path: '/offers',
|
||||
label: 'Nabídky',
|
||||
permission: 'offers.view',
|
||||
matchPrefix: '/offers',
|
||||
matchExclude: ['/offers/customers', '/offers/templates'],
|
||||
path: "/offers",
|
||||
label: "Nabídky",
|
||||
permission: "offers.view",
|
||||
matchPrefix: "/offers",
|
||||
matchExclude: ["/offers/customers", "/offers/templates"],
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
label: 'Objednávky',
|
||||
permission: 'orders.view',
|
||||
matchPrefix: '/orders',
|
||||
path: "/orders",
|
||||
label: "Objednávky",
|
||||
permission: "orders.view",
|
||||
matchPrefix: "/orders",
|
||||
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" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<path d="M16 10a4 4 0 0 1-8 0" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/invoices',
|
||||
label: 'Faktury',
|
||||
permission: 'invoices.view',
|
||||
matchPrefix: '/invoices',
|
||||
path: "/invoices",
|
||||
label: "Faktury",
|
||||
permission: "invoices.view",
|
||||
matchPrefix: "/invoices",
|
||||
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" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
label: 'Projekty',
|
||||
permission: 'projects.view',
|
||||
matchPrefix: '/projects',
|
||||
path: "/projects",
|
||||
label: "Projekty",
|
||||
permission: "projects.view",
|
||||
matchPrefix: "/projects",
|
||||
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" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/offers/customers',
|
||||
label: 'Zákazníci',
|
||||
permission: 'offers.view',
|
||||
path: "/offers/customers",
|
||||
label: "Zákazníci",
|
||||
permission: "offers.view",
|
||||
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" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/company/settings',
|
||||
label: 'Firma',
|
||||
permission: 'offers.settings',
|
||||
path: "/company/settings",
|
||||
label: "Firma",
|
||||
permission: "offers.settings",
|
||||
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" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
]
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Systém',
|
||||
label: "Systém",
|
||||
items: [
|
||||
{
|
||||
path: '/users',
|
||||
label: 'Uživatelé',
|
||||
permission: 'users.view',
|
||||
path: "/users",
|
||||
label: "Uživatelé",
|
||||
permission: "users.view",
|
||||
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" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: 'Nastavení',
|
||||
permission: ['settings.roles', 'settings.security'],
|
||||
path: "/settings",
|
||||
label: "Nastavení",
|
||||
permission: ["settings.roles", "settings.security"],
|
||||
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" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/audit-log',
|
||||
label: 'Audit log',
|
||||
permission: 'settings.audit',
|
||||
path: "/audit-log",
|
||||
label: "Audit log",
|
||||
permission: "settings.audit",
|
||||
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" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onLogout: () => void
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
|
||||
const { user, hasPermission } = useAuth()
|
||||
const { theme } = useTheme()
|
||||
const location = useLocation()
|
||||
const { user, hasPermission } = useAuth();
|
||||
const { theme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const isItemActive = (item: MenuItem) => {
|
||||
if (item.matchPrefix) {
|
||||
let active = location.pathname.startsWith(item.matchPrefix)
|
||||
let active = location.pathname.startsWith(item.matchPrefix);
|
||||
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) {
|
||||
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) => {
|
||||
if (!item.permission) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
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
|
||||
.map(section => ({
|
||||
.map((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 (
|
||||
<>
|
||||
<div
|
||||
className={`admin-sidebar-overlay${isOpen ? ' open' : ''}`}
|
||||
className={`admin-sidebar-overlay${isOpen ? " open" : ""}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<aside className={`admin-sidebar${isOpen ? ' open' : ''}`}>
|
||||
<aside className={`admin-sidebar${isOpen ? " open" : ""}`}>
|
||||
<div className="admin-sidebar-header">
|
||||
<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"
|
||||
className="admin-sidebar-logo"
|
||||
/>
|
||||
<button onClick={onClose} className="admin-sidebar-close" aria-label="Zavřít menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="admin-sidebar-close"
|
||||
aria-label="Zavřít menu"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -374,11 +499,13 @@ export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
|
||||
end={item.end}
|
||||
onClick={onClose}
|
||||
className={() => {
|
||||
let active = isItemActive(item)
|
||||
let active = isItemActive(item);
|
||||
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}
|
||||
@@ -392,20 +519,27 @@ export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
|
||||
<div className="admin-sidebar-footer">
|
||||
<div className="admin-user-chip">
|
||||
<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 className="admin-user-details">
|
||||
<div className="admin-user-name">
|
||||
{user?.fullName || user?.username}
|
||||
</div>
|
||||
<div className="admin-user-role">
|
||||
{user?.roleDisplay}
|
||||
</div>
|
||||
<div className="admin-user-role">{user?.roleDisplay}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={onLogout} className="admin-logout-btn" aria-label="Odhlásit se">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="admin-logout-btn"
|
||||
aria-label="Odhlásit se"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
@@ -415,5 +549,5 @@ export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
interface SortIconProps {
|
||||
column: string
|
||||
sort: string | null
|
||||
order: string
|
||||
column: string;
|
||||
sort: string | null;
|
||||
order: string;
|
||||
}
|
||||
|
||||
export default function SortIcon({ column, sort, order }: SortIconProps) {
|
||||
if (sort !== column) {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.3, marginLeft: 4 }}>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ opacity: 0.3, marginLeft: 4 }}
|
||||
>
|
||||
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 4 }}>
|
||||
{order === 'asc' ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
{order === "asc" ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +1,139 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers'
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
getActivityIconClass,
|
||||
formatActivityTime,
|
||||
} from "../../utils/dashboardHelpers";
|
||||
|
||||
interface Activity {
|
||||
id: number | string
|
||||
action: string
|
||||
description: string
|
||||
username?: string
|
||||
entity_type: string
|
||||
created_at: string
|
||||
id: number | string;
|
||||
action: string;
|
||||
description: string;
|
||||
username?: string;
|
||||
entity_type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface DashActivityFeedProps {
|
||||
activities: Activity[] | null
|
||||
activities: Activity[] | null;
|
||||
}
|
||||
|
||||
function getActivityIcon(action: string) {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
case "create":
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
case 'update':
|
||||
);
|
||||
case "update":
|
||||
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="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
)
|
||||
case 'delete':
|
||||
);
|
||||
case "delete":
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
)
|
||||
case 'login':
|
||||
);
|
||||
case "login":
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
|
||||
<polyline points="10 17 15 12 10 7" />
|
||||
<line x1="15" y1="12" x2="3" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashActivityFeed({ activities }: DashActivityFeedProps) {
|
||||
export default function DashActivityFeed({
|
||||
activities,
|
||||
}: DashActivityFeedProps) {
|
||||
if (!activities) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-activity-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Audit log</h2>
|
||||
<Link to="/audit-log" className="admin-btn admin-btn-primary admin-btn-sm">Detail →</Link>
|
||||
<Link
|
||||
to="/audit-log"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{activities.map((act) => (
|
||||
<div key={act.id} className="dash-activity-row">
|
||||
<div className={`dash-activity-icon ${getActivityIconClass(act.action)}`}>
|
||||
<div
|
||||
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</div>
|
||||
<div className="dash-activity-main">
|
||||
<div className="dash-activity-text">{act.description}</div>
|
||||
<div className="dash-activity-sub">{act.username || 'Systém'} · {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}</div>
|
||||
<div 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 className="dash-activity-time admin-mono">{formatActivityTime(act.created_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,71 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { LEAVE_TYPE_LABELS, STATUS_DOT_CLASS, STATUS_LABELS } from '../../utils/dashboardHelpers'
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LEAVE_TYPE_LABELS,
|
||||
STATUS_DOT_CLASS,
|
||||
STATUS_LABELS,
|
||||
} from "../../utils/dashboardHelpers";
|
||||
|
||||
interface AttendanceUser {
|
||||
user_id: number | string
|
||||
name: string
|
||||
initials?: string
|
||||
status: string
|
||||
leave_type?: string
|
||||
arrived_at?: string
|
||||
user_id: number | string;
|
||||
name: string;
|
||||
initials?: string;
|
||||
status: string;
|
||||
leave_type?: string;
|
||||
arrived_at?: string;
|
||||
}
|
||||
|
||||
interface AttendanceData {
|
||||
users: AttendanceUser[]
|
||||
users: AttendanceUser[];
|
||||
}
|
||||
|
||||
interface DashAttendanceTodayProps {
|
||||
attendance: AttendanceData | null
|
||||
attendance: AttendanceData | null;
|
||||
}
|
||||
|
||||
export default function DashAttendanceToday({ attendance }: DashAttendanceTodayProps) {
|
||||
export default function DashAttendanceToday({
|
||||
attendance,
|
||||
}: DashAttendanceTodayProps) {
|
||||
if (!attendance) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-attendance-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Docházka dnes</h2>
|
||||
<Link to="/attendance/admin" className="admin-btn admin-btn-primary admin-btn-sm">Detail →</Link>
|
||||
<Link
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{attendance.users.map((u, i) => (
|
||||
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
|
||||
<div className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}>
|
||||
{u.initials || '?'}
|
||||
<div
|
||||
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</div>
|
||||
<div className="dash-presence-name">{u.name}</div>
|
||||
<div className="dash-presence-end">
|
||||
<span className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}>
|
||||
{u.status === 'leave' ? (LEAVE_TYPE_LABELS[u.leave_type || ''] || 'Nepřítomen') : STATUS_LABELS[u.status]}
|
||||
<span
|
||||
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
|
||||
>
|
||||
{u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]}
|
||||
</span>
|
||||
{u.arrived_at && <span className="admin-mono dash-presence-time">{u.arrived_at}</span>}
|
||||
{u.arrived_at && (
|
||||
<span className="admin-mono dash-presence-time">
|
||||
{u.arrived_at}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,112 +1,127 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { formatCurrency } from '../../utils/formatters'
|
||||
import { motion } from "framer-motion";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
interface KpiCard {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
color: string
|
||||
footer: string | null
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
color: string;
|
||||
footer: string | null;
|
||||
}
|
||||
|
||||
interface RevenueItem {
|
||||
amount: number
|
||||
currency: string
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface InvoicesData {
|
||||
revenue_this_month: RevenueItem[]
|
||||
revenue_czk?: number | null
|
||||
unpaid_count: number
|
||||
revenue_this_month: RevenueItem[];
|
||||
revenue_czk?: number | null;
|
||||
unpaid_count: number;
|
||||
}
|
||||
|
||||
interface DashData {
|
||||
attendance?: {
|
||||
present_today: number
|
||||
total_active: number
|
||||
on_leave: number
|
||||
}
|
||||
present_today: number;
|
||||
total_active: number;
|
||||
on_leave: number;
|
||||
};
|
||||
offers?: {
|
||||
open_count: number
|
||||
created_this_month: number
|
||||
}
|
||||
invoices?: InvoicesData
|
||||
open_count: number;
|
||||
created_this_month: number;
|
||||
};
|
||||
invoices?: InvoicesData;
|
||||
leave_pending?: {
|
||||
count: number
|
||||
}
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DashKpiCardsProps {
|
||||
dashData: DashData | null
|
||||
dashData: DashData | null;
|
||||
}
|
||||
|
||||
function buildKpiCards(dashData: DashData | null): KpiCard[] {
|
||||
const cards: KpiCard[] = []
|
||||
const cards: KpiCard[] = [];
|
||||
if (dashData?.attendance) {
|
||||
cards.push({
|
||||
label: 'Přítomní dnes',
|
||||
label: "Přítomní dnes",
|
||||
value: `${dashData.attendance.present_today}`,
|
||||
sub: `/ ${dashData.attendance.total_active}`,
|
||||
color: 'success',
|
||||
footer: dashData.attendance.on_leave > 0 ? `${dashData.attendance.on_leave} nepřítomných` : null,
|
||||
})
|
||||
color: "success",
|
||||
footer:
|
||||
dashData.attendance.on_leave > 0
|
||||
? `${dashData.attendance.on_leave} nepřítomných`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (dashData?.offers) {
|
||||
cards.push({
|
||||
label: 'Otevřené nabídky',
|
||||
label: "Otevřené nabídky",
|
||||
value: `${dashData.offers.open_count}`,
|
||||
color: 'info',
|
||||
footer: dashData.offers.created_this_month > 0 ? `${dashData.offers.created_this_month} tento měsíc` : null,
|
||||
})
|
||||
color: "info",
|
||||
footer:
|
||||
dashData.offers.created_this_month > 0
|
||||
? `${dashData.offers.created_this_month} tento měsíc`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (dashData?.invoices) {
|
||||
cards.push(buildInvoiceKpi(dashData.invoices))
|
||||
cards.push(buildInvoiceKpi(dashData.invoices));
|
||||
}
|
||||
if (dashData?.leave_pending) {
|
||||
cards.push({
|
||||
label: 'Žádosti o volno',
|
||||
label: "Žádosti o volno",
|
||||
value: `${dashData.leave_pending.count}`,
|
||||
color: 'danger',
|
||||
footer: dashData.leave_pending.count > 0 ? 'čeká na schválení' : null,
|
||||
})
|
||||
color: "danger",
|
||||
footer: dashData.leave_pending.count > 0 ? "čeká na schválení" : null,
|
||||
});
|
||||
}
|
||||
return cards
|
||||
return cards;
|
||||
}
|
||||
|
||||
function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
||||
const rev = invoices.revenue_this_month || []
|
||||
const hasForeign = rev.some(r => r.currency !== 'CZK')
|
||||
const hasCzkTotal = hasForeign && invoices.revenue_czk !== null && invoices.revenue_czk !== undefined
|
||||
const fallbackText = rev.length > 0
|
||||
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
|
||||
: '0 Kč'
|
||||
const rev = invoices.revenue_this_month || [];
|
||||
const hasForeign = rev.some((r) => r.currency !== "CZK");
|
||||
const hasCzkTotal =
|
||||
hasForeign &&
|
||||
invoices.revenue_czk !== null &&
|
||||
invoices.revenue_czk !== undefined;
|
||||
const fallbackText =
|
||||
rev.length > 0
|
||||
? rev.map((r) => formatCurrency(r.amount, r.currency)).join(" · ")
|
||||
: "0 Kč";
|
||||
const revenueText = hasCzkTotal
|
||||
? formatCurrency(invoices.revenue_czk!, 'CZK')
|
||||
: fallbackText
|
||||
const detailText = hasForeign && rev.length > 0
|
||||
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
|
||||
: null
|
||||
const unpaidText = invoices.unpaid_count > 0
|
||||
? `${invoices.unpaid_count} neuhrazených`
|
||||
: null
|
||||
const footerParts = [detailText, unpaidText].filter(Boolean)
|
||||
? formatCurrency(invoices.revenue_czk!, "CZK")
|
||||
: fallbackText;
|
||||
const detailText =
|
||||
hasForeign && rev.length > 0
|
||||
? rev.map((r) => formatCurrency(r.amount, r.currency)).join(" · ")
|
||||
: null;
|
||||
const unpaidText =
|
||||
invoices.unpaid_count > 0 ? `${invoices.unpaid_count} neuhrazených` : null;
|
||||
const footerParts = [detailText, unpaidText].filter(Boolean);
|
||||
return {
|
||||
label: 'Tržby (měsíc)',
|
||||
label: "Tržby (měsíc)",
|
||||
value: revenueText,
|
||||
color: 'warning',
|
||||
footer: footerParts.length > 0 ? footerParts.join(' · ') : null,
|
||||
}
|
||||
color: "warning",
|
||||
footer: footerParts.length > 0 ? footerParts.join(" · ") : null,
|
||||
};
|
||||
}
|
||||
|
||||
const KPI_CLASS_MAP: Record<number, string> = { 4: 'dash-kpi-4', 3: 'dash-kpi-3', 2: 'dash-kpi-2', 1: 'dash-kpi-1' }
|
||||
const KPI_CLASS_MAP: Record<number, string> = {
|
||||
4: "dash-kpi-4",
|
||||
3: "dash-kpi-3",
|
||||
2: "dash-kpi-2",
|
||||
1: "dash-kpi-1",
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
const kpiCards = buildKpiCards(dashData)
|
||||
const kpiCards = buildKpiCards(dashData);
|
||||
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 (
|
||||
<motion.div
|
||||
@@ -120,11 +135,22 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
<div className="admin-stat-label">{kpi.label}</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{kpi.value}
|
||||
{kpi.sub && <small className="text-muted" style={{ fontSize: '0.75em', fontWeight: 500, marginLeft: '0.25rem' }}>{kpi.sub}</small>}
|
||||
{kpi.sub && (
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
marginLeft: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,107 +1,123 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useAlert } from '../../context/AlertContext'
|
||||
import useModalLock from '../../hooks/useModalLock'
|
||||
import apiFetch from '../../utils/api'
|
||||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
import apiFetch from "../../utils/api";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface DashProfileProps {
|
||||
totpEnabled: boolean
|
||||
totpLoading: boolean
|
||||
totpSubmitting: boolean
|
||||
onStart2FASetup: () => void
|
||||
onConfirm2FA: () => void
|
||||
onDisable2FA: () => void
|
||||
totpSecret: string | null
|
||||
totpQrUri: string | null
|
||||
totpCode: string
|
||||
setTotpCode: (code: string) => void
|
||||
backupCodes: string[] | null
|
||||
setBackupCodes: (codes: string[] | null) => void
|
||||
show2FASetup: boolean
|
||||
setShow2FASetup: (show: boolean) => void
|
||||
show2FADisable: boolean
|
||||
setShow2FADisable: (show: boolean) => void
|
||||
disableCode: string
|
||||
setDisableCode: (code: string) => void
|
||||
totpEnabled: boolean;
|
||||
totpLoading: boolean;
|
||||
totpSubmitting: boolean;
|
||||
onStart2FASetup: () => void;
|
||||
onConfirm2FA: () => void;
|
||||
onDisable2FA: () => void;
|
||||
totpSecret: string | null;
|
||||
totpQrUri: string | null;
|
||||
totpCode: string;
|
||||
setTotpCode: (code: string) => void;
|
||||
backupCodes: string[] | null;
|
||||
setBackupCodes: (codes: string[] | null) => void;
|
||||
show2FASetup: boolean;
|
||||
setShow2FASetup: (show: boolean) => void;
|
||||
show2FADisable: boolean;
|
||||
setShow2FADisable: (show: boolean) => void;
|
||||
disableCode: string;
|
||||
setDisableCode: (code: string) => void;
|
||||
}
|
||||
|
||||
interface ProfileFormData {
|
||||
username: string
|
||||
email: string
|
||||
new_password: string
|
||||
current_password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
username: string;
|
||||
email: string;
|
||||
new_password: string;
|
||||
current_password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
export default function DashProfile({
|
||||
totpEnabled, totpLoading, totpSubmitting,
|
||||
onStart2FASetup, onConfirm2FA, onDisable2FA,
|
||||
totpSecret, totpQrUri, totpCode, setTotpCode,
|
||||
backupCodes, setBackupCodes,
|
||||
show2FASetup, setShow2FASetup,
|
||||
show2FADisable, setShow2FADisable,
|
||||
disableCode, setDisableCode,
|
||||
totpEnabled,
|
||||
totpLoading,
|
||||
totpSubmitting,
|
||||
onStart2FASetup,
|
||||
onConfirm2FA,
|
||||
onDisable2FA,
|
||||
totpSecret,
|
||||
totpQrUri,
|
||||
totpCode,
|
||||
setTotpCode,
|
||||
backupCodes,
|
||||
setBackupCodes,
|
||||
show2FASetup,
|
||||
setShow2FASetup,
|
||||
show2FADisable,
|
||||
setShow2FADisable,
|
||||
disableCode,
|
||||
setDisableCode,
|
||||
}: DashProfileProps) {
|
||||
const { user, updateUser } = useAuth()
|
||||
const alert = useAlert()
|
||||
const totpSetupRef = useRef<HTMLInputElement>(null)
|
||||
const { user, updateUser } = useAuth();
|
||||
const alert = useAlert();
|
||||
const totpSetupRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
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 nameParts = (user?.fullName || '').split(' ')
|
||||
const nameParts = (user?.fullName || "").split(" ");
|
||||
setFormData({
|
||||
username: user?.username || '',
|
||||
email: user?.email || '',
|
||||
new_password: '',
|
||||
current_password: '',
|
||||
first_name: nameParts[0] || '',
|
||||
last_name: nameParts.slice(1).join(' ') || ''
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
username: user?.username || "",
|
||||
email: user?.email || "",
|
||||
new_password: "",
|
||||
current_password: "",
|
||||
first_name: nameParts[0] || "",
|
||||
last_name: nameParts.slice(1).join(" ") || "",
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
const dataToSave = { ...formData }
|
||||
e?.preventDefault();
|
||||
const dataToSave = { ...formData };
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dataToSave)
|
||||
})
|
||||
const data = await response.json()
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(dataToSave),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
updateUser({
|
||||
username: dataToSave.username,
|
||||
email: dataToSave.email,
|
||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
|
||||
})
|
||||
setShowModal(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
alert.success('Profil byl upraven')
|
||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
||||
});
|
||||
setShowModal(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success("Profil byl upraven");
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se uložit profil')
|
||||
alert.error(data.error || "Nepodařilo se uložit profil");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getTotpStatusText(): string {
|
||||
if (totpLoading) {
|
||||
return 'Načítání...'
|
||||
return "Načítání...";
|
||||
}
|
||||
return totpEnabled ? 'Aktivní' : 'Neaktivní'
|
||||
return totpEnabled ? "Aktivní" : "Neaktivní";
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,8 +130,18 @@ export default function DashProfile({
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Váš účet</h2>
|
||||
<button onClick={openEditModal} className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<button
|
||||
onClick={openEditModal}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
@@ -138,42 +164,82 @@ export default function DashProfile({
|
||||
</div>
|
||||
<div className="dash-profile-item">
|
||||
<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>
|
||||
|
||||
{/* 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-row-gap">
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: totpEnabled ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
|
||||
color: totpEnabled ? 'var(--success)' : 'var(--text-secondary)'
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: totpEnabled
|
||||
? "var(--success-light)"
|
||||
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)",
|
||||
color: totpEnabled
|
||||
? "var(--success)"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>Dvoufaktorové ověření (2FA)</div>
|
||||
<div className={totpEnabled ? 'text-success' : 'text-secondary'} style={{ fontSize: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}>
|
||||
Dvoufaktorové ověření (2FA)
|
||||
</div>
|
||||
<div
|
||||
className={totpEnabled ? "text-success" : "text-secondary"}
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{getTotpStatusText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!totpLoading && (
|
||||
totpEnabled ? (
|
||||
<button onClick={() => { setDisableCode(''); setShow2FADisable(true) }} className="admin-btn admin-btn-primary admin-btn-sm">
|
||||
{!totpLoading &&
|
||||
(totpEnabled ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDisableCode("");
|
||||
setShow2FADisable(true);
|
||||
}}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
Deaktivovat
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary admin-btn-sm">
|
||||
{totpSubmitting ? 'Generuji...' : 'Aktivovat'}
|
||||
<button
|
||||
onClick={onStart2FASetup}
|
||||
disabled={totpSubmitting}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
{totpSubmitting ? "Generuji..." : "Aktivovat"}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,45 +248,139 @@ export default function DashProfile({
|
||||
{/* Edit Profile Modal */}
|
||||
<AnimatePresence>
|
||||
{showModal && (
|
||||
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
|
||||
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
||||
<div className="admin-modal-header"><h2 className="admin-modal-title">Upravit profil</h2></div>
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShowModal(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Upravit profil</h2>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jméno</label>
|
||||
<input type="text" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" />
|
||||
<input
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
first_name: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příjmení</label>
|
||||
<input type="text" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" />
|
||||
<input
|
||||
type="text"
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
last_name: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Uživatelské jméno</label>
|
||||
<input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" />
|
||||
<label className="admin-form-label">
|
||||
Uživatelské jméno
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">E-mail</label>
|
||||
<input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" />
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
|
||||
<input type="password" value={formData.new_password} onChange={(e) => setFormData({ ...formData, new_password: e.target.value })} className="admin-form-input" />
|
||||
<label className="admin-form-label">
|
||||
Nové heslo (ponechte prázdné pro zachování stávajícího)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.new_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
new_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
{formData.new_password && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">Aktuální heslo</label>
|
||||
<input type="password" value={formData.current_password} onChange={(e) => setFormData({ ...formData, current_password: e.target.value })} className="admin-form-input" placeholder="Zadejte aktuální heslo pro potvrzení" />
|
||||
<label className="admin-form-label required">
|
||||
Aktuální heslo
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Zadejte aktuální heslo pro potvrzení"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary">Zrušit</button>
|
||||
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">Uložit změny</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Uložit změny
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
@@ -230,31 +390,105 @@ export default function DashProfile({
|
||||
{/* 2FA Setup Modal */}
|
||||
<AnimatePresence>
|
||||
{show2FASetup && (
|
||||
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="admin-modal-backdrop" onClick={() => { if (!backupCodes) { setShow2FASetup(false) } }} />
|
||||
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => {
|
||||
if (!backupCodes) {
|
||||
setShow2FASetup(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}</h2>
|
||||
<h2 className="admin-modal-title">
|
||||
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
{backupCodes ? (
|
||||
<div>
|
||||
<div className="admin-role-locked-notice mb-4">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte.
|
||||
Uložte si tyto kódy na bezpečné místo. Každý kód lze
|
||||
použít pouze jednou. Po zavření tohoto okna je již
|
||||
neuvidíte.
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "0.5rem",
|
||||
padding: "1rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code} style={{ padding: '0.25rem 0.5rem', textAlign: 'center', color: 'var(--text-primary)' }}>{code}</div>
|
||||
<div
|
||||
key={code}
|
||||
style={{
|
||||
padding: "0.25rem 0.5rem",
|
||||
textAlign: "center",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button onClick={() => { navigator.clipboard?.writeText(backupCodes.join('\n')); alert.success('Kódy zkopírovány') }} className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(
|
||||
backupCodes.join("\n"),
|
||||
);
|
||||
alert.success("Kódy zkopírovány");
|
||||
}}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Kopírovat kódy
|
||||
</button>
|
||||
@@ -262,48 +496,143 @@ export default function DashProfile({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-secondary" style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.)
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
|
||||
>
|
||||
Naskenujte QR kód v autentizační aplikaci (Google
|
||||
Authenticator, Authy, Microsoft Authenticator apod.)
|
||||
</p>
|
||||
{totpQrUri && (
|
||||
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
|
||||
<div
|
||||
style={{ textAlign: "center", marginBottom: "1rem" }}
|
||||
>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
||||
alt="TOTP QR Code"
|
||||
style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }}
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{totpSecret && (
|
||||
<div className="mb-4">
|
||||
<label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Nebo zadejte klíč ručně:</label>
|
||||
<div style={{ padding: '0.5rem 0.75rem', background: 'var(--bg-secondary)', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem', wordBreak: 'break-all', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
Nebo zadejte klíč ručně:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.375rem",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.875rem",
|
||||
wordBreak: "break-all",
|
||||
color: "var(--text-primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span>{totpSecret}</span>
|
||||
<button onClick={() => { navigator.clipboard?.writeText(totpSecret); alert.success('Klíč zkopírován') }} className="admin-btn-icon" title="Kopírovat" aria-label="Kopírovat" style={{ flexShrink: 0 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(totpSecret);
|
||||
alert.success("Klíč zkopírován");
|
||||
}}
|
||||
className="admin-btn-icon"
|
||||
title="Kopírovat"
|
||||
aria-label="Kopírovat"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Ověřovací kód z aplikace</label>
|
||||
<input ref={totpSetupRef} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} />
|
||||
<label className="admin-form-label">
|
||||
Ověřovací kód z aplikace
|
||||
</label>
|
||||
<input
|
||||
ref={totpSetupRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) =>
|
||||
setTotpCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder="000000"
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
onConfirm2FA();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
{backupCodes ? (
|
||||
<button onClick={() => { setShow2FASetup(false); setBackupCodes(null) }} className="admin-btn admin-btn-primary">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShow2FASetup(false);
|
||||
setBackupCodes(null);
|
||||
}}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Rozumím, uložil jsem si kódy
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => setShow2FASetup(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
|
||||
<button onClick={onConfirm2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || totpCode.length !== 6}>
|
||||
{totpSubmitting ? 'Ověřuji...' : 'Aktivovat 2FA'}
|
||||
<button
|
||||
onClick={() => setShow2FASetup(false)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={totpSubmitting}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm2FA}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={totpSubmitting || totpCode.length !== 6}
|
||||
>
|
||||
{totpSubmitting ? "Ověřuji..." : "Aktivovat 2FA"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -316,23 +645,80 @@ export default function DashProfile({
|
||||
{/* 2FA Disable Modal */}
|
||||
<AnimatePresence>
|
||||
{show2FADisable && (
|
||||
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="admin-modal-backdrop" onClick={() => setShow2FADisable(false)} />
|
||||
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
||||
<div className="admin-modal-header"><h2 className="admin-modal-title">Deaktivovat 2FA</h2></div>
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShow2FADisable(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Deaktivovat 2FA</h2>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace.
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
||||
autentizační aplikace.
|
||||
</p>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Ověřovací kód</label>
|
||||
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={disableCode} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) =>
|
||||
setDisableCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder="000000"
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "0.4rem",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && disableCode.length === 6) {
|
||||
onDisable2FA();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button onClick={() => setShow2FADisable(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
|
||||
<button onClick={onDisable2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || disableCode.length !== 6}>
|
||||
{totpSubmitting ? 'Deaktivuji...' : 'Deaktivovat 2FA'}
|
||||
<button
|
||||
onClick={() => setShow2FADisable(false)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={totpSubmitting}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
onClick={onDisable2FA}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={totpSubmitting || disableCode.length !== 6}
|
||||
>
|
||||
{totpSubmitting ? "Deaktivuji..." : "Deaktivovat 2FA"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -340,5 +726,5 @@ export default function DashProfile({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,192 +1,287 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useAlert } from '../../context/AlertContext'
|
||||
import { formatKm } from '../../utils/formatters'
|
||||
import AdminDatePicker from '../AdminDatePicker'
|
||||
import apiFetch from '../../utils/api'
|
||||
import useModalLock from '../../hooks/useModalLock'
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import { formatKm } from "../../utils/formatters";
|
||||
import AdminDatePicker from "../AdminDatePicker";
|
||||
import apiFetch from "../../utils/api";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface Vehicle {
|
||||
id: number | string
|
||||
spz: string
|
||||
name: string
|
||||
id: number | string;
|
||||
spz: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TripForm {
|
||||
vehicle_id: string
|
||||
trip_date: string
|
||||
start_km: string
|
||||
end_km: string
|
||||
route_from: string
|
||||
route_to: string
|
||||
is_business: number
|
||||
notes: string
|
||||
vehicle_id: string;
|
||||
trip_date: string;
|
||||
start_km: string;
|
||||
end_km: string;
|
||||
route_from: string;
|
||||
route_to: string;
|
||||
is_business: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface TripErrors {
|
||||
vehicle_id?: string
|
||||
trip_date?: string
|
||||
start_km?: string
|
||||
end_km?: string
|
||||
route_from?: string
|
||||
route_to?: string
|
||||
vehicle_id?: string;
|
||||
trip_date?: string;
|
||||
start_km?: string;
|
||||
end_km?: string;
|
||||
route_from?: string;
|
||||
route_to?: string;
|
||||
}
|
||||
|
||||
interface DashQuickActionsProps {
|
||||
dashData: {
|
||||
my_shift?: {
|
||||
has_ongoing: boolean
|
||||
}
|
||||
} | null
|
||||
punching: boolean
|
||||
onPunch: () => void
|
||||
has_ongoing: boolean;
|
||||
};
|
||||
} | null;
|
||||
punching: boolean;
|
||||
onPunch: () => void;
|
||||
}
|
||||
|
||||
export default function DashQuickActions({ dashData, punching, onPunch }: DashQuickActionsProps) {
|
||||
const { hasPermission } = useAuth()
|
||||
const alert = useAlert()
|
||||
export default function DashQuickActions({
|
||||
dashData,
|
||||
punching,
|
||||
onPunch,
|
||||
}: DashQuickActionsProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
|
||||
const [showTripModal, setShowTripModal] = useState(false)
|
||||
const [tripSubmitting, setTripSubmitting] = useState(false)
|
||||
const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([])
|
||||
const [showTripModal, setShowTripModal] = useState(false);
|
||||
const [tripSubmitting, setTripSubmitting] = useState(false);
|
||||
const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([]);
|
||||
const [tripForm, setTripForm] = useState<TripForm>({
|
||||
vehicle_id: '', trip_date: '', start_km: '', end_km: '',
|
||||
route_from: '', route_to: '', is_business: 1, notes: ''
|
||||
})
|
||||
const [tripErrors, setTripErrors] = useState<TripErrors>({})
|
||||
vehicle_id: "",
|
||||
trip_date: "",
|
||||
start_km: "",
|
||||
end_km: "",
|
||||
route_from: "",
|
||||
route_to: "",
|
||||
is_business: 1,
|
||||
notes: "",
|
||||
});
|
||||
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
||||
|
||||
useModalLock(showTripModal)
|
||||
useModalLock(showTripModal);
|
||||
|
||||
const openTripModal = async () => {
|
||||
setTripForm({
|
||||
vehicle_id: '', trip_date: new Date().toISOString().split('T')[0],
|
||||
start_km: '', end_km: '', route_from: '', route_to: '',
|
||||
is_business: 1, notes: ''
|
||||
})
|
||||
setTripErrors({})
|
||||
setShowTripModal(true)
|
||||
vehicle_id: "",
|
||||
trip_date: new Date().toISOString().split("T")[0],
|
||||
start_km: "",
|
||||
end_km: "",
|
||||
route_from: "",
|
||||
route_to: "",
|
||||
is_business: 1,
|
||||
notes: "",
|
||||
});
|
||||
setTripErrors({});
|
||||
setShowTripModal(true);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/vehicles`)
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/vehicles`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setTripVehicles(Array.isArray(result.data) ? result.data : result.data?.vehicles || [])
|
||||
setTripVehicles(
|
||||
Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data?.vehicles || [],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// vozidla se nenacetla
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTripVehicleChange = async (vehicleId: string) => {
|
||||
setTripForm(prev => ({ ...prev, vehicle_id: vehicleId }))
|
||||
setTripForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
|
||||
if (!vehicleId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setTripForm(prev => ({ ...prev, start_km: result.data.last_km }))
|
||||
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
|
||||
}
|
||||
} catch {
|
||||
// last_km se nenacetlo
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTripSubmit = async () => {
|
||||
const errs: TripErrors = {}
|
||||
const errs: TripErrors = {};
|
||||
if (!tripForm.vehicle_id) {
|
||||
errs.vehicle_id = 'Vyberte vozidlo'
|
||||
errs.vehicle_id = "Vyberte vozidlo";
|
||||
}
|
||||
if (!tripForm.trip_date) {
|
||||
errs.trip_date = 'Zadejte datum'
|
||||
errs.trip_date = "Zadejte datum";
|
||||
}
|
||||
if (!tripForm.start_km) {
|
||||
errs.start_km = 'Zadejte počáteční km'
|
||||
errs.start_km = "Zadejte počáteční 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)) {
|
||||
errs.end_km = 'Musí být větší než počáteční'
|
||||
if (
|
||||
tripForm.start_km &&
|
||||
tripForm.end_km &&
|
||||
parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)
|
||||
) {
|
||||
errs.end_km = "Musí být větší než počáteční";
|
||||
}
|
||||
if (!tripForm.route_from) {
|
||||
errs.route_from = 'Zadejte místo odjezdu'
|
||||
errs.route_from = "Zadejte místo odjezdu";
|
||||
}
|
||||
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) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setTripSubmitting(true)
|
||||
setTripSubmitting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/trips`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tripForm)
|
||||
})
|
||||
const result = await response.json()
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(tripForm),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setShowTripModal(false)
|
||||
alert.success(result.message)
|
||||
setShowTripModal(false);
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setTripSubmitting(false)
|
||||
setTripSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tripDistance = (): number => {
|
||||
const s = parseInt(tripForm.start_km) || 0
|
||||
const e = parseInt(tripForm.end_km) || 0
|
||||
return e > s ? e - s : 0
|
||||
}
|
||||
const s = parseInt(tripForm.start_km) || 0;
|
||||
const e = parseInt(tripForm.end_km) || 0;
|
||||
return e > s ? e - s : 0;
|
||||
};
|
||||
|
||||
const hasOngoingShift = dashData?.my_shift?.has_ongoing
|
||||
const punchLabel = hasOngoingShift ? 'Zaznamenat odchod' : 'Zaznamenat příchod'
|
||||
const hasOngoingShift = dashData?.my_shift?.has_ongoing;
|
||||
const punchLabel = hasOngoingShift
|
||||
? "Zaznamenat odchod"
|
||||
: "Zaznamenat příchod";
|
||||
const quickActions: Array<{
|
||||
label: string
|
||||
color: string
|
||||
icon: React.ReactNode
|
||||
onClick?: () => void
|
||||
path?: string
|
||||
disabled?: boolean
|
||||
}> = []
|
||||
label: string;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
path?: string;
|
||||
disabled?: boolean;
|
||||
}> = [];
|
||||
|
||||
if (hasPermission('attendance.record')) {
|
||||
if (hasPermission("attendance.record")) {
|
||||
quickActions.push({
|
||||
label: punching ? 'Odesílám...' : punchLabel,
|
||||
color: hasOngoingShift ? 'danger' : 'success',
|
||||
icon: hasOngoingShift
|
||||
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>
|
||||
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 12l2 2 4-4" /><circle cx="12" cy="12" r="10" /></svg>,
|
||||
label: punching ? "Odesílám..." : punchLabel,
|
||||
color: hasOngoingShift ? "danger" : "success",
|
||||
icon: hasOngoingShift ? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
),
|
||||
onClick: onPunch,
|
||||
disabled: punching,
|
||||
})
|
||||
});
|
||||
}
|
||||
if (hasPermission('offers.create')) {
|
||||
quickActions.push({ label: 'Nová nabídka', path: '/offers/new', color: 'info', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg> })
|
||||
}
|
||||
if (hasPermission('trips.record')) {
|
||||
if (hasPermission("offers.create")) {
|
||||
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,
|
||||
})
|
||||
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('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> })
|
||||
if (hasPermission("trips.record")) {
|
||||
quickActions.push({
|
||||
label: "Přidat jízdu",
|
||||
color: "warning",
|
||||
icon: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" />
|
||||
<circle cx="8.5" cy="16" r="2.5" />
|
||||
<circle cx="18.5" cy="16" r="2.5" />
|
||||
<path d="M16 8h4l3 5v3h-7" />
|
||||
</svg>
|
||||
),
|
||||
onClick: openTripModal,
|
||||
});
|
||||
}
|
||||
if (hasPermission("invoices.create")) {
|
||||
quickActions.push({
|
||||
label: "Vystavit fakturu",
|
||||
path: "/invoices/new",
|
||||
color: "danger",
|
||||
icon: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -197,22 +292,28 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
{quickActions.map((action) => action.onClick ? (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link key={action.label} to={action.path!} className={`dash-quick-btn dash-quick-btn-${action.color}`}>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
{quickActions.map((action) =>
|
||||
action.onClick ? (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
key={action.label}
|
||||
to={action.path!}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -224,7 +325,10 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={() => setShowTripModal(false)} />
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShowTripModal(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
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-form">
|
||||
<div className="admin-form-row">
|
||||
<div className={`admin-form-group${tripErrors.vehicle_id ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Vozidlo</label>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Vozidlo
|
||||
</label>
|
||||
<select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleTripVehicleChange(e.target.value)
|
||||
setTripErrors(prev => ({ ...prev, vehicle_id: undefined }))
|
||||
handleTripVehicleChange(e.target.value);
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
vehicle_id: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte vozidlo</option>
|
||||
{tripVehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.spz} - {v.name}</option>
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.spz} - {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tripErrors.vehicle_id && <span className="admin-form-error">{tripErrors.vehicle_id}</span>}
|
||||
{tripErrors.vehicle_id && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.vehicle_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`admin-form-group${tripErrors.trip_date ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Datum jízdy</label>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Datum jízdy
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val: string) => {
|
||||
setTripForm(prev => ({ ...prev, trip_date: val }))
|
||||
setTripErrors(prev => ({ ...prev, trip_date: undefined }))
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
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 className="admin-form-row admin-form-row-3">
|
||||
<div className={`admin-form-group${tripErrors.start_km ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Počáteční stav km</label>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Počáteční stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm(prev => ({ ...prev, start_km: e.target.value }))
|
||||
setTripErrors(prev => ({ ...prev, start_km: undefined }))
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
start_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
start_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.start_km && <span className="admin-form-error">{tripErrors.start_km}</span>}
|
||||
{tripErrors.start_km && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.start_km}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`admin-form-group${tripErrors.end_km ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Konečný stav km</label>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Konečný stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm(prev => ({ ...prev, end_km: e.target.value }))
|
||||
setTripErrors(prev => ({ ...prev, end_km: undefined }))
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
end_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
end_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.end_km && <span className="admin-form-error">{tripErrors.end_km}</span>}
|
||||
{tripErrors.end_km && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.end_km}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Vzdálenost</label>
|
||||
<input type="text" value={`${formatKm(tripDistance())} km`} className="admin-form-input" readOnly disabled />
|
||||
<input
|
||||
type="text"
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className={`admin-form-group${tripErrors.route_from ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Místo odjezdu</label>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo odjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm(prev => ({ ...prev, route_from: e.target.value }))
|
||||
setTripErrors(prev => ({ ...prev, route_from: undefined }))
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_from: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
{tripErrors.route_from && <span className="admin-form-error">{tripErrors.route_from}</span>}
|
||||
{tripErrors.route_from && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_from}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`admin-form-group${tripErrors.route_to ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Místo příjezdu</label>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo příjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm(prev => ({ ...prev, route_to: e.target.value }))
|
||||
setTripErrors(prev => ({ ...prev, route_to: undefined }))
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_to: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_to: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
{tripErrors.route_to && <span className="admin-form-error">{tripErrors.route_to}</span>}
|
||||
{tripErrors.route_to && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_to}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -341,7 +531,12 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
|
||||
<label className="admin-form-label">Typ jízdy</label>
|
||||
<select
|
||||
value={tripForm.is_business}
|
||||
onChange={(e) => setTripForm(prev => ({ ...prev, is_business: parseInt(e.target.value) }))}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
is_business: parseInt(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<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>
|
||||
<textarea
|
||||
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"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
@@ -362,11 +562,21 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
<button type="button" onClick={handleTripSubmit} className="admin-btn admin-btn-primary" disabled={tripSubmitting}>
|
||||
{tripSubmitting ? 'Ukládám...' : 'Uložit'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTripSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={tripSubmitting}
|
||||
>
|
||||
{tripSubmitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -374,5 +584,5 @@ export default function DashQuickActions({ dashData, punching, onPunch }: DashQu
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +1,159 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAlert } from '../../context/AlertContext'
|
||||
import ConfirmModal from '../ConfirmModal'
|
||||
import useModalLock from '../../hooks/useModalLock'
|
||||
import apiFetch from '../../utils/api'
|
||||
import { formatSessionDate } from '../../utils/dashboardHelpers'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import ConfirmModal from "../ConfirmModal";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface DeviceInfo {
|
||||
icon?: string
|
||||
browser?: string
|
||||
os?: string
|
||||
icon?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: number | string
|
||||
is_current: boolean
|
||||
device_info?: DeviceInfo
|
||||
ip_address: string
|
||||
created_at: string
|
||||
id: number | string;
|
||||
is_current: boolean;
|
||||
device_info?: DeviceInfo;
|
||||
ip_address: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface DeleteModalState {
|
||||
isOpen: boolean
|
||||
session: Session | null
|
||||
isOpen: boolean;
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
function getDeviceIcon(iconType?: string) {
|
||||
switch (iconType) {
|
||||
case 'smartphone':
|
||||
case "smartphone":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
||||
<line x1="12" y1="18" x2="12" y2="18" />
|
||||
</svg>
|
||||
)
|
||||
case 'tablet':
|
||||
);
|
||||
case "tablet":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
|
||||
<line x1="12" y1="18" x2="12" y2="18" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashSessions() {
|
||||
const alert = useAlert()
|
||||
const alert = useAlert();
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({ isOpen: false, session: null })
|
||||
const [deleteAllModal, setDeleteAllModal] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
|
||||
isOpen: false,
|
||||
session: null,
|
||||
});
|
||||
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useModalLock(deleteAllModal)
|
||||
useModalLock(deleteAllModal);
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/sessions`)
|
||||
const data = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/sessions`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSessions(Array.isArray(data.data) ? data.data : data.data?.sessions || [])
|
||||
setSessions(
|
||||
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// session fetch failed silently
|
||||
} finally {
|
||||
setSessionsLoading(false)
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions()
|
||||
}, [fetchSessions])
|
||||
fetchSessions();
|
||||
}, [fetchSessions]);
|
||||
|
||||
const handleDeleteSession = async () => {
|
||||
if (!deleteModal.session) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const sessionId = deleteModal.session.id
|
||||
setDeleting(true)
|
||||
const sessionId = deleteModal.session.id;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setDeleteModal({ isOpen: false, session: null })
|
||||
setSessions(prev => prev.filter(s => s.id !== sessionId))
|
||||
alert.success('Relace byla ukončena')
|
||||
setDeleteModal({ isOpen: false, session: null });
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
alert.success("Relace byla ukončena");
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se ukončit relaci')
|
||||
alert.error(data.error || "Nepodařilo se ukončit relaci");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAllSessions = async () => {
|
||||
setDeleting(true)
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/sessions?action=all`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/sessions?action=all`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setDeleteAllModal(false)
|
||||
setSessions(prev => prev.filter(s => s.is_current))
|
||||
alert.success(data.message || 'Ostatní relace byly ukončeny')
|
||||
setDeleteAllModal(false);
|
||||
setSessions((prev) => prev.filter((s) => s.is_current));
|
||||
alert.success(data.message || "Ostatní relace byly ukončeny");
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se ukončit relace')
|
||||
alert.error(data.error || "Nepodařilo se ukončit relace");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -130,43 +163,81 @@ export default function DashSessions() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<div
|
||||
className="admin-card-header"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h2 className="admin-card-title">Přihlášená zařízení</h2>
|
||||
{sessions.filter(s => !s.is_current).length > 0 && (
|
||||
<button onClick={() => setDeleteAllModal(true)} className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||
<button
|
||||
onClick={() => setDeleteAllModal(true)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
Odhlásit ostatní
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{sessionsLoading && (
|
||||
<div className="admin-skeleton" style={{ padding: '1rem', gap: '1rem' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div
|
||||
className="admin-skeleton"
|
||||
style={{ padding: "1rem", gap: "1rem" }}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/2"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!sessionsLoading && sessions.length === 0 && (
|
||||
<div className="text-secondary" style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.875rem' }}>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</div>
|
||||
)}
|
||||
{!sessionsLoading && sessions.length > 0 && (
|
||||
<div className="sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className={`session-item ${session.is_current ? 'session-item-current' : ''}`}>
|
||||
<div className="session-icon">{getDeviceIcon(session.device_info?.icon)}</div>
|
||||
<div
|
||||
key={session.id}
|
||||
className={`session-item ${session.is_current ? "session-item-current" : ""}`}
|
||||
>
|
||||
<div className="session-icon">
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</div>
|
||||
<div className="session-info">
|
||||
<div className="session-device">
|
||||
{session.device_info?.browser} na {session.device_info?.os}
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
{session.is_current && (
|
||||
<span className="admin-badge admin-badge-success" style={{ marginLeft: '0.5rem' }}>Aktuální</span>
|
||||
<span
|
||||
className="admin-badge admin-badge-success"
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Aktuální
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-meta">
|
||||
@@ -177,9 +248,25 @@ export default function DashSessions() {
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
{!session.is_current && (
|
||||
<button onClick={() => setDeleteModal({ isOpen: true, session })} className="admin-btn-icon danger" title="Ukončit relaci" aria-label="Ukončit relaci">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
@@ -214,5 +301,5 @@ export default function DashSessions() {
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
id: string
|
||||
message: string
|
||||
type: 'success' | 'error' | 'warning' | 'info'
|
||||
id: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}
|
||||
|
||||
interface AlertMethods {
|
||||
addAlert: (message: string, type?: string, duration?: number) => string
|
||||
removeAlert: (id: string) => void
|
||||
success: (message: string, duration?: number) => string
|
||||
error: (message: string, duration?: number) => string
|
||||
warning: (message: string, duration?: number) => string
|
||||
info: (message: string, duration?: number) => string
|
||||
addAlert: (message: string, type?: string, duration?: number) => string;
|
||||
removeAlert: (id: string) => void;
|
||||
success: (message: string, duration?: number) => string;
|
||||
error: (message: string, duration?: number) => string;
|
||||
warning: (message: string, duration?: number) => string;
|
||||
info: (message: string, duration?: number) => string;
|
||||
}
|
||||
|
||||
interface AlertStateValue {
|
||||
alerts: Alert[]
|
||||
removeAlert: (id: string) => void
|
||||
alerts: Alert[];
|
||||
removeAlert: (id: string) => void;
|
||||
}
|
||||
|
||||
const AlertContext = createContext<AlertMethods | null>(null)
|
||||
const AlertStateContext = createContext<AlertStateValue | null>(null)
|
||||
const AlertContext = createContext<AlertMethods | null>(null);
|
||||
const AlertStateContext = createContext<AlertStateValue | null>(null);
|
||||
|
||||
export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
|
||||
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 addAlert = useCallback((message: string, type = 'success', duration = 4000) => {
|
||||
const id = `${Date.now()}-${counterRef.current++}`
|
||||
setAlerts(prev => [...prev, { id, message, type: type as Alert['type'] }])
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeAlert(id), duration)
|
||||
}
|
||||
return id
|
||||
}, [removeAlert])
|
||||
const counterRef = useRef(0);
|
||||
const addAlert = useCallback(
|
||||
(message: string, type = "success", duration = 4000) => {
|
||||
const id = `${Date.now()}-${counterRef.current++}`;
|
||||
setAlerts((prev) => [
|
||||
...prev,
|
||||
{ id, message, type: type as Alert["type"] },
|
||||
]);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeAlert(id), duration);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
[removeAlert],
|
||||
);
|
||||
|
||||
const methods = useMemo<AlertMethods>(() => ({
|
||||
addAlert,
|
||||
removeAlert,
|
||||
success: (message, duration) => addAlert(message, 'success', duration),
|
||||
error: (message, duration) => addAlert(message, 'error', duration),
|
||||
warning: (message, duration) => addAlert(message, 'warning', duration),
|
||||
info: (message, duration) => addAlert(message, 'info', duration),
|
||||
}), [addAlert, removeAlert])
|
||||
const methods = useMemo<AlertMethods>(
|
||||
() => ({
|
||||
addAlert,
|
||||
removeAlert,
|
||||
success: (message, duration) => addAlert(message, "success", duration),
|
||||
error: (message, duration) => addAlert(message, "error", duration),
|
||||
warning: (message, duration) => addAlert(message, "warning", duration),
|
||||
info: (message, duration) => addAlert(message, "info", duration),
|
||||
}),
|
||||
[addAlert, removeAlert],
|
||||
);
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={methods}>
|
||||
@@ -55,17 +72,19 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
</AlertStateContext.Provider>
|
||||
</AlertContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function useAlert(): AlertMethods {
|
||||
const context = useContext(AlertContext)
|
||||
if (!context) throw new Error('useAlert must be used within an AlertProvider')
|
||||
return context
|
||||
const context = useContext(AlertContext);
|
||||
if (!context)
|
||||
throw new Error("useAlert must be used within an AlertProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useAlertState(): AlertStateValue {
|
||||
const context = useContext(AlertStateContext)
|
||||
if (!context) throw new Error('useAlertState must be used within an AlertProvider')
|
||||
return context
|
||||
const context = useContext(AlertStateContext);
|
||||
if (!context)
|
||||
throw new Error("useAlertState must be used within an AlertProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,279 +1,397 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
|
||||
import { setSessionExpired, setTokenGetter, setRefreshFn } from '../utils/api'
|
||||
import {
|
||||
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 {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
fullName: string
|
||||
roleDisplay: string
|
||||
isAdmin: boolean
|
||||
totpEnabled: boolean
|
||||
require2FA: boolean
|
||||
permissions: string[]
|
||||
[key: string]: unknown
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
roleDisplay: string;
|
||||
isAdmin: boolean;
|
||||
totpEnabled: boolean;
|
||||
require2FA: boolean;
|
||||
permissions: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
isAuthenticated: boolean
|
||||
isAdmin: boolean
|
||||
permissions: string[]
|
||||
hasPermission: (permission: string) => boolean
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
permissions: string[];
|
||||
hasPermission: (permission: string) => boolean;
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; requires2FA?: boolean; loginToken?: string; error?: string; remember?: boolean }>
|
||||
verify2FA: (loginToken: string, code: string, remember?: boolean, isBackup?: boolean) => Promise<{ success: boolean; error?: string }>
|
||||
logout: () => Promise<void>
|
||||
checkSession: () => Promise<boolean>
|
||||
getAccessToken: () => string | null
|
||||
apiRequest: (endpoint: string, options?: RequestInit) => Promise<Response>
|
||||
silentRefresh: () => Promise<boolean>
|
||||
updateUser: (updates: Partial<User>) => void
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
remember?: boolean,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
requires2FA?: boolean;
|
||||
loginToken?: string;
|
||||
error?: string;
|
||||
remember?: boolean;
|
||||
}>;
|
||||
verify2FA: (
|
||||
loginToken: string,
|
||||
code: string,
|
||||
remember?: boolean,
|
||||
isBackup?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
checkSession: () => Promise<boolean>;
|
||||
getAccessToken: () => string | null;
|
||||
apiRequest: (endpoint: string, options?: RequestInit) => Promise<Response>;
|
||||
silentRefresh: () => Promise<boolean>;
|
||||
updateUser: (updates: Partial<User>) => void;
|
||||
}
|
||||
|
||||
const AuthStateContext = createContext<AuthState | null>(null)
|
||||
const AuthActionsContext = createContext<AuthActions | null>(null)
|
||||
const AuthStateContext = createContext<AuthState | null>(null);
|
||||
const AuthActionsContext = createContext<AuthActions | null>(null);
|
||||
|
||||
function mapUser(u: Record<string, unknown> | null): User | null {
|
||||
if (!u) return null
|
||||
const id = (u.userId ?? u.id) as number
|
||||
const firstName = (u.firstName ?? u.first_name ?? '') as string
|
||||
const lastName = (u.lastName ?? u.last_name ?? '') as string
|
||||
const roleName = (u.roleName ?? u.role_name ?? '') as string
|
||||
if (!u) return null;
|
||||
const id = (u.userId ?? u.id) as number;
|
||||
const firstName = (u.firstName ?? u.first_name ?? "") as string;
|
||||
const lastName = (u.lastName ?? u.last_name ?? "") as string;
|
||||
const roleName = (u.roleName ?? u.role_name ?? "") as string;
|
||||
return {
|
||||
...u,
|
||||
id,
|
||||
fullName: (u.fullName ?? u.full_name ?? `${firstName} ${lastName}`.trim()) as string,
|
||||
fullName: (u.fullName ??
|
||||
u.full_name ??
|
||||
`${firstName} ${lastName}`.trim()) as string,
|
||||
roleDisplay: (u.roleDisplay ?? u.role_display ?? roleName) as string,
|
||||
isAdmin: (u.isAdmin ?? u.is_admin ?? roleName === 'admin') as boolean,
|
||||
isAdmin: (u.isAdmin ?? u.is_admin ?? roleName === "admin") as boolean,
|
||||
totpEnabled: (u.totpEnabled ?? u.totp_enabled ?? false) as boolean,
|
||||
require2FA: (u.require2FA ?? u.require_2fa ?? false) as boolean,
|
||||
permissions: (u.permissions ?? []) as string[],
|
||||
} as User
|
||||
} as User;
|
||||
}
|
||||
|
||||
let accessToken: string | null = null
|
||||
let tokenExpiresAt: number | null = null
|
||||
let cachedUser: User | null = null
|
||||
let sessionFetched = false
|
||||
let silentRefreshInFlight: Promise<boolean> | null = null
|
||||
let accessToken: string | null = null;
|
||||
let tokenExpiresAt: number | null = null;
|
||||
let cachedUser: User | null = null;
|
||||
let sessionFetched = false;
|
||||
let silentRefreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(cachedUser)
|
||||
const [loading, setLoading] = useState(!sessionFetched)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [user, setUser] = useState<User | null>(cachedUser);
|
||||
const [loading, setLoading] = useState(!sessionFetched);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => { cachedUser = user }, [user])
|
||||
useEffect(() => {
|
||||
cachedUser = user;
|
||||
}, [user]);
|
||||
|
||||
const getAccessTokenFn = useCallback((): string | null => {
|
||||
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null
|
||||
return accessToken
|
||||
}, [])
|
||||
if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null;
|
||||
return accessToken;
|
||||
}, []);
|
||||
|
||||
const setAccessTokenFn = useCallback((token: string | null, expiresIn?: number) => {
|
||||
const ttl = expiresIn ?? 900 // default 15 min matching backend config
|
||||
accessToken = token
|
||||
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current)
|
||||
refreshTimeoutRef.current = null
|
||||
}
|
||||
if (token && ttl > 60) {
|
||||
refreshTimeoutRef.current = setTimeout(() => silentRefresh(), (ttl - 60) * 1000)
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const setAccessTokenFn = useCallback(
|
||||
(token: string | null, expiresIn?: number) => {
|
||||
const ttl = expiresIn ?? 900; // default 15 min matching backend config
|
||||
accessToken = token;
|
||||
tokenExpiresAt = token ? Date.now() + ttl * 1000 : null;
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
}
|
||||
if (token && ttl > 60) {
|
||||
refreshTimeoutRef.current = setTimeout(
|
||||
() => silentRefresh(),
|
||||
(ttl - 60) * 1000,
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
||||
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
||||
if (silentRefreshInFlight) return silentRefreshInFlight
|
||||
if (silentRefreshInFlight) return silentRefreshInFlight;
|
||||
|
||||
const promise = (async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/refresh`, { method: 'POST', credentials: 'include' })
|
||||
const data = await response.json()
|
||||
const response = await fetch(`${API_BASE}/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.data?.access_token) {
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in)
|
||||
setUser(mapUser(data.data.user))
|
||||
return true
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
return true;
|
||||
}
|
||||
accessToken = null
|
||||
tokenExpiresAt = null
|
||||
setUser(null)
|
||||
cachedUser = null
|
||||
setSessionExpired()
|
||||
return false
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
setSessionExpired();
|
||||
return false;
|
||||
} catch {
|
||||
// Network error — don't kick the user out, just return false
|
||||
return false
|
||||
return false;
|
||||
} finally {
|
||||
silentRefreshInFlight = null
|
||||
silentRefreshInFlight = null;
|
||||
}
|
||||
})()
|
||||
})();
|
||||
|
||||
silentRefreshInFlight = promise
|
||||
return promise
|
||||
}, [setAccessTokenFn])
|
||||
silentRefreshInFlight = promise;
|
||||
return promise;
|
||||
}, [setAccessTokenFn]);
|
||||
|
||||
const checkSession = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const token = getAccessTokenFn()
|
||||
const token = getAccessTokenFn();
|
||||
if (token) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }
|
||||
const response = await fetch(`${API_BASE}/session`, { method: 'GET', credentials: 'include', headers })
|
||||
if (response.status === 429 || response.status >= 500) return !!cachedUser
|
||||
const data = await response.json()
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const response = await fetch(`${API_BASE}/session`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
if (response.status === 429 || response.status >= 500)
|
||||
return !!cachedUser;
|
||||
const data = await response.json();
|
||||
if (data.success && data.data?.user) {
|
||||
if (data.data.access_token) setAccessTokenFn(data.data.access_token)
|
||||
setUser(mapUser(data.data.user))
|
||||
cachedUser = mapUser(data.data.user)
|
||||
return true
|
||||
if (data.data.access_token) setAccessTokenFn(data.data.access_token);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// No token or session invalid — try silent refresh via cookie
|
||||
const refreshed = await silentRefresh()
|
||||
if (refreshed) return true
|
||||
setUser(null)
|
||||
cachedUser = null
|
||||
accessToken = null
|
||||
tokenExpiresAt = null
|
||||
return false
|
||||
const refreshed = await silentRefresh();
|
||||
if (refreshed) return true;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
return false;
|
||||
} catch {
|
||||
return !!cachedUser
|
||||
return !!cachedUser;
|
||||
} finally {
|
||||
setLoading(false)
|
||||
sessionFetched = true
|
||||
setLoading(false);
|
||||
sessionFetched = true;
|
||||
}
|
||||
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh])
|
||||
}, [getAccessTokenFn, setAccessTokenFn, silentRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
setTokenGetter(getAccessTokenFn)
|
||||
setRefreshFn(silentRefresh)
|
||||
}, [getAccessTokenFn, silentRefresh])
|
||||
setTokenGetter(getAccessTokenFn);
|
||||
setRefreshFn(silentRefresh);
|
||||
}, [getAccessTokenFn, silentRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
checkSession()
|
||||
return () => { if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current) }
|
||||
}, [checkSession])
|
||||
checkSession();
|
||||
return () => {
|
||||
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
|
||||
};
|
||||
}, [checkSession]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string, remember = false) => {
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password, remember_me: remember }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
if (data.data?.totp_required) {
|
||||
return { success: false, requires2FA: true, loginToken: data.data.login_token, remember }
|
||||
const login = useCallback(
|
||||
async (username: string, password: string, remember = false) => {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password, remember_me: remember }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
if (data.data?.totp_required) {
|
||||
return {
|
||||
success: false,
|
||||
requires2FA: true,
|
||||
loginToken: data.data.login_token,
|
||||
remember,
|
||||
};
|
||||
}
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
sessionFetched = true;
|
||||
return { success: true };
|
||||
}
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in)
|
||||
setUser(mapUser(data.data.user))
|
||||
cachedUser = mapUser(data.data.user)
|
||||
sessionFetched = true
|
||||
return { success: true }
|
||||
setError(data.error);
|
||||
return { success: false, error: data.error };
|
||||
} catch {
|
||||
const errorMsg =
|
||||
"Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu.";
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
setError(data.error)
|
||||
return { success: false, error: data.error }
|
||||
} catch {
|
||||
const errorMsg = 'Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu.'
|
||||
setError(errorMsg)
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
}, [setAccessTokenFn])
|
||||
},
|
||||
[setAccessTokenFn],
|
||||
);
|
||||
|
||||
const verify2FA = useCallback(async (loginToken: string, code: string, remember = false, isBackup = false) => {
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/login/totp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ login_token: loginToken, totp_code: code, remember_me: remember }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in)
|
||||
setUser(mapUser(data.data.user))
|
||||
cachedUser = mapUser(data.data.user)
|
||||
sessionFetched = true
|
||||
return { success: true }
|
||||
const verify2FA = useCallback(
|
||||
async (
|
||||
loginToken: string,
|
||||
code: string,
|
||||
remember = false,
|
||||
isBackup = false,
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/login/totp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
login_token: loginToken,
|
||||
totp_code: code,
|
||||
remember_me: remember,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUser = mapUser(data.data.user);
|
||||
sessionFetched = true;
|
||||
return { success: true };
|
||||
}
|
||||
setError(data.error);
|
||||
return { success: false, error: data.error };
|
||||
} catch {
|
||||
const errorMsg = "Chyba pripojeni.";
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
setError(data.error)
|
||||
return { success: false, error: data.error }
|
||||
} catch {
|
||||
const errorMsg = 'Chyba pripojeni.'
|
||||
setError(errorMsg)
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
}, [setAccessTokenFn])
|
||||
},
|
||||
[setAccessTokenFn],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
const token = getAccessTokenFn()
|
||||
const token = getAccessTokenFn();
|
||||
await fetch(`${API_BASE}/logout`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: { ...(token && { Authorization: `Bearer ${token}` }) },
|
||||
credentials: 'include',
|
||||
})
|
||||
} catch { /* ignore */ } finally {
|
||||
accessToken = null
|
||||
tokenExpiresAt = null
|
||||
setUser(null)
|
||||
cachedUser = null
|
||||
sessionFetched = false
|
||||
if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null }
|
||||
}
|
||||
}, [getAccessTokenFn])
|
||||
|
||||
const apiRequest = useCallback(async (endpoint: string, options: RequestInit = {}) => {
|
||||
let token = getAccessTokenFn()
|
||||
if (!token && user) {
|
||||
const refreshed = await silentRefresh()
|
||||
if (refreshed) token = getAccessTokenFn()
|
||||
}
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(options.headers as Record<string, string>) }
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: 'include' })
|
||||
if (response.status === 401 && user) {
|
||||
const refreshed = await silentRefresh()
|
||||
if (refreshed) {
|
||||
token = getAccessTokenFn()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
return fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: 'include' })
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
setUser(null);
|
||||
cachedUser = null;
|
||||
sessionFetched = false;
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
return response
|
||||
}, [getAccessTokenFn, silentRefresh, user])
|
||||
}, [getAccessTokenFn]);
|
||||
|
||||
const apiRequest = useCallback(
|
||||
async (endpoint: string, options: RequestInit = {}) => {
|
||||
let token = getAccessTokenFn();
|
||||
if (!token && user) {
|
||||
const refreshed = await silentRefresh();
|
||||
if (refreshed) token = getAccessTokenFn();
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
if (response.status === 401 && user) {
|
||||
const refreshed = await silentRefresh();
|
||||
if (refreshed) {
|
||||
token = getAccessTokenFn();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
return fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
}
|
||||
return response;
|
||||
},
|
||||
[getAccessTokenFn, silentRefresh, user],
|
||||
);
|
||||
|
||||
const updateUser = useCallback((updates: Partial<User>) => {
|
||||
setUser(prev => prev ? { ...prev, ...updates } : null)
|
||||
}, [])
|
||||
setUser((prev) => (prev ? { ...prev, ...updates } : null));
|
||||
}, []);
|
||||
|
||||
const hasPermission = useCallback((permission: string): boolean => {
|
||||
if (!user) return false
|
||||
if (user.isAdmin) return true
|
||||
return (user.permissions || []).includes(permission)
|
||||
}, [user])
|
||||
const hasPermission = useCallback(
|
||||
(permission: string): boolean => {
|
||||
if (!user) return false;
|
||||
if (user.isAdmin) return true;
|
||||
return (user.permissions || []).includes(permission);
|
||||
},
|
||||
[user],
|
||||
);
|
||||
|
||||
const permissions = useMemo(() => user?.permissions || [], [user])
|
||||
const permissions = useMemo(() => user?.permissions || [], [user]);
|
||||
|
||||
const stateValue = useMemo<AuthState>(() => ({
|
||||
user, loading, error, isAuthenticated: !!user, isAdmin: user?.isAdmin || false, permissions, hasPermission,
|
||||
}), [user, loading, error, permissions, hasPermission])
|
||||
const stateValue = useMemo<AuthState>(
|
||||
() => ({
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.isAdmin || false,
|
||||
permissions,
|
||||
hasPermission,
|
||||
}),
|
||||
[user, loading, error, permissions, hasPermission],
|
||||
);
|
||||
|
||||
const actionsValue = useMemo<AuthActions>(() => ({
|
||||
login, verify2FA, logout, checkSession, getAccessToken: getAccessTokenFn, apiRequest, silentRefresh, updateUser,
|
||||
}), [login, verify2FA, logout, checkSession, getAccessTokenFn, apiRequest, silentRefresh, updateUser])
|
||||
const actionsValue = useMemo<AuthActions>(
|
||||
() => ({
|
||||
login,
|
||||
verify2FA,
|
||||
logout,
|
||||
checkSession,
|
||||
getAccessToken: getAccessTokenFn,
|
||||
apiRequest,
|
||||
silentRefresh,
|
||||
updateUser,
|
||||
}),
|
||||
[
|
||||
login,
|
||||
verify2FA,
|
||||
logout,
|
||||
checkSession,
|
||||
getAccessTokenFn,
|
||||
apiRequest,
|
||||
silentRefresh,
|
||||
updateUser,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthActionsContext.Provider value={actionsValue}>
|
||||
@@ -281,26 +399,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
</AuthStateContext.Provider>
|
||||
</AuthActionsContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState & AuthActions {
|
||||
const state = useContext(AuthStateContext)
|
||||
const actions = useContext(AuthActionsContext)
|
||||
if (!state || !actions) throw new Error('useAuth must be used within an AuthProvider')
|
||||
return { ...state, ...actions }
|
||||
const state = useContext(AuthStateContext);
|
||||
const actions = useContext(AuthActionsContext);
|
||||
if (!state || !actions)
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
return { ...state, ...actions };
|
||||
}
|
||||
|
||||
export function useAuthState(): AuthState {
|
||||
const context = useContext(AuthStateContext)
|
||||
if (!context) throw new Error('useAuthState must be used within an AuthProvider')
|
||||
return context
|
||||
const context = useContext(AuthStateContext);
|
||||
if (!context)
|
||||
throw new Error("useAuthState must be used within an AuthProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useAuthActions(): AuthActions {
|
||||
const context = useContext(AuthActionsContext)
|
||||
if (!context) throw new Error('useAuthActions must be used within an AuthProvider')
|
||||
return context
|
||||
const context = useContext(AuthActionsContext);
|
||||
if (!context)
|
||||
throw new Error("useAuthActions must be used within an AuthProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
export default AuthStateContext
|
||||
export default AuthStateContext;
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import apiFetch from '../utils/api'
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
interface ApiCallResult<T> {
|
||||
data: T | null
|
||||
ok: boolean
|
||||
response: Response | null
|
||||
data: T | null;
|
||||
ok: boolean;
|
||||
response: Response | null;
|
||||
}
|
||||
|
||||
export default function useApiCall() {
|
||||
const alert = useAlert()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const alert = useAlert();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const call = useCallback(async <T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
errorMsg = 'Chyba při načítání dat'
|
||||
): Promise<ApiCallResult<T>> => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const call = useCallback(
|
||||
async <T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
errorMsg = "Chyba při načítání dat",
|
||||
): Promise<ApiCallResult<T>> => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok || !data.success) {
|
||||
alert.error(data.error || errorMsg)
|
||||
return { data: null, ok: false, response }
|
||||
try {
|
||||
const response = await apiFetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
alert.error(data.error || errorMsg);
|
||||
return { data: null, ok: false, response };
|
||||
}
|
||||
return { data: data.data as T, ok: true, response };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return { data: null, ok: false, response: null };
|
||||
}
|
||||
alert.error(errorMsg);
|
||||
return { data: null, ok: false, response: null };
|
||||
}
|
||||
return { data: data.data as T, ok: true, response }
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { data: null, ok: false, response: null }
|
||||
}
|
||||
alert.error(errorMsg)
|
||||
return { data: null, ok: false, response: null }
|
||||
}
|
||||
}, [alert])
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
return { call }
|
||||
return { call };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
return () => clearTimeout(handler)
|
||||
}, [value, delay])
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
@@ -1,90 +1,126 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import apiFetch from '../utils/api'
|
||||
import useDebounce from './useDebounce'
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import apiFetch from "../utils/api";
|
||||
import useDebounce from "./useDebounce";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface PaginationData {
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
total_pages: number
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface UseListDataOptions {
|
||||
dataKey?: string
|
||||
search?: string
|
||||
sort?: string
|
||||
order?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
extraParams?: Record<string, string>
|
||||
errorMsg?: string
|
||||
dataKey?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
extraParams?: Record<string, string>;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export default function useListData<T = unknown>(
|
||||
endpoint: string,
|
||||
options: UseListDataOptions = {}
|
||||
options: UseListDataOptions = {},
|
||||
) {
|
||||
const { dataKey, search = '', sort, order, page = 1, perPage = 25, extraParams = {}, errorMsg = 'Nepodařilo se načíst data' } = options
|
||||
const alert = useAlert()
|
||||
const [items, setItems] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [initialLoad, setInitialLoad] = useState(true)
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const {
|
||||
dataKey,
|
||||
search = "",
|
||||
sort,
|
||||
order,
|
||||
page = 1,
|
||||
perPage = 25,
|
||||
extraParams = {},
|
||||
errorMsg = "Nepodařilo se načíst data",
|
||||
} = options;
|
||||
const alert = useAlert();
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
})
|
||||
if (debouncedSearch) params.set('search', debouncedSearch)
|
||||
if (sort) params.set('sort', sort)
|
||||
if (order) params.set('order', order)
|
||||
});
|
||||
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||
if (sort) params.set("sort", sort);
|
||||
if (order) params.set("order", order);
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v) params.set(k, v)
|
||||
})
|
||||
if (v) params.set(k, v);
|
||||
});
|
||||
|
||||
const url = endpoint.startsWith('/') ? `${endpoint}?${params}` : `${API_BASE}/${endpoint}?${params}`
|
||||
const response = await apiFetch(url, { signal: controller.signal })
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
const url = endpoint.startsWith("/")
|
||||
? `${endpoint}?${params}`
|
||||
: `${API_BASE}/${endpoint}?${params}`;
|
||||
const response = await apiFetch(url, { signal: controller.signal });
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const data = dataKey ? result.data[dataKey] : (Array.isArray(result.data) ? result.data : result.data?.items || [])
|
||||
setItems(data || [])
|
||||
const pag = result.pagination || (!Array.isArray(result.data) && result.data?.pagination) || null
|
||||
setPagination(pag || {
|
||||
total: data?.length ?? 0,
|
||||
page,
|
||||
per_page: perPage,
|
||||
total_pages: 1,
|
||||
})
|
||||
const data = dataKey
|
||||
? result.data[dataKey]
|
||||
: Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data?.items || [];
|
||||
setItems(data || []);
|
||||
const pag =
|
||||
result.pagination ||
|
||||
(!Array.isArray(result.data) && result.data?.pagination) ||
|
||||
null;
|
||||
setPagination(
|
||||
pag || {
|
||||
total: data?.length ?? 0,
|
||||
page,
|
||||
per_page: perPage,
|
||||
total_pages: 1,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
alert.error(result.error || errorMsg)
|
||||
alert.error(result.error || errorMsg);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
alert.error(errorMsg)
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
alert.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setInitialLoad(false)
|
||||
setLoading(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(() => {
|
||||
fetchData()
|
||||
fetchData();
|
||||
return () => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
}
|
||||
}, [fetchData])
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
return { items, setItems, loading, initialLoad, pagination, refetch: fetchData }
|
||||
return {
|
||||
items,
|
||||
setItems,
|
||||
loading,
|
||||
initialLoad,
|
||||
pagination,
|
||||
refetch: fetchData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function useModalLock(isOpen: boolean): void {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen])
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
interface SortState {
|
||||
sort: string
|
||||
order: 'asc' | 'desc'
|
||||
sort: string;
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
export default function useTableSort(defaultSort = 'id', defaultOrder: 'asc' | 'desc' = 'desc') {
|
||||
const [state, setState] = useState<SortState>({ sort: defaultSort, order: defaultOrder })
|
||||
const userClicked = useRef(false)
|
||||
export default function useTableSort(
|
||||
defaultSort = "id",
|
||||
defaultOrder: "asc" | "desc" = "desc",
|
||||
) {
|
||||
const [state, setState] = useState<SortState>({
|
||||
sort: defaultSort,
|
||||
order: defaultOrder,
|
||||
});
|
||||
const userClicked = useRef(false);
|
||||
|
||||
const handleSort = useCallback((column: string) => {
|
||||
userClicked.current = true
|
||||
setState(prev => {
|
||||
userClicked.current = true;
|
||||
setState((prev) => {
|
||||
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
@@ -1,109 +1,169 @@
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { motion } from 'framer-motion'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import BulkAttendanceModal from '../components/BulkAttendanceModal'
|
||||
import ShiftFormModal from '../components/ShiftFormModal'
|
||||
import AttendanceShiftTable from '../components/AttendanceShiftTable'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
|
||||
import FormField from '../components/FormField'
|
||||
import { formatMinutes } from '../utils/attendanceHelpers'
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import BulkAttendanceModal from "../components/BulkAttendanceModal";
|
||||
import ShiftFormModal from "../components/ShiftFormModal";
|
||||
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
||||
import FormField from "../components/FormField";
|
||||
import { formatMinutes } from "../utils/attendanceHelpers";
|
||||
|
||||
interface UserTotalData {
|
||||
name: string
|
||||
minutes: number
|
||||
working: boolean
|
||||
vacation_hours: number
|
||||
sick_hours: number
|
||||
holiday_hours: number
|
||||
unpaid_hours: number
|
||||
fund: number | null
|
||||
worked_hours: number
|
||||
covered: number
|
||||
missing: number
|
||||
overtime: number
|
||||
name: string;
|
||||
minutes: number;
|
||||
working: boolean;
|
||||
vacation_hours: number;
|
||||
sick_hours: number;
|
||||
holiday_hours: number;
|
||||
unpaid_hours: number;
|
||||
fund: number | null;
|
||||
worked_hours: number;
|
||||
covered: number;
|
||||
missing: number;
|
||||
overtime: number;
|
||||
}
|
||||
|
||||
function getFundBarBackground(data: UserTotalData) {
|
||||
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
|
||||
if (data.covered >= (data.fund ?? 0)) return 'linear-gradient(135deg, var(--success), #059669)'
|
||||
return 'var(--gradient)'
|
||||
if (data.overtime > 0)
|
||||
return "linear-gradient(135deg, var(--warning), #d97706)";
|
||||
if (data.covered >= (data.fund ?? 0))
|
||||
return "linear-gradient(135deg, var(--success), #059669)";
|
||||
return "var(--gradient)";
|
||||
}
|
||||
|
||||
export default function AttendanceAdmin() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const {
|
||||
loading, month, setMonth,
|
||||
filterUserId, setFilterUserId,
|
||||
data, hasData,
|
||||
showBulkModal, setShowBulkModal,
|
||||
bulkSubmitting, bulkForm, setBulkForm,
|
||||
showCreateModal, setShowCreateModal,
|
||||
createForm, setCreateForm,
|
||||
showEditModal, setShowEditModal,
|
||||
editingRecord, editForm, setEditForm,
|
||||
deleteConfirm, setDeleteConfirm,
|
||||
loading,
|
||||
month,
|
||||
setMonth,
|
||||
filterUserId,
|
||||
setFilterUserId,
|
||||
data,
|
||||
hasData,
|
||||
showBulkModal,
|
||||
setShowBulkModal,
|
||||
bulkSubmitting,
|
||||
bulkForm,
|
||||
setBulkForm,
|
||||
showCreateModal,
|
||||
setShowCreateModal,
|
||||
createForm,
|
||||
setCreateForm,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
editingRecord,
|
||||
editForm,
|
||||
setEditForm,
|
||||
deleteConfirm,
|
||||
setDeleteConfirm,
|
||||
projectList,
|
||||
createProjectLogs, setCreateProjectLogs,
|
||||
editProjectLogs, setEditProjectLogs,
|
||||
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
|
||||
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
|
||||
openEditModal, handleEditSubmit,
|
||||
handleDelete, handlePrint
|
||||
} = useAttendanceAdmin({ alert })
|
||||
createProjectLogs,
|
||||
setCreateProjectLogs,
|
||||
editProjectLogs,
|
||||
setEditProjectLogs,
|
||||
openCreateModal,
|
||||
handleCreateShiftDateChange,
|
||||
handleCreateSubmit,
|
||||
openBulkModal,
|
||||
toggleBulkUser,
|
||||
toggleAllBulkUsers,
|
||||
handleBulkSubmit,
|
||||
openEditModal,
|
||||
handleEditSubmit,
|
||||
handleDelete,
|
||||
handlePrint,
|
||||
} = useAttendanceAdmin({ alert });
|
||||
|
||||
useModalLock(showBulkModal)
|
||||
useModalLock(showEditModal)
|
||||
useModalLock(showCreateModal)
|
||||
useModalLock(showBulkModal);
|
||||
useModalLock(showEditModal);
|
||||
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
|
||||
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) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "120px", borderRadius: "8px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "120px", borderRadius: "8px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "140px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
|
||||
<div
|
||||
className="admin-skeleton"
|
||||
style={{ gap: "0.75rem", padding: "1rem" }}
|
||||
>
|
||||
<div className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-line h-10" style={{ flex: 1, borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ flex: 1, borderRadius: "8px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ flex: 1, borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-grid admin-grid-3">
|
||||
{[0, 1, 2].map(i => (
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
|
||||
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
|
||||
<div className="admin-skeleton-line w-1/2" />
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
|
||||
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
|
||||
<div className="admin-skeleton-line w-full" style={{ height: '4px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "80px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-full"
|
||||
style={{ height: "4px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line w-1/3" />
|
||||
@@ -113,7 +173,7 @@ export default function AttendanceAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -134,7 +194,15 @@ export default function AttendanceAdmin() {
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk docházky"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginRight: "0.5rem" }}
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
@@ -152,7 +220,14 @@ export default function AttendanceAdmin() {
|
||||
onClick={openCreateModal}
|
||||
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="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -185,7 +260,9 @@ export default function AttendanceAdmin() {
|
||||
>
|
||||
<option value="">Všichni</option>
|
||||
{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>
|
||||
</FormField>
|
||||
@@ -202,68 +279,110 @@ export default function AttendanceAdmin() {
|
||||
transition={{ duration: 0.25, delay: 0.09 }}
|
||||
>
|
||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||
const ut = userData as UserTotalData
|
||||
const ut = userData as UserTotalData;
|
||||
return (
|
||||
<div key={uid} className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<div className="flex-row gap-2 mb-2">
|
||||
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
||||
<span className={`attendance-working-badge ${ut.working ? 'working' : 'finished'}`}>
|
||||
{ut.working ? '\u2713' : '\u2717'}
|
||||
<span
|
||||
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
|
||||
>
|
||||
{ut.working ? "\u2713" : "\u2717"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-stat-value">{formatMinutes(ut.minutes)}</div>
|
||||
<div className="admin-stat-value">
|
||||
{formatMinutes(ut.minutes)}
|
||||
</div>
|
||||
<div className="admin-stat-label">odpracováno</div>
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{ut.vacation_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-vacation">Dov: {ut.vacation_hours}h</span>
|
||||
<span className="attendance-leave-badge badge-vacation">
|
||||
Dov: {ut.vacation_hours}h
|
||||
</span>
|
||||
)}
|
||||
{ut.sick_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-sick">Nem: {ut.sick_hours}h</span>
|
||||
<span className="attendance-leave-badge badge-sick">
|
||||
Nem: {ut.sick_hours}h
|
||||
</span>
|
||||
)}
|
||||
{ut.holiday_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-holiday">Sv: {ut.holiday_hours}h</span>
|
||||
<span className="attendance-leave-badge badge-holiday">
|
||||
Sv: {ut.holiday_hours}h
|
||||
</span>
|
||||
)}
|
||||
{ut.unpaid_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-unpaid">Nep: {ut.unpaid_hours}h</span>
|
||||
<span className="attendance-leave-badge badge-unpaid">
|
||||
Nep: {ut.unpaid_hours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ut.fund !== null && (
|
||||
<div className="mt-2">
|
||||
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem' }}>
|
||||
<span>Fond: {ut.worked_hours}h / {ut.fund}h</span>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Fond: {ut.worked_hours}h / {ut.fund}h
|
||||
</span>
|
||||
{ut.overtime > 0 && (
|
||||
<span className="text-warning fw-600">+{ut.overtime}h</span>
|
||||
<span className="text-warning fw-600">
|
||||
+{ut.overtime}h
|
||||
</span>
|
||||
)}
|
||||
{ut.overtime <= 0 && ut.missing > 0 && (
|
||||
<span className="text-danger fw-600">-{ut.missing}h</span>
|
||||
<span className="text-danger fw-600">
|
||||
-{ut.missing}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '0.375rem',
|
||||
height: '4px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
|
||||
background: getFundBarBackground(ut),
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.375rem",
|
||||
height: "4px",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "2px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
|
||||
background: getFundBarBackground(ut),
|
||||
borderRadius: "2px",
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.leave_balances[uid] && (
|
||||
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
|
||||
Zbývá dovolené: {data.leave_balances[uid].vacation_remaining.toFixed(1)}h / {data.leave_balances[uid].vacation_total}h
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }}
|
||||
>
|
||||
Zbývá dovolené:{" "}
|
||||
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h
|
||||
/ {data.leave_balances[uid].vacation_total}h
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -337,5 +456,5 @@ export default function AttendanceAdmin() {
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +1,113 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import FormField from '../components/FormField'
|
||||
import apiFetch from '../utils/api'
|
||||
const API_BASE = '/api/admin'
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import FormField from "../components/FormField";
|
||||
import apiFetch from "../utils/api";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface User {
|
||||
id: number | string
|
||||
name: string
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateForm {
|
||||
user_id: string
|
||||
shift_date: string
|
||||
leave_type: string
|
||||
leave_hours: number
|
||||
arrival_date: string
|
||||
arrival_time: string
|
||||
break_start_date: string
|
||||
break_start_time: string
|
||||
break_end_date: string
|
||||
break_end_time: string
|
||||
departure_date: string
|
||||
departure_time: string
|
||||
notes: string
|
||||
user_id: string;
|
||||
shift_date: string;
|
||||
leave_type: string;
|
||||
leave_hours: number;
|
||||
arrival_date: string;
|
||||
arrival_time: string;
|
||||
break_start_date: string;
|
||||
break_start_time: string;
|
||||
break_end_date: string;
|
||||
break_end_time: string;
|
||||
departure_date: string;
|
||||
departure_time: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export default function AttendanceCreate() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const [form, setForm] = useState<CreateForm>({
|
||||
user_id: '',
|
||||
user_id: "",
|
||||
shift_date: today,
|
||||
leave_type: 'work',
|
||||
leave_type: "work",
|
||||
leave_hours: 8,
|
||||
arrival_date: today,
|
||||
arrival_time: '',
|
||||
arrival_time: "",
|
||||
break_start_date: today,
|
||||
break_start_time: '',
|
||||
break_start_time: "",
|
||||
break_end_date: today,
|
||||
break_end_time: '',
|
||||
break_end_time: "",
|
||||
departure_date: today,
|
||||
departure_time: '',
|
||||
notes: ''
|
||||
})
|
||||
departure_time: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/users`)
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/users`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setUsers(Array.isArray(result.data) ? result.data : result.data?.items || [])
|
||||
setUsers(
|
||||
Array.isArray(result.data) ? result.data : result.data?.items || [],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst uživatele')
|
||||
alert.error("Nepodařilo se načíst uživatele");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers()
|
||||
}, [alert])
|
||||
fetchUsers();
|
||||
}, [alert]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.user_id || !form.shift_date) {
|
||||
alert.error('Vyplňte zaměstnance a datum směny')
|
||||
return
|
||||
alert.error("Vyplňte zaměstnance a datum směny");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form)
|
||||
})
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert.success(result.message)
|
||||
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`)
|
||||
alert.success(result.message);
|
||||
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleShiftDateChange = (newDate: string) => {
|
||||
setForm({
|
||||
@@ -114,33 +116,42 @@ export default function AttendanceCreate() {
|
||||
arrival_date: newDate,
|
||||
break_start_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) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
|
||||
</div>
|
||||
<div className="admin-card" style={{ maxWidth: '600px' }}>
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-card" style={{ maxWidth: "600px" }}>
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="admin-skeleton-line w-1/4" style={{ marginBottom: '0.5rem', height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ marginBottom: "0.5rem", height: "10px" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line w-full h-10" />
|
||||
</div>
|
||||
))}
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "120px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -155,7 +166,10 @@ export default function AttendanceCreate() {
|
||||
<h1 className="admin-page-title">Přidat záznam docházky</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
|
||||
<Link
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
@@ -163,7 +177,7 @@ export default function AttendanceCreate() {
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
style={{ maxWidth: '600px' }}
|
||||
style={{ maxWidth: "600px" }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
@@ -174,13 +188,17 @@ export default function AttendanceCreate() {
|
||||
<FormField label="Zaměstnanec" required>
|
||||
<select
|
||||
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"
|
||||
required
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>{user.name}</option>
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
@@ -197,7 +215,9 @@ export default function AttendanceCreate() {
|
||||
<FormField label="Typ záznamu" required>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
@@ -213,13 +233,20 @@ export default function AttendanceCreate() {
|
||||
<input
|
||||
type="number"
|
||||
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"
|
||||
max="24"
|
||||
step="0.5"
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -230,14 +257,18 @@ export default function AttendanceCreate() {
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val: string) => setForm({ ...form, arrival_date: val })}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Příchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val: string) => setForm({ ...form, arrival_time: val })}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -247,14 +278,18 @@ export default function AttendanceCreate() {
|
||||
<AdminDatePicker
|
||||
mode="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 label="Začátek pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="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>
|
||||
</div>
|
||||
@@ -264,14 +299,18 @@ export default function AttendanceCreate() {
|
||||
<AdminDatePicker
|
||||
mode="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 label="Konec pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="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>
|
||||
</div>
|
||||
@@ -281,14 +320,18 @@ export default function AttendanceCreate() {
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val: string) => setForm({ ...form, departure_date: val })}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, departure_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Odchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val: string) => setForm({ ...form, departure_time: val })}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -305,7 +348,10 @@ export default function AttendanceCreate() {
|
||||
</FormField>
|
||||
|
||||
<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
|
||||
</Link>
|
||||
<button
|
||||
@@ -313,12 +359,12 @@ export default function AttendanceCreate() {
|
||||
disabled={submitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
{submitting ? 'Ukládám...' : 'Uložit'}
|
||||
{submitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,164 +1,210 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { motion } from 'framer-motion'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } from '../utils/attendanceHelpers'
|
||||
import FormField from '../components/FormField'
|
||||
import apiFetch from '../utils/api'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import {
|
||||
formatDate,
|
||||
formatDatetime,
|
||||
formatTime,
|
||||
calculateWorkMinutes,
|
||||
formatMinutes,
|
||||
getLeaveTypeName,
|
||||
getLeaveTypeBadgeClass,
|
||||
calculateWorkMinutesPrint,
|
||||
formatTimeOrDatetimePrint,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import FormField from "../components/FormField";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface ProjectLog {
|
||||
id?: number
|
||||
project_id?: number
|
||||
project_name?: string
|
||||
started_at?: string
|
||||
ended_at?: string | null
|
||||
hours?: string | number | null
|
||||
minutes?: string | number | null
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
project_name?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string | null;
|
||||
hours?: string | number | null;
|
||||
minutes?: string | number | null;
|
||||
}
|
||||
|
||||
interface AttendanceRecord {
|
||||
id: number
|
||||
shift_date: string
|
||||
leave_type?: string
|
||||
leave_hours?: number
|
||||
arrival_time?: string | null
|
||||
departure_time?: string | null
|
||||
break_start?: string | null
|
||||
break_end?: string | null
|
||||
notes?: string
|
||||
project_name?: string
|
||||
project_logs?: ProjectLog[]
|
||||
id: number;
|
||||
shift_date: string;
|
||||
leave_type?: string;
|
||||
leave_hours?: number;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
break_start?: string | null;
|
||||
break_end?: string | null;
|
||||
notes?: string;
|
||||
project_name?: string;
|
||||
project_logs?: ProjectLog[];
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
|
||||
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec'
|
||||
]
|
||||
"Leden",
|
||||
"Únor",
|
||||
"Březen",
|
||||
"Duben",
|
||||
"Květen",
|
||||
"Červen",
|
||||
"Červenec",
|
||||
"Srpen",
|
||||
"Září",
|
||||
"Říjen",
|
||||
"Listopad",
|
||||
"Prosinec",
|
||||
];
|
||||
|
||||
const formatBreakRange = (record: AttendanceRecord): string => {
|
||||
if (record.break_start && record.break_end) {
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||
}
|
||||
if (record.break_start) {
|
||||
return `${formatTime(record.break_start)} - ?`
|
||||
return `${formatTime(record.break_start)} - ?`;
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
return "—";
|
||||
};
|
||||
|
||||
const renderProjectCell = (record: AttendanceRecord) => {
|
||||
if (record.project_logs && record.project_logs.length > 0) {
|
||||
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) => {
|
||||
let h: number, m: number, isActive = false
|
||||
let h: number,
|
||||
m: number,
|
||||
isActive = false;
|
||||
if (log.hours !== null && log.hours !== undefined) {
|
||||
h = parseInt(String(log.hours)) || 0
|
||||
m = parseInt(String(log.minutes)) || 0
|
||||
h = parseInt(String(log.hours)) || 0;
|
||||
m = parseInt(String(log.minutes)) || 0;
|
||||
} else {
|
||||
isActive = !log.ended_at
|
||||
const end = log.ended_at ? new Date(log.ended_at) : new Date()
|
||||
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
|
||||
h = Math.floor(mins / 60)
|
||||
m = mins % 60
|
||||
isActive = !log.ended_at;
|
||||
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
||||
const mins = Math.floor(
|
||||
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
||||
);
|
||||
h = Math.floor(mins / 60);
|
||||
m = mins % 60;
|
||||
}
|
||||
return (
|
||||
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
|
||||
<span
|
||||
key={log.id || i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:
|
||||
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
|
||||
</span>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
|
||||
return (
|
||||
<span
|
||||
className="admin-badge admin-badge-wrap"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{record.project_name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
return "—";
|
||||
};
|
||||
|
||||
export default function AttendanceHistory() {
|
||||
const alert = useAlert()
|
||||
const { user, hasPermission } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const printRef = useRef<HTMLDivElement>(null)
|
||||
const alert = useAlert();
|
||||
const { user, hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
})
|
||||
const [records, setRecords] = useState<AttendanceRecord[]>([])
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
});
|
||||
const [records, setRecords] = useState<AttendanceRecord[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const [yearStr, monthStr] = month.split('-')
|
||||
const response = await apiFetch(`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ''}`)
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
const [yearStr, monthStr] = month.split("-");
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
|
||||
);
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setRecords(result.data)
|
||||
setRecords(result.data);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [month, alert, user?.id])
|
||||
}, [month, alert, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Compute totals client-side from raw records
|
||||
const computed = useMemo(() => {
|
||||
const [yearStr, monthStr] = month.split('-')
|
||||
const monthIndex = parseInt(monthStr, 10) - 1
|
||||
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`
|
||||
const [yearStr, monthStr] = month.split("-");
|
||||
const monthIndex = parseInt(monthStr, 10) - 1;
|
||||
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`;
|
||||
|
||||
let totalMinutes = 0
|
||||
let vacationHours = 0
|
||||
let sickHours = 0
|
||||
let holidayHours = 0
|
||||
let unpaidHours = 0
|
||||
let totalMinutes = 0;
|
||||
let vacationHours = 0;
|
||||
let sickHours = 0;
|
||||
let holidayHours = 0;
|
||||
let unpaidHours = 0;
|
||||
|
||||
for (const record of records) {
|
||||
const leaveType = record.leave_type || 'work'
|
||||
if (leaveType === 'work') {
|
||||
totalMinutes += calculateWorkMinutes(record)
|
||||
const leaveType = record.leave_type || "work";
|
||||
if (leaveType === "work") {
|
||||
totalMinutes += calculateWorkMinutes(record);
|
||||
} else {
|
||||
const hours = Number(record.leave_hours) || 8
|
||||
if (leaveType === 'vacation') vacationHours += hours
|
||||
else if (leaveType === 'sick') sickHours += hours
|
||||
else if (leaveType === 'holiday') holidayHours += hours
|
||||
else if (leaveType === 'unpaid') unpaidHours += hours
|
||||
const hours = Number(record.leave_hours) || 8;
|
||||
if (leaveType === "vacation") vacationHours += hours;
|
||||
else if (leaveType === "sick") sickHours += hours;
|
||||
else if (leaveType === "holiday") holidayHours += hours;
|
||||
else if (leaveType === "unpaid") unpaidHours += hours;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute monthly fund (working days * 8h)
|
||||
// Exclude holidays from business days (matching PHP CzechHolidays logic)
|
||||
const yr = parseInt(yearStr, 10)
|
||||
const mo = parseInt(monthStr, 10) - 1
|
||||
const yr = parseInt(yearStr, 10);
|
||||
const mo = parseInt(monthStr, 10) - 1;
|
||||
// Count holiday records to subtract from business days
|
||||
const holidayDays = records.filter(r => (r.leave_type || 'work') === 'holiday').length
|
||||
let businessDays = 0
|
||||
const cur = new Date(yr, mo, 1)
|
||||
const holidayDays = records.filter(
|
||||
(r) => (r.leave_type || "work") === "holiday",
|
||||
).length;
|
||||
let businessDays = 0;
|
||||
const cur = new Date(yr, mo, 1);
|
||||
while (cur.getMonth() === mo) {
|
||||
const dow = cur.getDay()
|
||||
if (dow !== 0 && dow !== 6) businessDays++
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
const dow = cur.getDay();
|
||||
if (dow !== 0 && dow !== 6) businessDays++;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
|
||||
businessDays = Math.max(0, businessDays - holidayDays)
|
||||
const fund = businessDays * 8
|
||||
const worked = Math.round((totalMinutes / 60) * 100) / 100
|
||||
businessDays = Math.max(0, businessDays - holidayDays);
|
||||
const fund = businessDays * 8;
|
||||
const worked = Math.round((totalMinutes / 60) * 100) / 100;
|
||||
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
|
||||
const leaveHours = vacationHours + sickHours
|
||||
const covered = Math.round((worked + leaveHours) * 100) / 100
|
||||
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100)
|
||||
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100)
|
||||
const leaveHours = vacationHours + sickHours;
|
||||
const covered = Math.round((worked + leaveHours) * 100) / 100;
|
||||
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
|
||||
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
|
||||
|
||||
const monthlyFund = {
|
||||
fund,
|
||||
@@ -167,18 +213,26 @@ export default function AttendanceHistory() {
|
||||
covered,
|
||||
remaining,
|
||||
overtime,
|
||||
}
|
||||
};
|
||||
|
||||
return { monthName, totalMinutes, vacationHours, sickHours, holidayHours, unpaidHours, monthlyFund }
|
||||
}, [records, month])
|
||||
return {
|
||||
monthName,
|
||||
totalMinutes,
|
||||
vacationHours,
|
||||
sickHours,
|
||||
holidayHours,
|
||||
unpaidHours,
|
||||
monthlyFund,
|
||||
};
|
||||
}, [records, month]);
|
||||
|
||||
if (!hasPermission('attendance.history')) return <Forbidden />
|
||||
if (!hasPermission("attendance.history")) return <Forbidden />;
|
||||
|
||||
const handlePrint = () => {
|
||||
if (!printRef.current) return
|
||||
const content = printRef.current.innerHTML
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) return
|
||||
if (!printRef.current) return;
|
||||
const content = printRef.current.innerHTML;
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
@@ -266,12 +320,12 @@ export default function AttendanceHistory() {
|
||||
${content}
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
printWindow.document.close()
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print()
|
||||
}
|
||||
}
|
||||
printWindow.print();
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -292,7 +346,15 @@ export default function AttendanceHistory() {
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk docházky"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginRight: "0.5rem" }}
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
@@ -332,33 +394,81 @@ export default function AttendanceHistory() {
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{loading && (
|
||||
<div className="admin-skeleton" style={{ gap: '0.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ gap: '1rem' }}>
|
||||
<div className="admin-skeleton-line" style={{ width: '48px', height: '48px', borderRadius: '12px', flexShrink: 0 }} />
|
||||
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
|
||||
<div className="admin-skeleton-row" style={{ gap: "1rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{
|
||||
width: "48px",
|
||||
height: "48px",
|
||||
borderRadius: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line w-full" style={{ height: '6px', borderRadius: '3px' }} />
|
||||
<div className="admin-skeleton-line w-1/3" style={{ height: '10px', marginTop: '0.5rem' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/2"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-full"
|
||||
style={{ height: "6px", borderRadius: "3px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ height: "10px", marginTop: "0.5rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && computed.monthlyFund && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-icon info">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.375rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '1rem', color: 'var(--text-primary)' }}>
|
||||
Fond: {computed.monthlyFund.worked}h / {computed.monthlyFund.fund}h
|
||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
||||
{computed.monthlyFund.fund}h
|
||||
</span>
|
||||
<span className="text-secondary" style={{ fontSize: '0.8125rem' }}>
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
{computed.monthlyFund.business_days} prac. dnů
|
||||
</span>
|
||||
</div>
|
||||
@@ -367,23 +477,41 @@ export default function AttendanceHistory() {
|
||||
className="attendance-balance-progress"
|
||||
style={{
|
||||
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
|
||||
background: computed.monthlyFund.covered >= computed.monthlyFund.fund
|
||||
? 'linear-gradient(135deg, var(--success), #059669)'
|
||||
: 'var(--gradient)'
|
||||
background:
|
||||
computed.monthlyFund.covered >=
|
||||
computed.monthlyFund.fund
|
||||
? "linear-gradient(135deg, var(--success), #059669)"
|
||||
: "var(--gradient)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginTop: '0.375rem' }}>
|
||||
<div
|
||||
className="text-muted"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{'Pokryto: '}{computed.monthlyFund.covered}h (práce {computed.monthlyFund.worked}h
|
||||
{computed.vacationHours > 0 && ` + dovolená ${computed.vacationHours}h`}
|
||||
{computed.sickHours > 0 && ` + nemoc ${computed.sickHours}h`}
|
||||
{computed.holidayHours > 0 && ` + svátek ${computed.holidayHours}h`}
|
||||
{computed.unpaidHours > 0 && ` + neplacené ${computed.unpaidHours}h`}
|
||||
{"Pokryto: "}
|
||||
{computed.monthlyFund.covered}h (práce{" "}
|
||||
{computed.monthlyFund.worked}h
|
||||
{computed.vacationHours > 0 &&
|
||||
` + dovolená ${computed.vacationHours}h`}
|
||||
{computed.sickHours > 0 &&
|
||||
` + nemoc ${computed.sickHours}h`}
|
||||
{computed.holidayHours > 0 &&
|
||||
` + svátek ${computed.holidayHours}h`}
|
||||
{computed.unpaidHours > 0 &&
|
||||
` + neplacené ${computed.unpaidHours}h`}
|
||||
)
|
||||
</span>
|
||||
{computed.monthlyFund.overtime > 0 ? (
|
||||
<span className="text-warning fw-600">Přesčas: +{computed.monthlyFund.overtime}h</span>
|
||||
<span className="text-warning fw-600">
|
||||
Přesčas: +{computed.monthlyFund.overtime}h
|
||||
</span>
|
||||
) : (
|
||||
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
|
||||
)}
|
||||
@@ -392,7 +520,14 @@ export default function AttendanceHistory() {
|
||||
</div>
|
||||
)}
|
||||
{!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
|
||||
</div>
|
||||
)}
|
||||
@@ -408,8 +543,8 @@ export default function AttendanceHistory() {
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{loading && (
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line w-1/3" />
|
||||
@@ -440,34 +575,53 @@ export default function AttendanceHistory() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || 'work'
|
||||
const isLeave = leaveType !== 'work'
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record)
|
||||
: calculateWorkMinutes(record);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(record.shift_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? '—' : formatBreakRange(record)}
|
||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
|
||||
<td className="admin-mono">{workMinutes > 0 ? formatMinutes(workMinutes, true) : '—'}</td>
|
||||
<td>
|
||||
{renderProjectCell(record)}
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreakRange(record)}
|
||||
</td>
|
||||
<td style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{record.notes || ''}
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? formatMinutes(workMinutes, true)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "150px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -478,109 +632,216 @@ export default function AttendanceHistory() {
|
||||
|
||||
{/* Hidden Print Content */}
|
||||
{records.length > 0 && (
|
||||
<div ref={printRef} style={{ display: 'none' }}>
|
||||
<div ref={printRef} style={{ display: "none" }}>
|
||||
<table className="print-wrapper-table">
|
||||
<thead>
|
||||
<tr><td>
|
||||
<div className="print-header">
|
||||
<div className="print-header-left">
|
||||
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
|
||||
<div className="print-header-text">
|
||||
<h1>EVIDENCE DOCHÁZKY</h1>
|
||||
<div className="company">BOHA Automation s.r.o.</div>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="print-header">
|
||||
<div className="print-header-left">
|
||||
<img
|
||||
src="/images/logo-light.png"
|
||||
alt="BOHA"
|
||||
className="print-logo"
|
||||
/>
|
||||
<div className="print-header-text">
|
||||
<h1>EVIDENCE DOCHÁZKY</h1>
|
||||
<div className="company">BOHA Automation s.r.o.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="print-header-right">
|
||||
<div className="period">{computed.monthName}</div>
|
||||
<div className="filters">
|
||||
Zaměstnanec: {user?.fullName || ""}
|
||||
</div>
|
||||
<div className="generated">
|
||||
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="print-header-right">
|
||||
<div className="period">{computed.monthName}</div>
|
||||
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div>
|
||||
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td></tr>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>
|
||||
<div className="user-section">
|
||||
<div className="user-header">
|
||||
<h3>{user?.fullName || ''}</h3>
|
||||
<span className="total">Odpracováno: {formatMinutes(computed.totalMinutes, true)}</span>
|
||||
</div>
|
||||
|
||||
{(computed.vacationHours > 0 || computed.sickHours > 0 || computed.holidayHours > 0) && (
|
||||
<div className="leave-summary">
|
||||
{computed.vacationHours > 0 && <><span className="leave-badge badge-vacation">Dovolená: {computed.vacationHours}h</span> </>}
|
||||
{computed.sickHours > 0 && <><span className="leave-badge badge-sick">Nemoc: {computed.sickHours}h</span> </>}
|
||||
{computed.holidayHours > 0 && <><span className="leave-badge badge-holiday">Svátek: {computed.holidayHours}h</span> </>}
|
||||
<tr>
|
||||
<td>
|
||||
<div className="user-section">
|
||||
<div className="user-header">
|
||||
<h3>{user?.fullName || ""}</h3>
|
||||
<span className="total">
|
||||
Odpracováno:{" "}
|
||||
{formatMinutes(computed.totalMinutes, true)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '70px' }}>Datum</th>
|
||||
<th style={{ width: '70px' }}>Typ</th>
|
||||
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
|
||||
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
|
||||
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
|
||||
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).map((record) => {
|
||||
const leaveType = record.leave_type || 'work'
|
||||
const isLeave = leaveType !== 'work'
|
||||
const workMinutes = calculateWorkMinutesPrint(record)
|
||||
const hours = Math.floor(workMinutes / 60)
|
||||
const mins = workMinutes % 60
|
||||
{(computed.vacationHours > 0 ||
|
||||
computed.sickHours > 0 ||
|
||||
computed.holidayHours > 0) && (
|
||||
<div className="leave-summary">
|
||||
{computed.vacationHours > 0 && (
|
||||
<>
|
||||
<span className="leave-badge badge-vacation">
|
||||
Dovolená: {computed.vacationHours}h
|
||||
</span>{" "}
|
||||
</>
|
||||
)}
|
||||
{computed.sickHours > 0 && (
|
||||
<>
|
||||
<span className="leave-badge badge-sick">
|
||||
Nemoc: {computed.sickHours}h
|
||||
</span>{" "}
|
||||
</>
|
||||
)}
|
||||
{computed.holidayHours > 0 && (
|
||||
<>
|
||||
<span className="leave-badge badge-holiday">
|
||||
Svátek: {computed.holidayHours}h
|
||||
</span>{" "}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td>{formatDate(record.shift_date)}</td>
|
||||
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
|
||||
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
|
||||
<td className="text-center">
|
||||
{isLeave || !record.break_start || !record.break_end
|
||||
? '—'
|
||||
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
|
||||
}
|
||||
</td>
|
||||
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
|
||||
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
|
||||
<td style={{ fontSize: '8px' }}>
|
||||
{(record.project_logs && record.project_logs.length > 0)
|
||||
? record.project_logs.map((log, i) => {
|
||||
let h: number, m: number
|
||||
if (log.hours !== null && log.hours !== undefined) {
|
||||
h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0
|
||||
} else if (log.started_at && log.ended_at) {
|
||||
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at).getTime()) / 60000))
|
||||
h = Math.floor(mins2 / 60); m = mins2 % 60
|
||||
} else { h = 0; m = 0 }
|
||||
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
|
||||
})
|
||||
: record.project_name || '—'}
|
||||
</td>
|
||||
<td>{record.notes || ''}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={6} className="text-right">Odpracováno:</td>
|
||||
<td className="text-center">{formatMinutes(computed.totalMinutes, true)}</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "70px" }}>Datum</th>
|
||||
<th style={{ width: "70px" }}>Typ</th>
|
||||
<th className="text-center" style={{ width: "70px" }}>
|
||||
Příchod
|
||||
</th>
|
||||
<th className="text-center" style={{ width: "90px" }}>
|
||||
Pauza
|
||||
</th>
|
||||
<th className="text-center" style={{ width: "70px" }}>
|
||||
Odchod
|
||||
</th>
|
||||
<th className="text-center" style={{ width: "80px" }}>
|
||||
Hodiny
|
||||
</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...records]
|
||||
.sort((a, b) =>
|
||||
a.shift_date.localeCompare(b.shift_date),
|
||||
)
|
||||
.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes =
|
||||
calculateWorkMinutesPrint(record);
|
||||
const hours = Math.floor(workMinutes / 60);
|
||||
const mins = workMinutes % 60;
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td>{formatDate(record.shift_date)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatTimeOrDatetimePrint(
|
||||
record.arrival_time,
|
||||
record.shift_date,
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{isLeave ||
|
||||
!record.break_start ||
|
||||
!record.break_end
|
||||
? "—"
|
||||
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatTimeOrDatetimePrint(
|
||||
record.departure_time,
|
||||
record.shift_date,
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{workMinutes > 0
|
||||
? `${hours}:${String(mins).padStart(2, "0")}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td style={{ fontSize: "8px" }}>
|
||||
{record.project_logs &&
|
||||
record.project_logs.length > 0
|
||||
? record.project_logs.map((log, i) => {
|
||||
let h: number, m: number;
|
||||
if (
|
||||
log.hours !== null &&
|
||||
log.hours !== undefined
|
||||
) {
|
||||
h = parseInt(String(log.hours)) || 0;
|
||||
m =
|
||||
parseInt(String(log.minutes)) || 0;
|
||||
} else if (
|
||||
log.started_at &&
|
||||
log.ended_at
|
||||
) {
|
||||
const mins2 = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(new Date(
|
||||
log.ended_at,
|
||||
).getTime() -
|
||||
new Date(
|
||||
log.started_at,
|
||||
).getTime()) /
|
||||
60000,
|
||||
),
|
||||
);
|
||||
h = Math.floor(mins2 / 60);
|
||||
m = mins2 % 60;
|
||||
} else {
|
||||
h = 0;
|
||||
m = 0;
|
||||
}
|
||||
return (
|
||||
<div key={log.id || i}>
|
||||
{log.project_name ||
|
||||
`#${log.project_id}`}{" "}
|
||||
({h}:{String(m).padStart(2, "0")}h)
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: record.project_name || "—"}
|
||||
</td>
|
||||
<td>{record.notes || ""}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={6} className="text-right">
|
||||
Odpracováno:
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{formatMinutes(computed.totalMinutes, true)}
|
||||
</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,154 +1,158 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { formatDate, formatTime } from '../utils/attendanceHelpers'
|
||||
import apiFetch from '../utils/api'
|
||||
const API_BASE = '/api/admin'
|
||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||
import apiFetch from "../utils/api";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
declare const L: any
|
||||
declare const L: any;
|
||||
|
||||
interface LocationRecord {
|
||||
user_name: string
|
||||
shift_date: string
|
||||
arrival_time?: string | null
|
||||
departure_time?: string | null
|
||||
arrival_lat?: string | number | null
|
||||
arrival_lng?: string | number | null
|
||||
arrival_accuracy?: number | null
|
||||
arrival_address?: string | null
|
||||
departure_lat?: string | number | null
|
||||
departure_lng?: string | number | null
|
||||
departure_accuracy?: number | null
|
||||
departure_address?: string | null
|
||||
user_name: string;
|
||||
shift_date: string;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
arrival_lat?: string | number | null;
|
||||
arrival_lng?: string | number | null;
|
||||
arrival_accuracy?: number | null;
|
||||
arrival_address?: string | null;
|
||||
departure_lat?: string | number | null;
|
||||
departure_lng?: string | number | null;
|
||||
departure_accuracy?: number | null;
|
||||
departure_address?: string | null;
|
||||
}
|
||||
|
||||
export default function AttendanceLocation() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [record, setRecord] = useState<LocationRecord | null>(null)
|
||||
const mapRef = useRef<HTMLDivElement>(null)
|
||||
const mapInstanceRef = useRef<unknown>(null)
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [record, setRecord] = useState<LocationRecord | null>(null);
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<unknown>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance?action=location&id=${id}`)
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?action=location&id=${id}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const raw = result.data.record || result.data
|
||||
const raw = result.data.record || result.data;
|
||||
// Enrich with user_name from nested users relation
|
||||
const userName = raw.users
|
||||
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
|
||||
: raw.user_name || ''
|
||||
setRecord({ ...raw, user_name: userName })
|
||||
: raw.user_name || "";
|
||||
setRecord({ ...raw, user_name: userName });
|
||||
} else {
|
||||
alert.error('Záznam nebyl nalezen')
|
||||
navigate('/attendance/admin')
|
||||
alert.error("Záznam nebyl nalezen");
|
||||
navigate("/attendance/admin");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
navigate('/attendance/admin')
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
navigate("/attendance/admin");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchData()
|
||||
}, [id, alert, navigate])
|
||||
fetchData();
|
||||
}, [id, alert, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!record || loading) return
|
||||
if (!record || loading) return;
|
||||
|
||||
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
|
||||
const hasDepartureLocation = record.departure_lat && record.departure_lng
|
||||
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
|
||||
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
|
||||
const hasDepartureLocation = record.departure_lat && record.departure_lng;
|
||||
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
|
||||
|
||||
if (!hasAnyLocation || !mapRef.current) return
|
||||
if (!hasAnyLocation || !mapRef.current) return;
|
||||
|
||||
const loadLeaflet = async () => {
|
||||
if ((window as unknown as Record<string, unknown>).L) {
|
||||
initMap()
|
||||
return
|
||||
initMap();
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
||||
document.head.appendChild(link)
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(link);
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||
script.onload = initMap
|
||||
document.body.appendChild(script)
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
|
||||
script.onload = initMap;
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
const initMap = () => {
|
||||
if (mapInstanceRef.current) {
|
||||
(mapInstanceRef.current as { remove: () => void }).remove()
|
||||
(mapInstanceRef.current as { remove: () => void }).remove();
|
||||
}
|
||||
|
||||
const map = L.map(mapRef.current!)
|
||||
mapInstanceRef.current = map
|
||||
const map = L.map(mapRef.current!);
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map)
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
}).addTo(map);
|
||||
|
||||
const bounds: [number, number][] = []
|
||||
const bounds: [number, number][] = [];
|
||||
|
||||
interface LocationPoint {
|
||||
lat: number
|
||||
lng: number
|
||||
type: string
|
||||
label: string
|
||||
time: string
|
||||
accuracy: number
|
||||
lat: number;
|
||||
lng: number;
|
||||
type: string;
|
||||
label: string;
|
||||
time: string;
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
const locations: LocationPoint[] = []
|
||||
const locations: LocationPoint[] = [];
|
||||
|
||||
if (hasArrivalLocation) {
|
||||
locations.push({
|
||||
lat: parseFloat(String(record.arrival_lat)),
|
||||
lng: parseFloat(String(record.arrival_lng)),
|
||||
type: 'arrival',
|
||||
label: 'Příchod',
|
||||
type: "arrival",
|
||||
label: "Příchod",
|
||||
time: formatTime(record.arrival_time),
|
||||
accuracy: Number(record.arrival_accuracy) || 0
|
||||
})
|
||||
accuracy: Number(record.arrival_accuracy) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasDepartureLocation) {
|
||||
locations.push({
|
||||
lat: parseFloat(String(record.departure_lat)),
|
||||
lng: parseFloat(String(record.departure_lng)),
|
||||
type: 'departure',
|
||||
label: 'Odchod',
|
||||
type: "departure",
|
||||
label: "Odchod",
|
||||
time: formatTime(record.departure_time),
|
||||
accuracy: Number(record.departure_accuracy) || 0
|
||||
})
|
||||
accuracy: Number(record.departure_accuracy) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
locations.forEach(loc => {
|
||||
const color = loc.type === 'arrival' ? '#22c55e' : '#ef4444'
|
||||
locations.forEach((loc) => {
|
||||
const color = loc.type === "arrival" ? "#22c55e" : "#ef4444";
|
||||
|
||||
const marker = L.circleMarker([loc.lat, loc.lng], {
|
||||
radius: 10,
|
||||
fillColor: color,
|
||||
color: '#fff',
|
||||
color: "#fff",
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(map)
|
||||
fillOpacity: 0.8,
|
||||
}).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) {
|
||||
L.circle([loc.lat, loc.lng], {
|
||||
@@ -157,55 +161,78 @@ export default function AttendanceLocation() {
|
||||
color: color,
|
||||
weight: 1,
|
||||
opacity: 0.3,
|
||||
fillOpacity: 0.1
|
||||
}).addTo(map)
|
||||
fillOpacity: 0.1,
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
bounds.push([loc.lat, loc.lng])
|
||||
})
|
||||
bounds.push([loc.lat, loc.lng]);
|
||||
});
|
||||
|
||||
if (bounds.length === 1) {
|
||||
map.setView(bounds[0], 16)
|
||||
map.setView(bounds[0], 16);
|
||||
} else if (bounds.length > 1) {
|
||||
map.fitBounds(bounds, { padding: [50, 50] })
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadLeaflet()
|
||||
loadLeaflet();
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
(mapInstanceRef.current as { remove: () => void }).remove()
|
||||
mapInstanceRef.current = null
|
||||
(mapInstanceRef.current as { remove: () => void }).remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [record, loading])
|
||||
};
|
||||
}, [record, loading]);
|
||||
|
||||
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
|
||||
if (!datetime) return '—'
|
||||
const d = new Date(datetime)
|
||||
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`
|
||||
}
|
||||
if (!datetime) return "—";
|
||||
const d = new Date(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) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
|
||||
>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton-line" style={{ width: '100%', height: '300px', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "100%", height: "300px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem' }}>
|
||||
{[0, 1].map(i => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "1.25rem",
|
||||
}}
|
||||
>
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '50%' }} />
|
||||
<div className="admin-skeleton" style={{ gap: "1rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "50%" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line w-full" />
|
||||
<div className="admin-skeleton-line w-3/4" />
|
||||
</div>
|
||||
@@ -213,18 +240,20 @@ export default function AttendanceLocation() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
|
||||
const hasDepartureLocation = record.departure_lat && record.departure_lng
|
||||
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
|
||||
const shiftDateStr = record.shift_date.includes('T') ? record.shift_date.split('T')[0] : record.shift_date
|
||||
const month = shiftDateStr.substring(0, 7)
|
||||
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
|
||||
const hasDepartureLocation = record.departure_lat && record.departure_lng;
|
||||
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
|
||||
const shiftDateStr = record.shift_date.includes("T")
|
||||
? record.shift_date.split("T")[0]
|
||||
: record.shift_date;
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -238,7 +267,10 @@ export default function AttendanceLocation() {
|
||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link to={`/attendance/admin?month=${month}`} className="admin-btn admin-btn-secondary">
|
||||
<Link
|
||||
to={`/attendance/admin?month=${month}`}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
@@ -257,18 +289,19 @@ export default function AttendanceLocation() {
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{hasAnyLocation && (
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="attendance-location-map"
|
||||
/>
|
||||
<div ref={mapRef} className="attendance-location-map" />
|
||||
)}
|
||||
|
||||
<div className="attendance-location-grid">
|
||||
{/* Arrival */}
|
||||
<div className={`attendance-location-card ${!hasArrivalLocation ? 'empty' : ''}`}>
|
||||
<div
|
||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
||||
>
|
||||
<h3 className="attendance-location-title">Příchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.arrival_time ? formatDatetimeLocal(record.arrival_time) : '—'}
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasArrivalLocation ? (
|
||||
<>
|
||||
@@ -277,7 +310,8 @@ export default function AttendanceLocation() {
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy && ` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
@@ -297,10 +331,14 @@ export default function AttendanceLocation() {
|
||||
|
||||
{/* Departure */}
|
||||
{(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>
|
||||
<div className="attendance-location-time">
|
||||
{record.departure_time ? formatDatetimeLocal(record.departure_time) : '—'}
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasDepartureLocation ? (
|
||||
<>
|
||||
@@ -309,7 +347,8 @@ export default function AttendanceLocation() {
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy && ` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
@@ -331,5 +370,5 @@ export default function AttendanceLocation() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,220 +1,263 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import Pagination from '../components/Pagination'
|
||||
import FormField from '../components/FormField'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import { czechPlural } from '../utils/formatters'
|
||||
import apiFetch from '../utils/api'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import Pagination from "../components/Pagination";
|
||||
import FormField from "../components/FormField";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { czechPlural } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'Vytvoření',
|
||||
update: 'Úprava',
|
||||
delete: 'Smazání',
|
||||
login: 'Přihlášení',
|
||||
login_failed: 'Neúspěšné přihlášení',
|
||||
logout: 'Odhlášení',
|
||||
view: 'Zobrazení',
|
||||
activate: 'Aktivace',
|
||||
deactivate: 'Deaktivace',
|
||||
password_change: 'Změna hesla',
|
||||
permission_change: 'Změna oprávnění',
|
||||
access_denied: 'Přístup odepřen',
|
||||
}
|
||||
create: "Vytvoření",
|
||||
update: "Úprava",
|
||||
delete: "Smazání",
|
||||
login: "Přihlášení",
|
||||
login_failed: "Neúspěšné přihlášení",
|
||||
logout: "Odhlášení",
|
||||
view: "Zobrazení",
|
||||
activate: "Aktivace",
|
||||
deactivate: "Deaktivace",
|
||||
password_change: "Změna hesla",
|
||||
permission_change: "Změna oprávnění",
|
||||
access_denied: "Přístup odepřen",
|
||||
};
|
||||
|
||||
const ACTION_BADGE_CLASS: Record<string, string> = {
|
||||
create: 'admin-badge-success',
|
||||
update: 'admin-badge-info',
|
||||
delete: 'admin-badge-danger',
|
||||
login: 'admin-badge-secondary',
|
||||
login_failed: 'admin-badge-danger',
|
||||
logout: 'admin-badge-secondary',
|
||||
view: 'admin-badge-info',
|
||||
activate: 'admin-badge-success',
|
||||
deactivate: 'admin-badge-warning',
|
||||
password_change: 'admin-badge-info',
|
||||
permission_change: 'admin-badge-warning',
|
||||
access_denied: 'admin-badge-danger',
|
||||
}
|
||||
create: "admin-badge-success",
|
||||
update: "admin-badge-info",
|
||||
delete: "admin-badge-danger",
|
||||
login: "admin-badge-secondary",
|
||||
login_failed: "admin-badge-danger",
|
||||
logout: "admin-badge-secondary",
|
||||
view: "admin-badge-info",
|
||||
activate: "admin-badge-success",
|
||||
deactivate: "admin-badge-warning",
|
||||
password_change: "admin-badge-info",
|
||||
permission_change: "admin-badge-warning",
|
||||
access_denied: "admin-badge-danger",
|
||||
};
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
user: 'Uživatel',
|
||||
attendance: 'Docházka',
|
||||
leave_request: 'Žádost o nepřítomnost',
|
||||
offers_quotation: 'Nabídka',
|
||||
offers_customer: 'Zákazník',
|
||||
offers_item_template: 'Šablona položky',
|
||||
offers_scope_template: 'Šablona rozsahu',
|
||||
offers_settings: 'Nastavení nabídek',
|
||||
orders_order: 'Objednávka',
|
||||
invoices_invoice: 'Faktura',
|
||||
projects_project: 'Projekt',
|
||||
role: 'Role',
|
||||
trips: 'Jízda',
|
||||
vehicles: 'Vozidlo',
|
||||
bank_account: 'Bankovní účet',
|
||||
}
|
||||
user: "Uživatel",
|
||||
attendance: "Docházka",
|
||||
leave_request: "Žádost o nepřítomnost",
|
||||
offers_quotation: "Nabídka",
|
||||
offers_customer: "Zákazník",
|
||||
offers_item_template: "Šablona položky",
|
||||
offers_scope_template: "Šablona rozsahu",
|
||||
offers_settings: "Nastavení nabídek",
|
||||
orders_order: "Objednávka",
|
||||
invoices_invoice: "Faktura",
|
||||
projects_project: "Projekt",
|
||||
role: "Role",
|
||||
trips: "Jízda",
|
||||
vehicles: "Vozidlo",
|
||||
bank_account: "Bankovní účet",
|
||||
};
|
||||
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
|
||||
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: number
|
||||
created_at: string
|
||||
username: string | null
|
||||
action: string
|
||||
entity_type: string | null
|
||||
description: string | null
|
||||
user_ip: string | null
|
||||
id: number;
|
||||
created_at: string;
|
||||
username: string | null;
|
||||
action: string;
|
||||
entity_type: string | null;
|
||||
description: string | null;
|
||||
user_ip: string | null;
|
||||
}
|
||||
|
||||
interface PaginationData {
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
total_pages: number
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search: string
|
||||
action: string
|
||||
entity_type: string
|
||||
date_from: string
|
||||
date_to: string
|
||||
search: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
export default function AuditLog() {
|
||||
const { hasPermission } = useAuth()
|
||||
const alert = useAlert()
|
||||
const [logs, setLogs] = useState<AuditLogEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null)
|
||||
const { hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null);
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
search: '',
|
||||
action: '',
|
||||
entity_type: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
})
|
||||
const [showCleanup, setShowCleanup] = useState(false)
|
||||
const [cleanupDays, setCleanupDays] = useState(90)
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
search: "",
|
||||
action: "",
|
||||
entity_type: "",
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
});
|
||||
const [showCleanup, setShowCleanup] = useState(false);
|
||||
const [cleanupDays, setCleanupDays] = useState(90);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||
const fetchLogs = useCallback(
|
||||
async (page = 1, perPage = 50) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
});
|
||||
|
||||
if (filters.search) params.set('search', filters.search)
|
||||
if (filters.action) params.set('action', filters.action)
|
||||
if (filters.entity_type) params.set('entity_type', filters.entity_type)
|
||||
if (filters.date_from) params.set('date_from', filters.date_from)
|
||||
if (filters.date_to) params.set('date_to', filters.date_to)
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.action) params.set("action", filters.action);
|
||||
if (filters.entity_type) params.set("entity_type", filters.entity_type);
|
||||
if (filters.date_from) params.set("date_from", filters.date_from);
|
||||
if (filters.date_to) params.set("date_to", filters.date_to);
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/audit-log?${params.toString()}`)
|
||||
const data = await response.json()
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/audit-log?${params.toString()}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setLogs(Array.isArray(data.data) ? data.data : [])
|
||||
setPagination({
|
||||
total: data.pagination?.total ?? 0,
|
||||
page: data.pagination?.page ?? 1,
|
||||
per_page: data.pagination?.limit ?? 50,
|
||||
total_pages: data.pagination?.total_pages ?? 1,
|
||||
})
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se načíst audit log')
|
||||
if (data.success) {
|
||||
setLogs(Array.isArray(data.data) ? data.data : []);
|
||||
setPagination({
|
||||
total: data.pagination?.total ?? 0,
|
||||
page: data.pagination?.page ?? 1,
|
||||
per_page: data.pagination?.limit ?? 50,
|
||||
total_pages: data.pagination?.total_pages ?? 1,
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se načíst audit log");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters, alert])
|
||||
},
|
||||
[filters, alert],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [fetchLogs])
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
if (!hasPermission('settings.audit')) {
|
||||
return <Forbidden />
|
||||
if (!hasPermission("settings.audit")) {
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLogs(newPage, pagination?.per_page || 50)
|
||||
}
|
||||
fetchLogs(newPage, pagination?.per_page || 50);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
fetchLogs(1, newPerPage)
|
||||
}
|
||||
fetchLogs(1, newPerPage);
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
setCleaning(true)
|
||||
setCleaning(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ days: cleanupDays }),
|
||||
})
|
||||
const data = await response.json()
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert.success(data.message)
|
||||
setShowCleanup(false)
|
||||
fetchLogs()
|
||||
alert.success(data.message);
|
||||
setShowCleanup(false);
|
||||
fetchLogs();
|
||||
} else {
|
||||
alert.error(data.error)
|
||||
alert.error(data.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setCleaning(false)
|
||||
setCleaning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDatetime = (dateString: string | null): string => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('cs-CZ')
|
||||
}
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleString("cs-CZ");
|
||||
};
|
||||
|
||||
if (loading && logs.length === 0) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '160px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '100px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "160px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "100px" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '0.75rem', padding: '1rem' }}>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton"
|
||||
style={{ gap: "0.75rem", padding: "1rem" }}
|
||||
>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '4px' }} />
|
||||
<div className="admin-skeleton" style={{ gap: "1rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "100%", borderRadius: "4px" }}
|
||||
/>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line" style={{ width: '120px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '80px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '70px', borderRadius: '10px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '80px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "80px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "70px", borderRadius: "10px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "80px" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line flex-1" />
|
||||
<div className="admin-skeleton-line" style={{ width: '90px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "90px" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -229,7 +272,8 @@ export default function AuditLog() {
|
||||
<h1 className="admin-page-title">Audit log</h1>
|
||||
{pagination && (
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination.total} {czechPlural(pagination.total, 'záznam', 'záznamy', 'záznamů')}
|
||||
{pagination.total}{" "}
|
||||
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,7 +281,14 @@ export default function AuditLog() {
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => setShowCleanup(true)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
@@ -247,7 +298,10 @@ export default function AuditLog() {
|
||||
|
||||
{showCleanup && (
|
||||
<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
|
||||
className="admin-modal admin-confirm-modal"
|
||||
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-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" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
|
||||
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
|
||||
<div style={{ margin: '0.75rem auto', maxWidth: '200px' }}>
|
||||
<p className="admin-confirm-message">
|
||||
Smazat záznamy starší než:
|
||||
</p>
|
||||
<div style={{ margin: "0.75rem auto", maxWidth: "200px" }}>
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={cleanupDays}
|
||||
@@ -277,7 +340,12 @@ export default function AuditLog() {
|
||||
<option value={0}>Vše</option>
|
||||
</select>
|
||||
</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 className="admin-modal-footer">
|
||||
<button
|
||||
@@ -294,7 +362,7 @@ export default function AuditLog() {
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={cleaning}
|
||||
>
|
||||
{cleaning ? 'Mažu...' : 'Smazat'}
|
||||
{cleaning ? "Mažu..." : "Smazat"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -315,18 +383,20 @@ export default function AuditLog() {
|
||||
className="admin-form-input"
|
||||
placeholder="Popis, uživatel..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Akce">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||
onChange={(e) => handleFilterChange("action", e.target.value)}
|
||||
>
|
||||
<option value="">Všechny</option>
|
||||
{ACTION_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
{ACTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
@@ -334,11 +404,15 @@ export default function AuditLog() {
|
||||
<select
|
||||
className="admin-form-select"
|
||||
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>
|
||||
{ENTITY_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
{ENTITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
@@ -346,14 +420,14 @@ export default function AuditLog() {
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_from}
|
||||
onChange={(val: string) => handleFilterChange('date_from', val)}
|
||||
onChange={(val: string) => handleFilterChange("date_from", val)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_to}
|
||||
onChange={(val: string) => handleFilterChange('date_to', val)}
|
||||
onChange={(val: string) => handleFilterChange("date_to", val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -380,22 +454,64 @@ export default function AuditLog() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && Array.from({ length: 10 }, (_, i) => (
|
||||
<tr key={`skeleton-${i}`}>
|
||||
<td><div className="admin-skeleton-line" style={{ width: '110px', height: '14px' }} /></td>
|
||||
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
|
||||
<td><div className="admin-skeleton-line" style={{ width: '70px', height: '22px', borderRadius: '10px' }} /></td>
|
||||
<td><div className="admin-skeleton-line" style={{ width: '80px', height: '14px' }} /></td>
|
||||
<td><div className="admin-skeleton-line" style={{ width: '60%', height: '14px' }} /></td>
|
||||
<td><div className="admin-skeleton-line" style={{ width: '90px', height: '14px' }} /></td>
|
||||
</tr>
|
||||
))}
|
||||
{loading &&
|
||||
Array.from({ length: 10 }, (_, i) => (
|
||||
<tr key={`skeleton-${i}`}>
|
||||
<td>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "110px", height: "14px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "80px", height: "14px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{
|
||||
width: "70px",
|
||||
height: "22px",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "80px", height: "14px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "60%", height: "14px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "90px", height: "14px" }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
@@ -407,20 +523,29 @@ export default function AuditLog() {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="admin-mono">{formatDatetime(log.created_at)}</td>
|
||||
<td className="fw-500">{log.username || '-'}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td>{ENTITY_TYPE_LABELS[log.entity_type || ''] || log.entity_type || '-'}</td>
|
||||
<td>{log.description || '-'}</td>
|
||||
<td className="admin-mono">{log.user_ip || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading &&
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDatetime(log.created_at)}
|
||||
</td>
|
||||
<td className="fw-500">{log.username || "-"}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
|
||||
>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
|
||||
log.entity_type ||
|
||||
"-"}
|
||||
</td>
|
||||
<td>{log.description || "-"}</td>
|
||||
<td className="admin-mono">{log.user_ip || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -433,5 +558,5 @@ export default function AuditLog() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,203 +1,203 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import apiFetch from '../utils/api'
|
||||
import { getCzechDate } from '../utils/dashboardHelpers'
|
||||
import DashKpiCards from '../components/dashboard/DashKpiCards'
|
||||
import DashQuickActions from '../components/dashboard/DashQuickActions'
|
||||
import DashActivityFeed from '../components/dashboard/DashActivityFeed'
|
||||
import DashAttendanceToday from '../components/dashboard/DashAttendanceToday'
|
||||
import DashProfile from '../components/dashboard/DashProfile'
|
||||
import DashSessions from '../components/dashboard/DashSessions'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import apiFetch from "../utils/api";
|
||||
import { getCzechDate } from "../utils/dashboardHelpers";
|
||||
import DashKpiCards from "../components/dashboard/DashKpiCards";
|
||||
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
||||
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
||||
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
|
||||
import DashProfile from "../components/dashboard/DashProfile";
|
||||
import DashSessions from "../components/dashboard/DashSessions";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type DashData = Record<string, any>
|
||||
type DashData = Record<string, any>;
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, updateUser, hasPermission } = useAuth()
|
||||
const alert = useAlert()
|
||||
const { user, updateUser, hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
|
||||
const [dashData, setDashData] = useState<DashData | null>(null)
|
||||
const [dashLoading, setDashLoading] = useState(true)
|
||||
const [punching, setPunching] = useState(false)
|
||||
const [dashData, setDashData] = useState<DashData | null>(null);
|
||||
const [dashLoading, setDashLoading] = useState(true);
|
||||
const [punching, setPunching] = useState(false);
|
||||
|
||||
// 2FA state - sdileny mezi profilem a bannerem
|
||||
const [totpEnabled, setTotpEnabled] = useState(false)
|
||||
const [totpLoading, setTotpLoading] = useState(true)
|
||||
const [show2FASetup, setShow2FASetup] = useState(false)
|
||||
const [show2FADisable, setShow2FADisable] = useState(false)
|
||||
const [totpSecret, setTotpSecret] = useState<string | null>(null)
|
||||
const [totpQrUri, setTotpQrUri] = useState<string | null>(null)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [totpSubmitting, setTotpSubmitting] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
const [disableCode, setDisableCode] = useState('')
|
||||
const [totpEnabled, setTotpEnabled] = useState(false);
|
||||
const [totpLoading, setTotpLoading] = useState(true);
|
||||
const [show2FASetup, setShow2FASetup] = useState(false);
|
||||
const [show2FADisable, setShow2FADisable] = useState(false);
|
||||
const [totpSecret, setTotpSecret] = useState<string | null>(null);
|
||||
const [totpQrUri, setTotpQrUri] = useState<string | null>(null);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
const [disableCode, setDisableCode] = useState("");
|
||||
|
||||
useModalLock(show2FASetup)
|
||||
useModalLock(show2FADisable)
|
||||
useModalLock(show2FASetup);
|
||||
useModalLock(show2FADisable);
|
||||
|
||||
const fetchDashboard = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/dashboard`)
|
||||
const data = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/dashboard`);
|
||||
const data = await response.json();
|
||||
if (data.success !== false) {
|
||||
setDashData(data.data || data)
|
||||
setDashData(data.data || data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Dashboard fetch error:', err)
|
||||
console.error("Dashboard fetch error:", err);
|
||||
}
|
||||
} finally {
|
||||
setDashLoading(false)
|
||||
setDashLoading(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard()
|
||||
}, [fetchDashboard])
|
||||
fetchDashboard();
|
||||
}, [fetchDashboard]);
|
||||
|
||||
// 2FA status fetch
|
||||
const fetch2FAStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/totp/setup`)
|
||||
const data = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/totp/setup`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTotpEnabled(!!user?.totpEnabled)
|
||||
setTotpEnabled(!!user?.totpEnabled);
|
||||
}
|
||||
} catch {
|
||||
// 2FA status fetch failed silently
|
||||
setTotpEnabled(!!user?.totpEnabled)
|
||||
setTotpEnabled(!!user?.totpEnabled);
|
||||
} finally {
|
||||
setTotpLoading(false)
|
||||
setTotpLoading(false);
|
||||
}
|
||||
}, [user?.totpEnabled])
|
||||
}, [user?.totpEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch2FAStatus()
|
||||
}, [fetch2FAStatus])
|
||||
fetch2FAStatus();
|
||||
}, [fetch2FAStatus]);
|
||||
|
||||
// Punch (prichod/odchod) primo z dashboardu
|
||||
const handleQuickPunch = () => {
|
||||
const action = dashData?.my_shift?.has_ongoing ? 'departure' : 'arrival'
|
||||
setPunching(true)
|
||||
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
||||
setPunching(true);
|
||||
|
||||
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ punch_action: action, ...gpsData })
|
||||
})
|
||||
const result = await response.json()
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ punch_action: action, ...gpsData }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert.success(result.data?.message || 'Docházka zaznamenána')
|
||||
fetchDashboard()
|
||||
alert.success(result.data?.message || "Docházka zaznamenána");
|
||||
fetchDashboard();
|
||||
} else {
|
||||
alert.error(result.error || 'Chyba při záznamu docházky')
|
||||
alert.error(result.error || "Chyba při záznamu docházky");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba pripojeni')
|
||||
alert.error("Chyba pripojeni");
|
||||
} finally {
|
||||
setPunching(false)
|
||||
setPunching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
submitPunch({})
|
||||
return
|
||||
submitPunch({});
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const { latitude, longitude, accuracy } = pos.coords
|
||||
submitPunch({ latitude, longitude, accuracy, address: '' })
|
||||
const { latitude, longitude, accuracy } = pos.coords;
|
||||
submitPunch({ latitude, longitude, accuracy, address: "" });
|
||||
},
|
||||
() => submitPunch({}),
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
||||
)
|
||||
}
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
||||
);
|
||||
};
|
||||
|
||||
// 2FA handlery
|
||||
const handleStart2FASetup = async () => {
|
||||
setTotpSubmitting(true)
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/totp/setup`)
|
||||
const data = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/totp/setup`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTotpSecret(data.data.secret)
|
||||
setTotpQrUri(data.data.uri || data.data.qr_uri)
|
||||
setTotpCode('')
|
||||
setBackupCodes(null)
|
||||
setShow2FASetup(true)
|
||||
setTotpSecret(data.data.secret);
|
||||
setTotpQrUri(data.data.uri || data.data.qr_uri);
|
||||
setTotpCode("");
|
||||
setBackupCodes(null);
|
||||
setShow2FASetup(true);
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se vygenerovat 2FA klíč')
|
||||
alert.error(data.error || "Nepodařilo se vygenerovat 2FA klíč");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setTotpSubmitting(false)
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm2FA = async () => {
|
||||
if (!totpCode.trim()) return
|
||||
setTotpSubmitting(true)
|
||||
if (!totpCode.trim()) return;
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/totp/enable`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() })
|
||||
})
|
||||
const data = await response.json()
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTotpEnabled(true)
|
||||
setBackupCodes(data.data?.backup_codes || null)
|
||||
setTotpSecret(null)
|
||||
setTotpQrUri(null)
|
||||
updateUser({ totpEnabled: true })
|
||||
alert.success('2FA bylo aktivováno')
|
||||
setTotpEnabled(true);
|
||||
setBackupCodes(data.data?.backup_codes || null);
|
||||
setTotpSecret(null);
|
||||
setTotpQrUri(null);
|
||||
updateUser({ totpEnabled: true });
|
||||
alert.success("2FA bylo aktivováno");
|
||||
} else {
|
||||
alert.error(data.error || 'Neplatný kód')
|
||||
setTotpCode('')
|
||||
alert.error(data.error || "Neplatný kód");
|
||||
setTotpCode("");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setTotpSubmitting(false)
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
if (!disableCode.trim()) return
|
||||
setTotpSubmitting(true)
|
||||
if (!disableCode.trim()) return;
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/totp/disable`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: disableCode.trim() })
|
||||
})
|
||||
const data = await response.json()
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: disableCode.trim() }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTotpEnabled(false)
|
||||
setShow2FADisable(false)
|
||||
setDisableCode('')
|
||||
updateUser({ totpEnabled: false })
|
||||
alert.success('2FA bylo deaktivováno')
|
||||
setTotpEnabled(false);
|
||||
setShow2FADisable(false);
|
||||
setDisableCode("");
|
||||
updateUser({ totpEnabled: false });
|
||||
alert.success("2FA bylo deaktivováno");
|
||||
} else {
|
||||
alert.error(data.error || 'Neplatný kód')
|
||||
setDisableCode('')
|
||||
alert.error(data.error || "Neplatný kód");
|
||||
setDisableCode("");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setTotpSubmitting(false)
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dash">
|
||||
@@ -223,29 +223,66 @@ export default function Dashboard() {
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ border: '2px solid var(--danger)', background: 'var(--danger-light)' }}
|
||||
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 style={{
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'var(--danger-light)', color: 'var(--danger)', flexShrink: 0
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--danger-light)",
|
||||
color: "var(--danger)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
|
||||
<div className="text-secondary" style={{ fontSize: '0.875rem' }}>
|
||||
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete, nemáte přístup k ostatním sekcím systému.
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
|
||||
nemáte přístup k ostatním sekcím systému.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary" style={{ flexShrink: 0 }}>
|
||||
{totpSubmitting ? 'Generuji...' : 'Aktivovat 2FA nyní'}
|
||||
<button
|
||||
onClick={handleStart2FASetup}
|
||||
disabled={totpSubmitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -253,36 +290,70 @@ export default function Dashboard() {
|
||||
|
||||
{/* Skeleton loading */}
|
||||
{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">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div key={i} className="admin-skeleton-line h-24" style={{ borderRadius: '10px' }} />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="admin-skeleton-line h-24"
|
||||
style={{ borderRadius: "10px" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="dash-quick-actions">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '10px' }} />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "52px", borderRadius: "10px" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="dash-main-grid">
|
||||
<div className="admin-skeleton-line" style={{ height: '320px', borderRadius: '10px' }} />
|
||||
<div className="admin-skeleton-line" style={{ height: '320px', borderRadius: '10px' }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div className="admin-skeleton-line" style={{ height: '150px', borderRadius: '10px' }} />
|
||||
<div className="admin-skeleton-line" style={{ height: '150px', borderRadius: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "320px", borderRadius: "10px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "320px", borderRadius: "10px" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.25rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "150px", borderRadius: "10px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "150px", borderRadius: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-bottom">
|
||||
<div className="admin-skeleton-line" style={{ height: '200px', borderRadius: '10px' }} />
|
||||
<div className="admin-skeleton-line" style={{ height: '200px', borderRadius: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "200px", borderRadius: "10px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "200px", borderRadius: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI cards — only show if user has any admin-level permissions */}
|
||||
{!dashLoading && (hasPermission('offers.view') || hasPermission('invoices.view') || hasPermission('projects.view') || hasPermission('orders.view')) && (
|
||||
<DashKpiCards dashData={dashData} />
|
||||
)}
|
||||
{!dashLoading &&
|
||||
(hasPermission("offers.view") ||
|
||||
hasPermission("invoices.view") ||
|
||||
hasPermission("projects.view") ||
|
||||
hasPermission("orders.view")) && <DashKpiCards dashData={dashData} />}
|
||||
|
||||
{/* Quick actions */}
|
||||
{!dashLoading && (
|
||||
@@ -294,87 +365,125 @@ export default function Dashboard() {
|
||||
)}
|
||||
|
||||
{/* Main content grid */}
|
||||
{!dashLoading && <motion.div
|
||||
className="dash-main-grid"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
{hasPermission('settings.audit') && <DashActivityFeed activities={dashData?.recent_activity} />}
|
||||
{!dashLoading && (
|
||||
<motion.div
|
||||
className="dash-main-grid"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
{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 */}
|
||||
<div className="dash-right-col">
|
||||
{dashData?.projects && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Aktivní projekty</h2>
|
||||
<Link to="/projects" className="admin-btn admin-btn-primary admin-btn-sm">Vše →</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{dashData.projects.active_projects.length === 0 && (
|
||||
<div className="dash-empty-row">Žádné aktivní projekty</div>
|
||||
)}
|
||||
{dashData.projects.active_projects.map((p: { id: number; name: string; customer_name: string | null }) => (
|
||||
<Link key={p.id} to={`/projects/${p.id}`} className="dash-project-row">
|
||||
<div className="dash-project-name">{p.name}</div>
|
||||
{p.customer_name && <div className="dash-project-customer">{p.customer_name}</div>}
|
||||
{/* Pravy sloupec: projekty + nabidky */}
|
||||
<div className="dash-right-col">
|
||||
{dashData?.projects && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Aktivní projekty</h2>
|
||||
<Link
|
||||
to="/projects"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
Vše →
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{dashData.projects.active_projects.length === 0 && (
|
||||
<div className="dash-empty-row">Žádné aktivní projekty</div>
|
||||
)}
|
||||
{dashData.projects.active_projects.map(
|
||||
(p: {
|
||||
id: number;
|
||||
name: string;
|
||||
customer_name: string | null;
|
||||
}) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/projects/${p.id}`}
|
||||
className="dash-project-row"
|
||||
>
|
||||
<div className="dash-project-name">{p.name}</div>
|
||||
{p.customer_name && (
|
||||
<div className="dash-project-customer">
|
||||
{p.customer_name}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{dashData?.offers && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Nabídky</h2>
|
||||
<Link to="/offers" className="admin-btn admin-btn-primary admin-btn-sm">Zobrazit →</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
<div className="dash-stat-row">
|
||||
<span>Otevřené</span>
|
||||
<span className="admin-badge admin-badge-info">{dashData.offers.open_count}</span>
|
||||
{dashData?.offers && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Nabídky</h2>
|
||||
<Link
|
||||
to="/offers"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
Zobrazit →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="dash-stat-row">
|
||||
<span>Převedené na objednávku</span>
|
||||
<span className="admin-badge admin-badge-success">{dashData.offers.converted_count}</span>
|
||||
</div>
|
||||
<div className="dash-stat-row">
|
||||
<span>Prošlé</span>
|
||||
<span className="admin-badge admin-badge-warning">{dashData.offers.expired_count}</span>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
<div className="dash-stat-row">
|
||||
<span>Otevřené</span>
|
||||
<span className="admin-badge admin-badge-info">
|
||||
{dashData.offers.open_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dash-stat-row">
|
||||
<span>Převedené na objednávku</span>
|
||||
<span className="admin-badge admin-badge-success">
|
||||
{dashData.offers.converted_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dash-stat-row">
|
||||
<span>Prošlé</span>
|
||||
<span className="admin-badge admin-badge-warning">
|
||||
{dashData.offers.expired_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>}
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Profile + Sessions */}
|
||||
{!dashLoading && <div className="dash-bottom">
|
||||
<DashProfile
|
||||
totpEnabled={totpEnabled}
|
||||
totpLoading={totpLoading}
|
||||
totpSubmitting={totpSubmitting}
|
||||
onStart2FASetup={handleStart2FASetup}
|
||||
onConfirm2FA={handleConfirm2FA}
|
||||
onDisable2FA={handleDisable2FA}
|
||||
totpSecret={totpSecret}
|
||||
totpQrUri={totpQrUri}
|
||||
totpCode={totpCode}
|
||||
setTotpCode={setTotpCode}
|
||||
backupCodes={backupCodes}
|
||||
setBackupCodes={setBackupCodes}
|
||||
show2FASetup={show2FASetup}
|
||||
setShow2FASetup={setShow2FASetup}
|
||||
show2FADisable={show2FADisable}
|
||||
setShow2FADisable={setShow2FADisable}
|
||||
disableCode={disableCode}
|
||||
setDisableCode={setDisableCode}
|
||||
/>
|
||||
<DashSessions />
|
||||
</div>}
|
||||
{!dashLoading && (
|
||||
<div className="dash-bottom">
|
||||
<DashProfile
|
||||
totpEnabled={totpEnabled}
|
||||
totpLoading={totpLoading}
|
||||
totpSubmitting={totpSubmitting}
|
||||
onStart2FASetup={handleStart2FASetup}
|
||||
onConfirm2FA={handleConfirm2FA}
|
||||
onDisable2FA={handleDisable2FA}
|
||||
totpSecret={totpSecret}
|
||||
totpQrUri={totpQrUri}
|
||||
totpCode={totpCode}
|
||||
setTotpCode={setTotpCode}
|
||||
backupCodes={backupCodes}
|
||||
setBackupCodes={setBackupCodes}
|
||||
show2FASetup={show2FASetup}
|
||||
setShow2FASetup={setShow2FASetup}
|
||||
show2FADisable={show2FADisable}
|
||||
setShow2FADisable={setShow2FADisable}
|
||||
disableCode={disableCode}
|
||||
setDisableCode={setDisableCode}
|
||||
/>
|
||||
<DashSessions />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,81 +1,87 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
|
||||
import apiFetch from '../utils/api'
|
||||
import { czechPlural } from '../utils/formatters'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import FormField from '../components/FormField'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||
import apiFetch from "../utils/api";
|
||||
import { czechPlural } from "../utils/formatters";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import FormField from "../components/FormField";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const leaveTypeLabels: Record<string, string> = {
|
||||
vacation: 'Dovolená',
|
||||
sick: 'Nemoc',
|
||||
unpaid: 'Neplacené volno'
|
||||
}
|
||||
vacation: "Dovolená",
|
||||
sick: "Nemoc",
|
||||
unpaid: "Neplacené volno",
|
||||
};
|
||||
|
||||
const leaveTypeClasses: Record<string, string> = {
|
||||
vacation: 'badge-vacation',
|
||||
sick: 'badge-sick',
|
||||
unpaid: 'badge-unpaid'
|
||||
}
|
||||
vacation: "badge-vacation",
|
||||
sick: "badge-sick",
|
||||
unpaid: "badge-unpaid",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Čeká na schválení',
|
||||
approved: 'Schváleno',
|
||||
rejected: 'Zamítnuto',
|
||||
cancelled: 'Zrušeno'
|
||||
}
|
||||
pending: "Čeká na schválení",
|
||||
approved: "Schváleno",
|
||||
rejected: "Zamítnuto",
|
||||
cancelled: "Zrušeno",
|
||||
};
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
pending: 'badge-pending',
|
||||
approved: 'badge-approved',
|
||||
rejected: 'badge-rejected',
|
||||
cancelled: 'badge-cancelled'
|
||||
}
|
||||
pending: "badge-pending",
|
||||
approved: "badge-approved",
|
||||
rejected: "badge-rejected",
|
||||
cancelled: "badge-cancelled",
|
||||
};
|
||||
|
||||
interface RawLeaveRequest {
|
||||
id: number
|
||||
leave_type: string
|
||||
date_from: string
|
||||
date_to: string
|
||||
total_days: number
|
||||
total_hours: number
|
||||
status: string
|
||||
notes?: string
|
||||
reviewer_note?: string
|
||||
created_at: string
|
||||
reviewed_at?: string
|
||||
users_leave_requests_user_idTousers?: { first_name: string; last_name: string }
|
||||
users_leave_requests_reviewer_idTousers?: { first_name: string; last_name: string } | null
|
||||
id: number;
|
||||
leave_type: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
total_days: number;
|
||||
total_hours: number;
|
||||
status: string;
|
||||
notes?: string;
|
||||
reviewer_note?: string;
|
||||
created_at: string;
|
||||
reviewed_at?: string;
|
||||
users_leave_requests_user_idTousers?: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
users_leave_requests_reviewer_idTousers?: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface LeaveRequest {
|
||||
id: number
|
||||
employee_name: string
|
||||
leave_type: string
|
||||
date_from: string
|
||||
date_to: string
|
||||
total_days: number
|
||||
total_hours: number
|
||||
status: string
|
||||
notes?: string
|
||||
reviewer_name?: string
|
||||
reviewer_note?: string
|
||||
created_at: string
|
||||
reviewed_at?: string
|
||||
id: number;
|
||||
employee_name: string;
|
||||
leave_type: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
total_days: number;
|
||||
total_hours: number;
|
||||
status: string;
|
||||
notes?: string;
|
||||
reviewer_name?: string;
|
||||
reviewer_note?: string;
|
||||
created_at: string;
|
||||
reviewed_at?: string;
|
||||
}
|
||||
|
||||
function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
|
||||
const user = raw.users_leave_requests_user_idTousers
|
||||
const reviewer = raw.users_leave_requests_reviewer_idTousers
|
||||
const user = raw.users_leave_requests_user_idTousers;
|
||||
const reviewer = raw.users_leave_requests_reviewer_idTousers;
|
||||
return {
|
||||
id: raw.id,
|
||||
employee_name: user ? `${user.first_name} ${user.last_name}` : 'Neznámý',
|
||||
employee_name: user ? `${user.first_name} ${user.last_name}` : "Neznámý",
|
||||
leave_type: raw.leave_type,
|
||||
date_from: raw.date_from,
|
||||
date_to: raw.date_to,
|
||||
@@ -83,152 +89,196 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
|
||||
total_hours: raw.total_hours,
|
||||
status: raw.status,
|
||||
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,
|
||||
created_at: raw.created_at,
|
||||
reviewed_at: raw.reviewed_at,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function LeaveApproval() {
|
||||
const { hasPermission } = useAuth()
|
||||
const alert = useAlert()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'processed'>('pending')
|
||||
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>([])
|
||||
const [approveModal, setApproveModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
|
||||
const [rejectModal, setRejectModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
|
||||
const [rejectNote, setRejectNote] = useState('')
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const { hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"pending" | "processed">(
|
||||
"pending",
|
||||
);
|
||||
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>(
|
||||
[],
|
||||
);
|
||||
const [approveModal, setApproveModal] = useState<{
|
||||
open: boolean;
|
||||
request: LeaveRequest | null;
|
||||
}>({ open: false, request: null });
|
||||
const [rejectModal, setRejectModal] = useState<{
|
||||
open: boolean;
|
||||
request: LeaveRequest | null;
|
||||
}>({ open: false, request: null });
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useModalLock(rejectModal.open)
|
||||
useModalLock(rejectModal.open);
|
||||
|
||||
const fetchPending = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests?status=pending`)
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/leave-requests?status=pending`,
|
||||
);
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest)
|
||||
setPendingRequests(mapped)
|
||||
setPendingCount(result.pagination?.total ?? mapped.length)
|
||||
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest);
|
||||
setPendingRequests(mapped);
|
||||
setPendingCount(result.pagination?.total ?? mapped.length);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst žádosti')
|
||||
alert.error("Nepodařilo se načíst žádosti");
|
||||
}
|
||||
}, [alert])
|
||||
}, [alert]);
|
||||
|
||||
const fetchProcessed = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests?status=approved`)
|
||||
if (response.status === 401) return
|
||||
const resultApproved = await response.json()
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/leave-requests?status=approved`,
|
||||
);
|
||||
if (response.status === 401) return;
|
||||
const resultApproved = await response.json();
|
||||
|
||||
const response2 = await apiFetch(`${API_BASE}/leave-requests?status=rejected`)
|
||||
if (response2.status === 401) return
|
||||
const resultRejected = await response2.json()
|
||||
const response2 = await apiFetch(
|
||||
`${API_BASE}/leave-requests?status=rejected`,
|
||||
);
|
||||
if (response2.status === 401) return;
|
||||
const resultRejected = await response2.json();
|
||||
|
||||
const all = [
|
||||
...(resultApproved.success ? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest) : []),
|
||||
...(resultRejected.success ? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest) : [])
|
||||
].sort((a: LeaveRequest, b: LeaveRequest) => new Date(b.reviewed_at!).getTime() - new Date(a.reviewed_at!).getTime())
|
||||
...(resultApproved.success
|
||||
? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest)
|
||||
: []),
|
||||
...(resultRejected.success
|
||||
? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest)
|
||||
: []),
|
||||
].sort(
|
||||
(a: LeaveRequest, b: LeaveRequest) =>
|
||||
new Date(b.reviewed_at!).getTime() -
|
||||
new Date(a.reviewed_at!).getTime(),
|
||||
);
|
||||
|
||||
setProcessedRequests(all)
|
||||
setProcessedRequests(all);
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst vyřízené žádosti')
|
||||
alert.error("Nepodařilo se načíst vyřízené žádosti");
|
||||
}
|
||||
}, [alert])
|
||||
}, [alert]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchPending().finally(() => setLoading(false))
|
||||
}, [fetchPending])
|
||||
setLoading(true);
|
||||
fetchPending().finally(() => setLoading(false));
|
||||
}, [fetchPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'processed' && processedRequests.length === 0) {
|
||||
fetchProcessed()
|
||||
if (activeTab === "processed" && processedRequests.length === 0) {
|
||||
fetchProcessed();
|
||||
}
|
||||
}, [activeTab, processedRequests.length, fetchProcessed])
|
||||
}, [activeTab, processedRequests.length, fetchProcessed]);
|
||||
|
||||
if (!hasPermission('attendance.approve')) return <Forbidden />
|
||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||
|
||||
const handleApprove = async () => {
|
||||
setProcessing(true)
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests/${approveModal.request!.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'approved' })
|
||||
})
|
||||
if (response.status === 401) return
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/leave-requests/${approveModal.request!.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
},
|
||||
);
|
||||
if (response.status === 401) return;
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setApproveModal({ open: false, request: null })
|
||||
await fetchPending()
|
||||
setProcessedRequests([])
|
||||
alert.success('Žádost byla schválena')
|
||||
setApproveModal({ open: false, request: null });
|
||||
await fetchPending();
|
||||
setProcessedRequests([]);
|
||||
alert.success("Žádost byla schválena");
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectNote.trim()) {
|
||||
alert.error('Důvod zamítnutí je povinný')
|
||||
return
|
||||
alert.error("Důvod zamítnutí je povinný");
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true)
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests/${rejectModal.request!.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'rejected', reviewer_note: rejectNote })
|
||||
})
|
||||
if (response.status === 401) return
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/leave-requests/${rejectModal.request!.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: "rejected",
|
||||
reviewer_note: rejectNote,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (response.status === 401) return;
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setRejectModal({ open: false, request: null })
|
||||
setRejectNote('')
|
||||
await fetchPending()
|
||||
setProcessedRequests([])
|
||||
alert.success('Žádost byla zamítnuta')
|
||||
setRejectModal({ open: false, request: null });
|
||||
setRejectNote("");
|
||||
await fetchPending();
|
||||
setProcessedRequests([]);
|
||||
alert.success("Žádost byla zamítnuta");
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3 mb-2" />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</div>
|
||||
@@ -236,7 +286,7 @@ export default function LeaveApproval() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -251,9 +301,8 @@ export default function LeaveApproval() {
|
||||
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pendingCount > 0
|
||||
? `${pendingCount} ${czechPlural(pendingCount, 'žádost čeká', 'žádosti čekají', 'žádostí čeká')} na schválení`
|
||||
: 'Žádné čekající žádosti'
|
||||
}
|
||||
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
|
||||
: "Žádné čekající žádosti"}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -266,19 +315,26 @@ export default function LeaveApproval() {
|
||||
>
|
||||
<div className="offers-tabs mb-6">
|
||||
<button
|
||||
className={`offers-tab ${activeTab === 'pending' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`offers-tab ${activeTab === "pending" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("pending")}
|
||||
>
|
||||
Ke schválení
|
||||
{pendingCount > 0 && (
|
||||
<span className="admin-badge badge-pending" style={{ marginLeft: '0.5rem', fontSize: '0.7rem', padding: '0.15rem 0.5rem' }}>
|
||||
<span
|
||||
className="admin-badge badge-pending"
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.7rem",
|
||||
padding: "0.15rem 0.5rem",
|
||||
}}
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`offers-tab ${activeTab === 'processed' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('processed')}
|
||||
className={`offers-tab ${activeTab === "processed" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("processed")}
|
||||
>
|
||||
Vyřízené
|
||||
</button>
|
||||
@@ -286,7 +342,7 @@ export default function LeaveApproval() {
|
||||
</motion.div>
|
||||
|
||||
{/* Pending Tab */}
|
||||
{activeTab === 'pending' && (
|
||||
{activeTab === "pending" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -296,7 +352,15 @@ export default function LeaveApproval() {
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted mb-4">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-muted mb-4"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
@@ -305,43 +369,100 @@ export default function LeaveApproval() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
>
|
||||
{pendingRequests.map((req) => (
|
||||
<div key={req.id} className="admin-card">
|
||||
<div className="admin-card-body" style={{ padding: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div
|
||||
className="admin-card-body"
|
||||
style={{ padding: "1.25rem" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex-row-gap mb-2">
|
||||
<strong style={{ fontSize: '1rem' }}>{req.employee_name}</strong>
|
||||
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
|
||||
<strong style={{ fontSize: "1rem" }}>
|
||||
{req.employee_name}
|
||||
</strong>
|
||||
<span
|
||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
||||
>
|
||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-secondary" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', fontSize: '0.875rem' }}>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1.5rem",
|
||||
flexWrap: "wrap",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{formatDate(req.date_from)}</strong> — <strong>{formatDate(req.date_to)}</strong>
|
||||
<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>{req.total_days} {czechPlural(req.total_days, 'den', 'dny', 'dnů')} ({req.total_hours}h)</span>
|
||||
<span className="text-muted">Podáno: {formatDatetime(req.created_at)}</span>
|
||||
</div>
|
||||
{req.notes && (
|
||||
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.875rem', fontStyle: 'italic' }}>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{req.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setApproveModal({ open: true, request: req })}
|
||||
onClick={() =>
|
||||
setApproveModal({ open: true, request: req })
|
||||
}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectModal({ open: true, request: req })}
|
||||
onClick={() =>
|
||||
setRejectModal({ open: true, request: req })
|
||||
}
|
||||
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
|
||||
</button>
|
||||
@@ -356,7 +477,7 @@ export default function LeaveApproval() {
|
||||
)}
|
||||
|
||||
{/* Processed Tab */}
|
||||
{activeTab === 'processed' && (
|
||||
{activeTab === "processed" && (
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -387,29 +508,46 @@ export default function LeaveApproval() {
|
||||
<tbody>
|
||||
{processedRequests.map((req) => (
|
||||
<tr key={req.id}>
|
||||
<td><strong>{req.employee_name}</strong></td>
|
||||
<td>
|
||||
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
|
||||
<strong>{req.employee_name}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
|
||||
>
|
||||
{leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">{formatDate(req.date_from)}</td>
|
||||
<td className="admin-mono">{formatDate(req.date_to)}</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(req.date_from)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(req.date_to)}
|
||||
</td>
|
||||
<td className="admin-mono">{req.total_days}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
|
||||
<span
|
||||
className={`admin-badge ${statusClasses[req.status] || ""}`}
|
||||
>
|
||||
{statusLabels[req.status] || req.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{req.reviewer_name || '—'}</td>
|
||||
<td style={{ maxWidth: '200px' }}>
|
||||
<td>{req.reviewer_name || "—"}</td>
|
||||
<td style={{ maxWidth: "200px" }}>
|
||||
{req.reviewer_note ? (
|
||||
<span title={req.reviewer_note}>
|
||||
{req.reviewer_note.length > 40 ? `${req.reviewer_note.substring(0, 40)}...` : req.reviewer_note}
|
||||
{req.reviewer_note.length > 40
|
||||
? `${req.reviewer_note.substring(0, 40)}...`
|
||||
: req.reviewer_note}
|
||||
</span>
|
||||
) : '—'}
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
|
||||
<td
|
||||
className="admin-mono"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{formatDatetime(req.reviewed_at)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -428,9 +566,10 @@ export default function LeaveApproval() {
|
||||
onClose={() => setApproveModal({ open: false, request: null })}
|
||||
onConfirm={handleApprove}
|
||||
title="Schválit žádost"
|
||||
message={approveModal.request
|
||||
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, 'den', 'dny', 'dnů')} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ''} pro ${approveModal.request.employee_name}?`
|
||||
: ''
|
||||
message={
|
||||
approveModal.request
|
||||
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, "den", "dny", "dnů")} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ""} pro ${approveModal.request.employee_name}?`
|
||||
: ""
|
||||
}
|
||||
confirmText="Schválit"
|
||||
type="info"
|
||||
@@ -447,7 +586,13 @@ export default function LeaveApproval() {
|
||||
exit={{ opacity: 0 }}
|
||||
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
|
||||
className="admin-modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -461,8 +606,11 @@ export default function LeaveApproval() {
|
||||
<div className="admin-modal-body">
|
||||
{rejectModal.request && (
|
||||
<p className="text-secondary mb-4">
|
||||
{rejectModal.request.employee_name} — {leaveTypeLabels[rejectModal.request.leave_type]},{' '}
|
||||
{formatDate(rejectModal.request.date_from)} — {formatDate(rejectModal.request.date_to)} ({rejectModal.request.total_days} dnů)
|
||||
{rejectModal.request.employee_name} —{" "}
|
||||
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
|
||||
{formatDate(rejectModal.request.date_from)} —{" "}
|
||||
{formatDate(rejectModal.request.date_to)} (
|
||||
{rejectModal.request.total_days} dnů)
|
||||
</p>
|
||||
)}
|
||||
<FormField label="Důvod zamítnutí" required>
|
||||
@@ -479,7 +627,10 @@ export default function LeaveApproval() {
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }}
|
||||
onClick={() => {
|
||||
setRejectModal({ open: false, request: null });
|
||||
setRejectNote("");
|
||||
}}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={processing}
|
||||
>
|
||||
@@ -491,7 +642,7 @@ export default function LeaveApproval() {
|
||||
disabled={processing || !rejectNote.trim()}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
{processing ? 'Zpracování...' : 'Zamítnout'}
|
||||
{processing ? "Zpracování..." : "Zamítnout"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -499,5 +650,5 @@ export default function LeaveApproval() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,142 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { motion } from 'framer-motion'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
|
||||
import apiFetch from '../utils/api'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { motion } from "framer-motion";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||
import apiFetch from "../utils/api";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const leaveTypeLabels: Record<string, string> = {
|
||||
vacation: 'Dovolená',
|
||||
sick: 'Nemoc',
|
||||
unpaid: 'Neplacené volno'
|
||||
}
|
||||
vacation: "Dovolená",
|
||||
sick: "Nemoc",
|
||||
unpaid: "Neplacené volno",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Čeká na schválení',
|
||||
approved: 'Schváleno',
|
||||
rejected: 'Zamítnuto',
|
||||
cancelled: 'Zrušeno'
|
||||
}
|
||||
pending: "Čeká na schválení",
|
||||
approved: "Schváleno",
|
||||
rejected: "Zamítnuto",
|
||||
cancelled: "Zrušeno",
|
||||
};
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
pending: 'badge-pending',
|
||||
approved: 'badge-approved',
|
||||
rejected: 'badge-rejected',
|
||||
cancelled: 'badge-cancelled'
|
||||
}
|
||||
pending: "badge-pending",
|
||||
approved: "badge-approved",
|
||||
rejected: "badge-rejected",
|
||||
cancelled: "badge-cancelled",
|
||||
};
|
||||
|
||||
const leaveTypeClasses: Record<string, string> = {
|
||||
vacation: 'badge-vacation',
|
||||
sick: 'badge-sick',
|
||||
unpaid: 'badge-unpaid'
|
||||
}
|
||||
vacation: "badge-vacation",
|
||||
sick: "badge-sick",
|
||||
unpaid: "badge-unpaid",
|
||||
};
|
||||
|
||||
interface LeaveRequest {
|
||||
id: number
|
||||
leave_type: string
|
||||
date_from: string
|
||||
date_to: string
|
||||
total_days: number
|
||||
total_hours: number
|
||||
status: string
|
||||
notes?: string
|
||||
reviewer_note?: string
|
||||
created_at: string
|
||||
id: number;
|
||||
leave_type: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
total_days: number;
|
||||
total_hours: number;
|
||||
status: string;
|
||||
notes?: string;
|
||||
reviewer_note?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function LeaveRequests() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [requests, setRequests] = useState<LeaveRequest[]>([])
|
||||
const [cancelModal, setCancelModal] = useState<{ open: boolean; id: number | null }>({ open: false, id: null })
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requests, setRequests] = useState<LeaveRequest[]>([]);
|
||||
const [cancelModal, setCancelModal] = useState<{
|
||||
open: boolean;
|
||||
id: number | null;
|
||||
}>({ open: false, id: null });
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests`)
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests`);
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setRequests(result.data)
|
||||
setRequests(result.data);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst žádosti')
|
||||
alert.error("Nepodařilo se načíst žádosti");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [alert])
|
||||
}, [alert]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests()
|
||||
}, [fetchRequests])
|
||||
fetchRequests();
|
||||
}, [fetchRequests]);
|
||||
|
||||
if (!hasPermission('attendance.record')) return <Forbidden />
|
||||
if (!hasPermission("attendance.record")) return <Forbidden />;
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelling(true)
|
||||
setCancelling(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests/${cancelModal.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (response.status === 401) return
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/leave-requests/${cancelModal.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (response.status === 401) return;
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setCancelModal({ open: false, id: null })
|
||||
await fetchRequests()
|
||||
alert.success(result.message)
|
||||
setCancelModal({ open: false, id: null });
|
||||
await fetchRequests();
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
||||
</div>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "140px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3 mb-2" />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</div>
|
||||
@@ -127,26 +145,34 @@ export default function LeaveRequests() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderNoteCell(req: LeaveRequest) {
|
||||
const truncate = (text: string) => text.length > 40 ? `${text.substring(0, 40)}...` : text
|
||||
if (req.status === 'rejected' && req.reviewer_note) {
|
||||
const truncate = (text: string) =>
|
||||
text.length > 40 ? `${text.substring(0, 40)}...` : text;
|
||||
if (req.status === "rejected" && req.reviewer_note) {
|
||||
return (
|
||||
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }} title={req.reviewer_note}>
|
||||
<span
|
||||
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
|
||||
title={req.reviewer_note}
|
||||
>
|
||||
{truncate(req.reviewer_note)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (req.notes) {
|
||||
return (
|
||||
<span className="text-secondary" style={{ fontSize: '0.875rem' }} title={req.notes}>
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
title={req.notes}
|
||||
>
|
||||
{truncate(req.notes)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
return <span className="text-muted">—</span>
|
||||
return <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -173,7 +199,16 @@ export default function LeaveRequests() {
|
||||
{requests.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
@@ -181,7 +216,7 @@ export default function LeaveRequests() {
|
||||
</svg>
|
||||
</div>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
@@ -205,29 +240,40 @@ export default function LeaveRequests() {
|
||||
{requests.map((req) => (
|
||||
<tr key={req.id}>
|
||||
<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}
|
||||
</span>
|
||||
</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">{req.total_days}</td>
|
||||
<td className="admin-mono">{req.total_hours}h</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${statusClasses[req.status] || ''}`}>
|
||||
<span
|
||||
className={`admin-badge ${statusClasses[req.status] || ""}`}
|
||||
>
|
||||
{statusLabels[req.status] || req.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: '200px' }}>
|
||||
<td style={{ maxWidth: "200px" }}>
|
||||
{renderNoteCell(req)}
|
||||
</td>
|
||||
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
|
||||
<td
|
||||
className="admin-mono"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{formatDatetime(req.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
{req.status === 'pending' && (
|
||||
{req.status === "pending" && (
|
||||
<button
|
||||
onClick={() => setCancelModal({ open: true, id: req.id })}
|
||||
onClick={() =>
|
||||
setCancelModal({ open: true, id: req.id })
|
||||
}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
Zrušit
|
||||
@@ -254,5 +300,5 @@ export default function LeaveRequests() {
|
||||
loading={cancelling}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useTheme } from '../../context/ThemeContext'
|
||||
import { shouldShowSessionExpiredAlert, shouldShowLogoutAlert } from '../utils/api'
|
||||
import FormField from '../components/FormField'
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import {
|
||||
shouldShowSessionExpiredAlert,
|
||||
shouldShowLogoutAlert,
|
||||
} from "../utils/api";
|
||||
import FormField from "../components/FormField";
|
||||
|
||||
export default function Login() {
|
||||
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth()
|
||||
const alert = useAlert()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [shake, setShake] = useState(false)
|
||||
const [animatingOut, setAnimatingOut] = useState(false)
|
||||
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
|
||||
const alert = useAlert();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shake, setShake] = useState(false);
|
||||
const [animatingOut, setAnimatingOut] = useState(false);
|
||||
|
||||
// 2FA state
|
||||
const [show2FA, setShow2FA] = useState(false)
|
||||
const [loginToken, setLoginToken] = useState<string | null>(null)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [useBackupCode, setUseBackupCode] = useState(false)
|
||||
const totpInputRef = useRef<HTMLInputElement>(null)
|
||||
const [show2FA, setShow2FA] = useState(false);
|
||||
const [loginToken, setLoginToken] = useState<string | null>(null);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowSessionExpiredAlert()) {
|
||||
alert.warning('Vaše relace vypršela. Přihlaste se prosím znovu.')
|
||||
alert.warning("Vaše relace vypršela. Přihlaste se prosím znovu.");
|
||||
} else if (shouldShowLogoutAlert()) {
|
||||
alert.success('Byli jste úspěšně odhlášeni.')
|
||||
alert.success("Byli jste úspěšně odhlášeni.");
|
||||
}
|
||||
}, [alert])
|
||||
}, [alert]);
|
||||
|
||||
// Auto-focus TOTP input
|
||||
useEffect(() => {
|
||||
if (show2FA && totpInputRef.current) {
|
||||
totpInputRef.current.focus()
|
||||
totpInputRef.current.focus();
|
||||
}
|
||||
}, [show2FA, useBackupCode])
|
||||
}, [show2FA, useBackupCode]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
@@ -47,76 +50,83 @@ export default function Login() {
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated && !animatingOut) {
|
||||
return <Navigate to="/" replace />
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(username, password, remember)
|
||||
const result = await login(username, password, remember);
|
||||
|
||||
if (result.requires2FA) {
|
||||
setLoginToken(result.loginToken ?? null)
|
||||
setShow2FA(true)
|
||||
setTotpCode('')
|
||||
setLoading(false)
|
||||
setLoginToken(result.loginToken ?? null);
|
||||
setShow2FA(true);
|
||||
setTotpCode("");
|
||||
setLoading(false);
|
||||
} else if (!result.success) {
|
||||
alert.error(result.error ?? 'Chyba přihlášení')
|
||||
setShake(true)
|
||||
setTimeout(() => setShake(false), 500)
|
||||
setLoading(false)
|
||||
alert.error(result.error ?? "Chyba přihlášení");
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 500);
|
||||
setLoading(false);
|
||||
} else {
|
||||
alert.success('Úspěšně přihlášeno')
|
||||
setAnimatingOut(true)
|
||||
setTimeout(() => setAnimatingOut(false), 400)
|
||||
alert.success("Úspěšně přihlášeno");
|
||||
setAnimatingOut(true);
|
||||
setTimeout(() => setAnimatingOut(false), 400);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle2FASubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!totpCode.trim()) return
|
||||
e.preventDefault();
|
||||
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) {
|
||||
alert.error(result.error ?? 'Chyba ověření')
|
||||
setShake(true)
|
||||
setTimeout(() => setShake(false), 500)
|
||||
setTotpCode('')
|
||||
if (totpInputRef.current) totpInputRef.current.focus()
|
||||
setLoading(false)
|
||||
alert.error(result.error ?? "Chyba ověření");
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 500);
|
||||
setTotpCode("");
|
||||
if (totpInputRef.current) totpInputRef.current.focus();
|
||||
setLoading(false);
|
||||
} else {
|
||||
alert.success('Úspěšně přihlášeno')
|
||||
setAnimatingOut(true)
|
||||
setTimeout(() => setAnimatingOut(false), 400)
|
||||
alert.success("Úspěšně přihlášeno");
|
||||
setAnimatingOut(true);
|
||||
setTimeout(() => setAnimatingOut(false), 400);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setShow2FA(false)
|
||||
setLoginToken(null)
|
||||
setTotpCode('')
|
||||
setUseBackupCode(false)
|
||||
}
|
||||
setShow2FA(false);
|
||||
setLoginToken(null);
|
||||
setTotpCode("");
|
||||
setUseBackupCode(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="admin-login"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={animatingOut
|
||||
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
|
||||
: { scale: 1, opacity: 1, filter: 'none' }
|
||||
animate={
|
||||
animatingOut
|
||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||
: { scale: 1, opacity: 1, filter: "none" }
|
||||
}
|
||||
transition={animatingOut
|
||||
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
||||
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
||||
transition={
|
||||
animatingOut
|
||||
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
||||
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
||||
}
|
||||
>
|
||||
<div className="bg-orb bg-orb-1" />
|
||||
@@ -125,16 +135,34 @@ export default function Login() {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
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" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<span
|
||||
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
@@ -146,19 +174,25 @@ export default function Login() {
|
||||
key="login"
|
||||
className="admin-login-card"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={shake
|
||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||
: { opacity: 1, y: 0 }
|
||||
animate={
|
||||
shake
|
||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||
: { opacity: 1, y: 0 }
|
||||
}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={shake
|
||||
? { x: { duration: 0.5, ease: 'easeOut' } }
|
||||
: { duration: 0.3 }
|
||||
transition={
|
||||
shake
|
||||
? { x: { duration: 0.5, ease: "easeOut" } }
|
||||
: { duration: 0.3 }
|
||||
}
|
||||
>
|
||||
<div className="admin-login-header">
|
||||
<img
|
||||
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
|
||||
src={
|
||||
theme === "dark"
|
||||
? "/images/logo-dark.png"
|
||||
: "/images/logo-light.png"
|
||||
}
|
||||
alt="Logo"
|
||||
className="admin-login-logo"
|
||||
/>
|
||||
@@ -206,15 +240,18 @@ export default function Login() {
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{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řihlásit se'
|
||||
"Přihlásit se"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
@@ -224,19 +261,28 @@ export default function Login() {
|
||||
key="2fa"
|
||||
className="admin-login-card"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={shake
|
||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||
: { opacity: 1, y: 0 }
|
||||
animate={
|
||||
shake
|
||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
||||
: { opacity: 1, y: 0 }
|
||||
}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={shake
|
||||
? { x: { duration: 0.5, ease: 'easeOut' } }
|
||||
: { duration: 0.3 }
|
||||
transition={
|
||||
shake
|
||||
? { x: { duration: 0.5, ease: "easeOut" } }
|
||||
: { duration: 0.3 }
|
||||
}
|
||||
>
|
||||
<div className="admin-login-header">
|
||||
<div className="admin-login-2fa-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
@@ -244,36 +290,43 @@ export default function Login() {
|
||||
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
|
||||
<p className="admin-login-subtitle">
|
||||
{useBackupCode
|
||||
? 'Zadejte jeden ze záložních kódů'
|
||||
: 'Zadejte 6místný kód z autentizační aplikace'
|
||||
}
|
||||
? "Zadejte jeden ze záložních kódů"
|
||||
: "Zadejte 6místný kód z autentizační aplikace"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handle2FASubmit} className="admin-form">
|
||||
<FormField label={useBackupCode ? 'Záložní kód' : 'Ověřovací kód'}>
|
||||
<FormField
|
||||
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
|
||||
>
|
||||
<input
|
||||
ref={totpInputRef}
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode={useBackupCode ? 'text' : 'numeric'}
|
||||
pattern={useBackupCode ? undefined : '[0-9]*'}
|
||||
inputMode={useBackupCode ? "text" : "numeric"}
|
||||
pattern={useBackupCode ? undefined : "[0-9]*"}
|
||||
maxLength={useBackupCode ? 8 : 6}
|
||||
value={totpCode}
|
||||
onChange={(e) => {
|
||||
const val = useBackupCode ? e.target.value : e.target.value.replace(/\D/g, '')
|
||||
setTotpCode(val)
|
||||
const val = useBackupCode
|
||||
? e.target.value
|
||||
: e.target.value.replace(/\D/g, "");
|
||||
setTotpCode(val);
|
||||
}}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
className="admin-form-input"
|
||||
placeholder={useBackupCode ? 'XXXXXXXX' : '000000'}
|
||||
style={useBackupCode ? {} : {
|
||||
textAlign: 'center',
|
||||
fontSize: '1.5rem',
|
||||
letterSpacing: '0.5rem',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
|
||||
style={
|
||||
useBackupCode
|
||||
? {}
|
||||
: {
|
||||
textAlign: "center",
|
||||
fontSize: "1.5rem",
|
||||
letterSpacing: "0.5rem",
|
||||
fontFamily: "monospace",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -281,34 +334,54 @@ export default function Login() {
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{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ěřit'
|
||||
"Ověřit"
|
||||
)}
|
||||
</button>
|
||||
</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
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode)
|
||||
setTotpCode('')
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setTotpCode("");
|
||||
}}
|
||||
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
|
||||
onClick={handleBack}
|
||||
className="admin-back-link"
|
||||
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
← Zpět na přihlášení
|
||||
</button>
|
||||
@@ -317,5 +390,5 @@ export default function Login() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,53 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<motion.div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: '60vh', justifyContent: 'center' }}
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<div
|
||||
className="admin-empty-icon"
|
||||
style={{ width: 80, height: 80, marginBottom: "1.5rem" }}
|
||||
>
|
||||
<svg
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "0.5rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
404
|
||||
</h2>
|
||||
<p>Stránka nebyla nalezena.</p>
|
||||
<Link to="/" className="admin-btn admin-btn-primary" style={{ marginTop: '0.5rem' }}>
|
||||
<Link
|
||||
to="/"
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
>
|
||||
Zpět na Dashboard
|
||||
</Link>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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
@@ -1,110 +1,140 @@
|
||||
import { useState } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { motion } from 'framer-motion'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import { useState } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
|
||||
import apiFetch from '../utils/api'
|
||||
import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import SortIcon from "../components/SortIcon";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useListData from "../hooks/useListData";
|
||||
import Pagination from "../components/Pagination";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
prijata: 'Přijatá',
|
||||
v_realizaci: 'V realizaci',
|
||||
dokoncena: 'Dokončená',
|
||||
stornovana: 'Stornována'
|
||||
}
|
||||
prijata: "Přijatá",
|
||||
v_realizaci: "V realizaci",
|
||||
dokoncena: "Dokončená",
|
||||
stornovana: "Stornována",
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
prijata: 'admin-badge-order-prijata',
|
||||
v_realizaci: 'admin-badge-order-realizace',
|
||||
dokoncena: 'admin-badge-order-dokoncena',
|
||||
stornovana: 'admin-badge-order-stornovana'
|
||||
}
|
||||
prijata: "admin-badge-order-prijata",
|
||||
v_realizaci: "admin-badge-order-realizace",
|
||||
dokoncena: "admin-badge-order-dokoncena",
|
||||
stornovana: "admin-badge-order-stornovana",
|
||||
};
|
||||
|
||||
interface Order {
|
||||
id: number
|
||||
order_number: string
|
||||
quotation_id: number
|
||||
quotation_number: string
|
||||
customer_name: string
|
||||
status: string
|
||||
created_at: string
|
||||
total: number
|
||||
currency: string
|
||||
invoice_id?: number
|
||||
id: number;
|
||||
order_number: string;
|
||||
quotation_id: number;
|
||||
quotation_number: string;
|
||||
customer_name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
invoice_id?: number;
|
||||
}
|
||||
|
||||
export default function Orders() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('order_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, order, handleSort, activeSort } = useTableSort("order_number");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; order: Order | null }>({ show: false, order: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFiles, setDeleteFiles] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
show: boolean;
|
||||
order: Order | null;
|
||||
}>({ show: false, order: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
|
||||
const { items: orders, loading, initialLoad, pagination, refetch: fetchData } = useListData('orders', {
|
||||
search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst objednávky'
|
||||
})
|
||||
const {
|
||||
items: orders,
|
||||
loading,
|
||||
initialLoad,
|
||||
pagination,
|
||||
refetch: fetchData,
|
||||
} = useListData("orders", {
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
errorMsg: "Nepodařilo se načíst objednávky",
|
||||
});
|
||||
|
||||
if (!hasPermission('orders.view')) return <Forbidden />
|
||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.order) return
|
||||
setDeleting(true)
|
||||
if (!deleteConfirm.order) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/orders/${deleteConfirm.order.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ delete_files: deleteFiles }),
|
||||
})
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/orders/${deleteConfirm.order.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ delete_files: deleteFiles }),
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, order: null })
|
||||
setDeleteFiles(false)
|
||||
alert.success(result.message || 'Objednávka byla smazána')
|
||||
fetchData()
|
||||
setDeleteConfirm({ show: false, order: null });
|
||||
setDeleteFiles(false);
|
||||
alert.success(result.message || "Objednávka byla smazána");
|
||||
fetchData();
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se smazat objednávku')
|
||||
alert.error(result.error || "Nepodařilo se smazat objednávku");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoad) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
||||
</div>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "140px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</div>
|
||||
@@ -113,7 +143,7 @@ export default function Orders() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -127,7 +157,13 @@ export default function Orders() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Objednávky</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? orders.length} {czechPlural(pagination?.total ?? orders.length, 'objednávka', 'objednávky', 'objednávek')}
|
||||
{pagination?.total ?? orders.length}{" "}
|
||||
{czechPlural(
|
||||
pagination?.total ?? orders.length,
|
||||
"objednávka",
|
||||
"objednávky",
|
||||
"objednávek",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -137,14 +173,17 @@ export default function Orders() {
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s' }}
|
||||
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
|
||||
/>
|
||||
@@ -153,14 +192,23 @@ export default function Orders() {
|
||||
{orders.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<path d="M16 10a4 4 0 0 1-8 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nejsou žádné objednávky.</p>
|
||||
<p className="text-tertiary" style={{ fontSize: '0.875rem' }}>
|
||||
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
|
||||
Objednávky se vytvářejí z nabídek.
|
||||
</p>
|
||||
</div>
|
||||
@@ -169,16 +217,40 @@ export default function Orders() {
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('order_number')}>
|
||||
Číslo <SortIcon column="order_number" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("order_number")}
|
||||
>
|
||||
Číslo{" "}
|
||||
<SortIcon
|
||||
column="order_number"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th>Nabídka</th>
|
||||
<th>Zákazník</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
|
||||
Stav <SortIcon column="status" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
Stav{" "}
|
||||
<SortIcon
|
||||
column="status"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('created_at')}>
|
||||
Datum <SortIcon column="created_at" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("created_at")}
|
||||
>
|
||||
Datum{" "}
|
||||
<SortIcon
|
||||
column="created_at"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-right">Celkem</th>
|
||||
<th>Akce</th>
|
||||
@@ -193,55 +265,116 @@ export default function Orders() {
|
||||
</Link>
|
||||
</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}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{o.customer_name || '—'}</td>
|
||||
<td>{o.customer_name || "—"}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${STATUS_CLASSES[o.status] || ''}`}>
|
||||
<span
|
||||
className={`admin-badge ${STATUS_CLASSES[o.status] || ""}`}
|
||||
>
|
||||
{STATUS_LABELS[o.status] || o.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(o.created_at)}
|
||||
</td>
|
||||
<td className="admin-mono">{formatDate(o.created_at)}</td>
|
||||
<td className="admin-mono text-right fw-500">
|
||||
{formatCurrency(o.total, o.currency)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<Link to={`/orders/${o.id}`} className="admin-btn-icon" title="Detail" aria-label="Detail">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<Link
|
||||
to={`/orders/${o.id}`}
|
||||
className="admin-btn-icon"
|
||||
title="Detail"
|
||||
aria-label="Detail"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</Link>
|
||||
{o.invoice_id ? (
|
||||
<Link to={`/invoices/${o.invoice_id}`} className="admin-btn-icon accent" title="Zobrazit fakturu" aria-label="Zobrazit fakturu">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<Link
|
||||
to={`/invoices/${o.invoice_id}`}
|
||||
className="admin-btn-icon accent"
|
||||
title="Zobrazit fakturu"
|
||||
aria-label="Zobrazit fakturu"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<text x="12" y="16.5" textAnchor="middle" fill="currentColor" stroke="none" fontSize="9" fontWeight="700">F</text>
|
||||
</svg>
|
||||
</Link>
|
||||
) : hasPermission('invoices.create') && (
|
||||
<Link to={`/invoices/new?fromOrder=${o.id}`} className="admin-btn-icon" title="Vytvořit fakturu" aria-label="Vytvořit fakturu">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
fontSize="9"
|
||||
fontWeight="700"
|
||||
>
|
||||
F
|
||||
</text>
|
||||
</svg>
|
||||
</Link>
|
||||
) : (
|
||||
hasPermission("invoices.create") && (
|
||||
<Link
|
||||
to={`/invoices/new?fromOrder=${o.id}`}
|
||||
className="admin-btn-icon"
|
||||
title="Vytvořit fakturu"
|
||||
aria-label="Vytvořit fakturu"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{hasPermission('orders.delete') && (
|
||||
{hasPermission("orders.delete") && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, order: o })}
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, order: o })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
@@ -262,15 +395,20 @@ export default function Orders() {
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => {
|
||||
setDeleteConfirm({ show: false, order: null })
|
||||
setDeleteFiles(false)
|
||||
setDeleteConfirm({ show: false, order: null });
|
||||
setDeleteFiles(false);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat objednávku"
|
||||
message={
|
||||
<>
|
||||
Opravdu chcete smazat objednávku "{deleteConfirm.order?.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.
|
||||
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
|
||||
Opravdu chcete smazat objednávku "
|
||||
{deleteConfirm.order?.order_number}"? Bude smazán i přidružený
|
||||
projekt. Tato akce je nevratná.
|
||||
<label
|
||||
className="admin-form-checkbox"
|
||||
style={{ marginTop: "1rem", display: "flex" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
@@ -286,5 +424,5 @@ export default function Orders() {
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { motion } from 'framer-motion'
|
||||
import FormField from '../components/FormField'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import apiFetch from '../utils/api'
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { motion } from "framer-motion";
|
||||
import FormField from "../components/FormField";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface Customer {
|
||||
id: number
|
||||
name: string
|
||||
company_id?: string
|
||||
city?: string
|
||||
id: number;
|
||||
name: string;
|
||||
company_id?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
name: string
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProjectForm {
|
||||
project_number: string
|
||||
name: string
|
||||
customer_id: number | null
|
||||
customer_name: string
|
||||
start_date: string
|
||||
responsible_user_id: string
|
||||
project_number: string;
|
||||
name: string;
|
||||
customer_id: number | null;
|
||||
customer_name: string;
|
||||
start_date: string;
|
||||
responsible_user_id: string;
|
||||
}
|
||||
|
||||
export default function ProjectCreate() {
|
||||
const navigate = useNavigate()
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const navigate = useNavigate();
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const [form, setForm] = useState<ProjectForm>({
|
||||
project_number: '',
|
||||
name: '',
|
||||
project_number: "",
|
||||
name: "",
|
||||
customer_id: null,
|
||||
customer_name: '',
|
||||
start_date: new Date().toISOString().split('T')[0],
|
||||
responsible_user_id: ''
|
||||
})
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
|
||||
const [loadingNumber, setLoadingNumber] = useState(true)
|
||||
customer_name: "",
|
||||
start_date: new Date().toISOString().split("T")[0],
|
||||
responsible_user_id: "",
|
||||
});
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [loadingNumber, setLoadingNumber] = useState(true);
|
||||
|
||||
// Customer selector state
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [customerSearch, setCustomerSearch] = useState('')
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [customerSearch, setCustomerSearch] = useState("");
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
@@ -61,115 +61,142 @@ export default function ProjectCreate() {
|
||||
const [numRes, custRes, usersRes] = await Promise.all([
|
||||
apiFetch(`${API_BASE}/projects/next-number`),
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const rawUsers = Array.isArray(usersData.data) ? usersData.data : usersData.data?.items || []
|
||||
setUsers(rawUsers.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
|
||||
const rawUsers = Array.isArray(usersData.data)
|
||||
? usersData.data
|
||||
: usersData.data?.items || [];
|
||||
setUsers(
|
||||
rawUsers.map((u: any) => ({
|
||||
id: u.id,
|
||||
name:
|
||||
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
|
||||
u.username,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba při načítání dat')
|
||||
alert.error("Chyba při načítání dat");
|
||||
} finally {
|
||||
setLoadingNumber(false)
|
||||
setLoadingNumber(false);
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [alert])
|
||||
};
|
||||
load();
|
||||
}, [alert]);
|
||||
|
||||
// Customer filtering
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!customerSearch) return customers
|
||||
const q = customerSearch.toLowerCase()
|
||||
return customers.filter(c =>
|
||||
(c.name || '').toLowerCase().includes(q) ||
|
||||
(c.company_id || '').includes(customerSearch) ||
|
||||
(c.city || '').toLowerCase().includes(q)
|
||||
)
|
||||
}, [customers, customerSearch])
|
||||
if (!customerSearch) return customers;
|
||||
const q = customerSearch.toLowerCase();
|
||||
return customers.filter(
|
||||
(c) =>
|
||||
(c.name || "").toLowerCase().includes(q) ||
|
||||
(c.company_id || "").includes(customerSearch) ||
|
||||
(c.city || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [customers, customerSearch]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setShowCustomerDropdown(false)
|
||||
const handleClickOutside = () => setShowCustomerDropdown(false);
|
||||
if (showCustomerDropdown) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
document.addEventListener("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) => {
|
||||
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
|
||||
setErrors(prev => ({ ...prev, customer_id: undefined }))
|
||||
setCustomerSearch('')
|
||||
setShowCustomerDropdown(false)
|
||||
}
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
customer_id: customer.id,
|
||||
customer_name: customer.name,
|
||||
}));
|
||||
setErrors((prev) => ({ ...prev, customer_id: undefined }));
|
||||
setCustomerSearch("");
|
||||
setShowCustomerDropdown(false);
|
||||
};
|
||||
|
||||
const clearCustomer = () => {
|
||||
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
|
||||
}
|
||||
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
|
||||
};
|
||||
|
||||
const updateForm = (field: keyof ProjectForm, value: unknown) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }))
|
||||
}
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!form.name.trim()) newErrors.name = 'Název projektu je povinný'
|
||||
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
|
||||
setErrors(newErrors)
|
||||
if (Object.keys(newErrors).length > 0) return
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.name.trim()) newErrors.name = "Název projektu je povinný";
|
||||
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
setSaving(true)
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
customer_id: form.customer_id,
|
||||
start_date: form.start_date,
|
||||
project_number: form.project_number.trim(),
|
||||
responsible_user_id: form.responsible_user_id || null
|
||||
}
|
||||
responsible_user_id: form.responsible_user_id || null,
|
||||
};
|
||||
|
||||
const res = await apiFetch(`${API_BASE}/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const data = await res.json()
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
navigate(`/projects/${data.data.project_id}`, { state: { created: true } })
|
||||
navigate(`/projects/${data.data.project_id}`, {
|
||||
state: { created: true },
|
||||
});
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se vytvořit projekt')
|
||||
alert.error(data.error || "Nepodařilo se vytvořit projekt");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingNumber) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line w-1/2" />
|
||||
@@ -178,7 +205,7 @@ export default function ProjectCreate() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -190,8 +217,20 @@ export default function ProjectCreate() {
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div className="flex-row gap-4">
|
||||
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<Link
|
||||
to="/projects"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět"
|
||||
aria-label="Zpět"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
@@ -206,7 +245,7 @@ export default function ProjectCreate() {
|
||||
disabled={saving}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
{saving ? 'Ukládám...' : 'Uložit'}
|
||||
{saving ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -216,7 +255,7 @@ export default function ProjectCreate() {
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ overflow: 'visible' }}
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Základní údaje</h3>
|
||||
@@ -226,7 +265,7 @@ export default function ProjectCreate() {
|
||||
<input
|
||||
type="text"
|
||||
value={form.project_number}
|
||||
onChange={(e) => updateForm('project_number', e.target.value)}
|
||||
onChange={(e) => updateForm("project_number", e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Ponechte prázdné pro automatické"
|
||||
/>
|
||||
@@ -235,7 +274,7 @@ export default function ProjectCreate() {
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Název projektu"
|
||||
/>
|
||||
@@ -247,18 +286,38 @@ export default function ProjectCreate() {
|
||||
{form.customer_id ? (
|
||||
<div className="offers-customer-selected">
|
||||
<span>{form.customer_name}</span>
|
||||
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearCustomer}
|
||||
className="admin-btn-icon"
|
||||
title="Odebrat zákazníka"
|
||||
aria-label="Odebrat zákazníka"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="offers-customer-select"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={customerSearch}
|
||||
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
|
||||
onChange={(e) => {
|
||||
setCustomerSearch(e.target.value);
|
||||
setShowCustomerDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCustomerDropdown(true)}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat zákazníka..."
|
||||
@@ -270,7 +329,7 @@ export default function ProjectCreate() {
|
||||
Žádní zákazníci
|
||||
</div>
|
||||
) : (
|
||||
filteredCustomers.slice(0, 20).map(c => (
|
||||
filteredCustomers.slice(0, 20).map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="offers-customer-dropdown-item"
|
||||
@@ -290,7 +349,7 @@ export default function ProjectCreate() {
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.start_date}
|
||||
onChange={(val: string) => updateForm('start_date', val)}
|
||||
onChange={(val: string) => updateForm("start_date", val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -299,12 +358,16 @@ export default function ProjectCreate() {
|
||||
<FormField label="Zodpovědná osoba">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">— Nevybráno —</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
@@ -312,7 +375,6 @@ export default function ProjectCreate() {
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,292 +1,323 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import FormField from '../components/FormField'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import ProjectFileManager from '../components/ProjectFileManager'
|
||||
import apiFetch from '../utils/api'
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormField from "../components/FormField";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import ProjectFileManager from "../components/ProjectFileManager";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
aktivni: 'Aktivní',
|
||||
dokonceny: 'Dokončený',
|
||||
zruseny: 'Zrušený'
|
||||
}
|
||||
aktivni: "Aktivní",
|
||||
dokonceny: "Dokončený",
|
||||
zruseny: "Zrušený",
|
||||
};
|
||||
|
||||
function formatNoteDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
const day = d.getDate()
|
||||
const month = d.getMonth() + 1
|
||||
const year = d.getFullYear()
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const mins = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${day}. ${month}. ${year} ${hours}:${mins}`
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const day = d.getDate();
|
||||
const month = d.getMonth() + 1;
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day}. ${month}. ${year} ${hours}:${mins}`;
|
||||
}
|
||||
|
||||
interface Note {
|
||||
id: number
|
||||
content: string
|
||||
user_name: string
|
||||
created_at: string
|
||||
id: number;
|
||||
content: string;
|
||||
user_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
name: string
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
id: number
|
||||
project_number: string
|
||||
name: string
|
||||
status: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
customer_name: string
|
||||
responsible_user_id: string
|
||||
notes?: string
|
||||
order_id?: number
|
||||
order_number?: string
|
||||
order_status?: string
|
||||
quotation_id?: number
|
||||
quotation_number?: string
|
||||
has_nas_folder?: boolean
|
||||
id: number;
|
||||
project_number: string;
|
||||
name: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
customer_name: string;
|
||||
responsible_user_id: string;
|
||||
notes?: string;
|
||||
order_id?: number;
|
||||
order_number?: string;
|
||||
order_status?: string;
|
||||
quotation_id?: number;
|
||||
quotation_number?: string;
|
||||
has_nas_folder?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectForm {
|
||||
name: string
|
||||
status: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
responsible_user_id: string
|
||||
name: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
responsible_user_id: string;
|
||||
}
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams()
|
||||
const alert = useAlert()
|
||||
const { hasPermission, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
const { hasPermission, isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [project, setProject] = useState<ProjectData | null>(null)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [project, setProject] = useState<ProjectData | null>(null);
|
||||
const [form, setForm] = useState<ProjectForm>({
|
||||
name: '',
|
||||
status: 'aktivni',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
responsible_user_id: ''
|
||||
})
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
name: "",
|
||||
status: "aktivni",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
responsible_user_id: "",
|
||||
});
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFiles, setDeleteFiles] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
|
||||
// Dynamic notes
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [notesLoading, setNotesLoading] = useState(true)
|
||||
const [newNote, setNewNote] = useState('')
|
||||
const [addingNote, setAddingNote] = useState(false)
|
||||
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null)
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [notesLoading, setNotesLoading] = useState(true);
|
||||
const [newNote, setNewNote] = useState("");
|
||||
const [addingNote, setAddingNote] = useState(false);
|
||||
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
|
||||
|
||||
const createdShown = useRef(false)
|
||||
const createdShown = useRef(false);
|
||||
useEffect(() => {
|
||||
if ((location.state as { created?: boolean })?.created && !createdShown.current) {
|
||||
createdShown.current = true
|
||||
alert.success('Projekt byl vytvořen')
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
if (
|
||||
(location.state as { created?: boolean })?.created &&
|
||||
!createdShown.current
|
||||
) {
|
||||
createdShown.current = true;
|
||||
alert.success("Projekt byl vytvořen");
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, location.pathname, alert, navigate])
|
||||
}, [location.state, location.pathname, alert, navigate]);
|
||||
|
||||
const fetchNotes = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}`)
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}`);
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setNotes(result.data.project_notes || [])
|
||||
setNotes(result.data.project_notes || []);
|
||||
}
|
||||
} catch {
|
||||
// silent - notes are supplementary
|
||||
} finally {
|
||||
setNotesLoading(false)
|
||||
setNotesLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}`)
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}`);
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const p = result.data
|
||||
setProject(p)
|
||||
const p = result.data;
|
||||
setProject(p);
|
||||
setForm({
|
||||
name: p.name || '',
|
||||
status: p.status || 'aktivni',
|
||||
start_date: (p.start_date || '').substring(0, 10),
|
||||
end_date: (p.end_date || '').substring(0, 10),
|
||||
responsible_user_id: p.responsible_user_id || ''
|
||||
})
|
||||
name: p.name || "",
|
||||
status: p.status || "aktivni",
|
||||
start_date: (p.start_date || "").substring(0, 10),
|
||||
end_date: (p.end_date || "").substring(0, 10),
|
||||
responsible_user_id: p.responsible_user_id || "",
|
||||
});
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se načíst projekt')
|
||||
navigate('/projects')
|
||||
alert.error(result.error || "Nepodařilo se načíst projekt");
|
||||
navigate("/projects");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
navigate('/projects')
|
||||
alert.error("Chyba připojení");
|
||||
navigate("/projects");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`${API_BASE}/users`)
|
||||
if (res.status === 401) return
|
||||
const data = await res.json()
|
||||
const res = await apiFetch(`${API_BASE}/users`);
|
||||
if (res.status === 401) return;
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const raw = Array.isArray(data.data) ? data.data : data.data?.items || []
|
||||
setUsers(raw.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
|
||||
const raw = Array.isArray(data.data)
|
||||
? data.data
|
||||
: data.data?.items || [];
|
||||
setUsers(
|
||||
raw.map((u: any) => ({
|
||||
id: u.id,
|
||||
name:
|
||||
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
|
||||
u.username,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail()
|
||||
fetchNotes()
|
||||
fetchUsers()
|
||||
}, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
fetchDetail();
|
||||
fetchNotes();
|
||||
fetchUsers();
|
||||
}, [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 () => {
|
||||
if (!form.name.trim()) {
|
||||
alert.error('Název projektu je povinný')
|
||||
return
|
||||
alert.error("Název projektu je povinný");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
status: form.status,
|
||||
start_date: form.start_date || null,
|
||||
end_date: form.end_date || null,
|
||||
responsible_user_id: form.responsible_user_id || null
|
||||
})
|
||||
})
|
||||
const result = await response.json()
|
||||
responsible_user_id: form.responsible_user_id || null,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert.success(result.message || 'Projekt byl aktualizován')
|
||||
alert.success(result.message || "Projekt byl aktualizován");
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se uložit projekt')
|
||||
alert.error(result.error || "Nepodařilo se uložit projekt");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ delete_files: deleteFiles }),
|
||||
})
|
||||
const result = await response.json()
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
navigate('/projects')
|
||||
setTimeout(() => alert.success('Projekt byl smazán'), 300)
|
||||
navigate("/projects");
|
||||
setTimeout(() => alert.success("Projekt byl smazán"), 300);
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se smazat projekt')
|
||||
alert.error(result.error || "Nepodařilo se smazat projekt");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!newNote.trim()) return
|
||||
if (!newNote.trim()) return;
|
||||
|
||||
setAddingNote(true)
|
||||
setAddingNote(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newNote.trim() })
|
||||
})
|
||||
const result = await response.json()
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: newNote.trim() }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setNotes(prev => [result.data.note, ...prev])
|
||||
setNewNote('')
|
||||
alert.success('Poznámka byla přidána')
|
||||
setNotes((prev) => [result.data.note, ...prev]);
|
||||
setNewNote("");
|
||||
alert.success("Poznámka byla přidána");
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se přidat poznámku')
|
||||
alert.error(result.error || "Nepodařilo se přidat poznámku");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setAddingNote(false)
|
||||
setAddingNote(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (noteId: number) => {
|
||||
setDeletingNoteId(noteId)
|
||||
setDeletingNoteId(noteId);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/projects/${id}/notes/${noteId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setNotes(prev => prev.filter(n => n.id !== noteId))
|
||||
alert.success('Poznámka byla smazána')
|
||||
setNotes((prev) => prev.filter((n) => n.id !== noteId));
|
||||
alert.success("Poznámka byla smazána");
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se smazat poznámku')
|
||||
alert.error(result.error || "Nepodařilo se smazat poznámku");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeletingNoteId(null)
|
||||
setDeletingNoteId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div className="flex-row-gap">
|
||||
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "100px", borderRadius: "8px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "100px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line w-1/2" />
|
||||
@@ -295,12 +326,12 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) return null
|
||||
if (!project) return null;
|
||||
|
||||
const canEdit = hasPermission('projects.edit')
|
||||
const canEdit = hasPermission("projects.edit");
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -311,9 +342,21 @@ export default function ProjectDetail() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Link
|
||||
to="/projects"
|
||||
className="admin-btn-icon"
|
||||
title="Zpět"
|
||||
aria-label="Zpět"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
@@ -325,13 +368,19 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
{canEdit && (
|
||||
<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 ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
</>
|
||||
) : 'Uložit'}
|
||||
) : (
|
||||
"Uložit"
|
||||
)}
|
||||
</button>
|
||||
{!project.order_id && (
|
||||
<button
|
||||
@@ -362,14 +411,17 @@ export default function ProjectDetail() {
|
||||
value={project.project_number}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
cursor: "default",
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Název">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="Název projektu"
|
||||
disabled={!canEdit}
|
||||
@@ -381,22 +433,29 @@ export default function ProjectDetail() {
|
||||
<FormField label="Zákazník">
|
||||
<input
|
||||
type="text"
|
||||
value={project.customer_name || '—'}
|
||||
value={project.customer_name || "—"}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
cursor: "default",
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Zodpovědná osoba">
|
||||
<select
|
||||
value={form.responsible_user_id}
|
||||
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateForm("responsible_user_id", e.target.value)
|
||||
}
|
||||
className="admin-form-select"
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<option value="">— Nevybráno —</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
@@ -406,7 +465,7 @@ export default function ProjectDetail() {
|
||||
<FormField label="Stav">
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => updateForm('status', e.target.value)}
|
||||
onChange={(e) => updateForm("status", e.target.value)}
|
||||
className="admin-form-select"
|
||||
disabled={!canEdit}
|
||||
>
|
||||
@@ -419,7 +478,7 @@ export default function ProjectDetail() {
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.start_date}
|
||||
onChange={(val: string) => updateForm('start_date', val)}
|
||||
onChange={(val: string) => updateForm("start_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -427,12 +486,11 @@ export default function ProjectDetail() {
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.end_date}
|
||||
onChange={(val: string) => updateForm('end_date', val)}
|
||||
onChange={(val: string) => updateForm("end_date", val)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -455,10 +513,10 @@ export default function ProjectDetail() {
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
placeholder="Napište poznámku..."
|
||||
style={{ resize: 'vertical', width: '100%' }}
|
||||
style={{ resize: "vertical", width: "100%" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey && newNote.trim()) {
|
||||
handleAddNote()
|
||||
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
||||
handleAddNote();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -471,7 +529,7 @@ export default function ProjectDetail() {
|
||||
{addingNote ? (
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
) : (
|
||||
'Přidat poznámku'
|
||||
"Přidat poznámku"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -479,57 +537,107 @@ export default function ProjectDetail() {
|
||||
|
||||
{/* Legacy notes (read-only) */}
|
||||
{project.notes && (
|
||||
<div style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRadius: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.25rem' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-tertiary)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Starší poznámka (před zavedením systému)
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{project.notes}</div>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes list */}
|
||||
{notesLoading && (
|
||||
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '8px' }} />
|
||||
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="admin-skeleton-line"
|
||||
style={{ height: "52px", borderRadius: "8px" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!notesLoading && notes.length === 0 && !project.notes && (
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem', textAlign: 'center', padding: '1rem 0' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "1rem 0",
|
||||
}}
|
||||
>
|
||||
Zatím žádné poznámky
|
||||
</div>
|
||||
)}
|
||||
{!notesLoading && (notes.length > 0 || project.notes) && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{notes.map(note => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRadius: '0.5rem',
|
||||
position: 'relative'
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "0.5rem",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
|
||||
{note.user_name}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{formatNoteDate(note.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontSize: '0.875rem', lineHeight: 1.5 }}>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{note.content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,12 +647,25 @@ export default function ProjectDetail() {
|
||||
className="admin-btn-icon"
|
||||
title="Smazat poznámku"
|
||||
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 ? (
|
||||
<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" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
@@ -562,6 +683,7 @@ export default function ProjectDetail() {
|
||||
|
||||
{/* Project File Manager */}
|
||||
<motion.div
|
||||
style={{ marginBottom: "1rem" }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
@@ -587,24 +709,40 @@ export default function ProjectDetail() {
|
||||
<FormField label="Objednávka">
|
||||
<div>
|
||||
{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_status && (
|
||||
<span className="text-tertiary" style={{ fontWeight: 400, marginLeft: '0.5rem' }}>
|
||||
({STATUS_LABELS[project.order_status] || project.order_status})
|
||||
<span
|
||||
className="text-tertiary"
|
||||
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
|
||||
>
|
||||
(
|
||||
{STATUS_LABELS[project.order_status] ||
|
||||
project.order_status}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
) : '—'}
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Nabídka">
|
||||
<div>
|
||||
{project.quotation_id ? (
|
||||
<Link to={`/offers/${project.quotation_id}`} className="link-accent">
|
||||
<Link
|
||||
to={`/offers/${project.quotation_id}`}
|
||||
className="link-accent"
|
||||
>
|
||||
{project.quotation_number}
|
||||
</Link>
|
||||
) : '—'}
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -614,16 +752,20 @@ export default function ProjectDetail() {
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm}
|
||||
onClose={() => {
|
||||
setDeleteConfirm(false)
|
||||
setDeleteFiles(false)
|
||||
setDeleteConfirm(false);
|
||||
setDeleteFiles(false);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat projekt"
|
||||
message={
|
||||
<>
|
||||
Opravdu chcete smazat projekt "{project.project_number} – {project.name}"? Tato akce je nevratná.
|
||||
Opravdu chcete smazat projekt "{project.project_number} –{" "}
|
||||
{project.name}"? Tato akce je nevratná.
|
||||
{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
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
@@ -640,5 +782,5 @@ export default function ProjectDetail() {
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,107 +1,134 @@
|
||||
import { useState } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { motion } from 'framer-motion'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import { useState } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
|
||||
import apiFetch from '../utils/api'
|
||||
import { formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatDate, czechPlural } from "../utils/formatters";
|
||||
import SortIcon from "../components/SortIcon";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useListData from "../hooks/useListData";
|
||||
import Pagination from "../components/Pagination";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
aktivni: 'Aktivní',
|
||||
dokonceny: 'Dokončený',
|
||||
zruseny: 'Zrušený'
|
||||
}
|
||||
aktivni: "Aktivní",
|
||||
dokonceny: "Dokončený",
|
||||
zruseny: "Zrušený",
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<string, string> = {
|
||||
aktivni: 'admin-badge-project-aktivni',
|
||||
dokonceny: 'admin-badge-project-dokonceny',
|
||||
zruseny: 'admin-badge-project-zruseny'
|
||||
}
|
||||
aktivni: "admin-badge-project-aktivni",
|
||||
dokonceny: "admin-badge-project-dokonceny",
|
||||
zruseny: "admin-badge-project-zruseny",
|
||||
};
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
project_number: string
|
||||
name: string
|
||||
customer_name: string
|
||||
responsible_user_name: string
|
||||
status: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
order_id?: number
|
||||
order_number?: string
|
||||
id: number;
|
||||
project_number: string;
|
||||
name: string;
|
||||
customer_name: string;
|
||||
responsible_user_name: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
order_id?: number;
|
||||
order_number?: string;
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('project_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||
const [deleteFiles, setDeleteFiles] = useState(false)
|
||||
const { sort, order, handleSort, activeSort } =
|
||||
useTableSort("project_number");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
|
||||
const { items: projects, setItems: setProjects, loading, initialLoad, pagination } = useListData<Project>('projects', {
|
||||
search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst projekty'
|
||||
})
|
||||
const {
|
||||
items: projects,
|
||||
setItems: setProjects,
|
||||
loading,
|
||||
initialLoad,
|
||||
pagination,
|
||||
} = useListData<Project>("projects", {
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
errorMsg: "Nepodařilo se načíst projekty",
|
||||
});
|
||||
|
||||
if (!hasPermission('projects.view')) return <Forbidden />
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeletingId(deleteTarget.id)
|
||||
if (!deleteTarget) return;
|
||||
setDeletingId(deleteTarget.id);
|
||||
try {
|
||||
const res = await apiFetch(`${API_BASE}/projects/${deleteTarget.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ delete_files: deleteFiles }),
|
||||
})
|
||||
const data = await res.json()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert.success(data.message || 'Projekt byl smazán')
|
||||
setProjects((prev: Project[]) => prev.filter(p => p.id !== deleteTarget.id))
|
||||
alert.success(data.message || "Projekt byl smazán");
|
||||
setProjects((prev: Project[]) =>
|
||||
prev.filter((p) => p.id !== deleteTarget.id),
|
||||
);
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se smazat projekt')
|
||||
alert.error(data.error || "Nepodařilo se smazat projekt");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
setDeleteTarget(null)
|
||||
setDeleteFiles(false)
|
||||
setDeletingId(null);
|
||||
setDeleteTarget(null);
|
||||
setDeleteFiles(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoad) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
||||
</div>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "140px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</div>
|
||||
@@ -110,7 +137,7 @@ export default function Projects() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -124,12 +151,25 @@ export default function Projects() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Projekty</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination?.total ?? projects.length} {czechPlural(pagination?.total ?? projects.length, 'projekt', 'projekty', 'projektů')}
|
||||
{pagination?.total ?? projects.length}{" "}
|
||||
{czechPlural(
|
||||
pagination?.total ?? projects.length,
|
||||
"projekt",
|
||||
"projekty",
|
||||
"projektů",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('projects.create') && (
|
||||
{hasPermission("projects.create") && (
|
||||
<Link to="/projects/new" className="admin-btn admin-btn-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -143,14 +183,17 @@ export default function Projects() {
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s' }}
|
||||
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-search-bar mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||
/>
|
||||
@@ -159,13 +202,25 @@ export default function Projects() {
|
||||
{projects.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Zatím nejsou žádné projekty.</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem' }}>
|
||||
Vytvořte první projekt tlačítkem výše nebo automaticky při vytvoření objednávky.
|
||||
<p
|
||||
style={{ color: "var(--text-tertiary)", fontSize: "0.875rem" }}
|
||||
>
|
||||
Vytvořte první projekt tlačítkem výše nebo automaticky při
|
||||
vytvoření objednávky.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -173,22 +228,58 @@ export default function Projects() {
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('project_number')}>
|
||||
Číslo <SortIcon column="project_number" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("project_number")}
|
||||
>
|
||||
Číslo{" "}
|
||||
<SortIcon
|
||||
column="project_number"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('name')}>
|
||||
Název <SortIcon column="name" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
Název{" "}
|
||||
<SortIcon column="name" sort={activeSort} order={order} />
|
||||
</th>
|
||||
<th>Zákazník</th>
|
||||
<th>Zodpovědná osoba</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
|
||||
Stav <SortIcon column="status" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
Stav{" "}
|
||||
<SortIcon
|
||||
column="status"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('start_date')}>
|
||||
Začátek <SortIcon column="start_date" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("start_date")}
|
||||
>
|
||||
Začátek{" "}
|
||||
<SortIcon
|
||||
column="start_date"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('end_date')}>
|
||||
Konec <SortIcon column="end_date" sort={activeSort} order={order} />
|
||||
<th
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleSort("end_date")}
|
||||
>
|
||||
Konec{" "}
|
||||
<SortIcon
|
||||
column="end_date"
|
||||
sort={activeSort}
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th>Objednávka</th>
|
||||
<th>Akce</th>
|
||||
@@ -202,11 +293,13 @@ export default function Projects() {
|
||||
{p.project_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="fw-500">{p.name || '—'}</td>
|
||||
<td>{p.customer_name || '—'}</td>
|
||||
<td>{p.responsible_user_name || '—'}</td>
|
||||
<td className="fw-500">{p.name || "—"}</td>
|
||||
<td>{p.customer_name || "—"}</td>
|
||||
<td>{p.responsible_user_name || "—"}</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${STATUS_CLASSES[p.status] || ''}`}>
|
||||
<span
|
||||
className={`admin-badge ${STATUS_CLASSES[p.status] || ""}`}
|
||||
>
|
||||
{STATUS_LABELS[p.status] || p.status}
|
||||
</span>
|
||||
</td>
|
||||
@@ -214,20 +307,38 @@ export default function Projects() {
|
||||
<td className="admin-mono">{formatDate(p.end_date)}</td>
|
||||
<td>
|
||||
{p.order_id ? (
|
||||
<Link to={`/orders/${p.order_id}`} className="text-secondary" style={{ textDecoration: 'none' }}>
|
||||
<Link
|
||||
to={`/orders/${p.order_id}`}
|
||||
className="text-secondary"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{p.order_number}
|
||||
</Link>
|
||||
) : '—'}
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<Link to={`/projects/${p.id}`} className="admin-btn-icon" title="Upravit" aria-label="Upravit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<Link
|
||||
to={`/projects/${p.id}`}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</Link>
|
||||
{!p.order_id && hasPermission('projects.create') && (
|
||||
{!p.order_id && hasPermission("projects.create") && (
|
||||
<button
|
||||
onClick={() => setDeleteTarget(p)}
|
||||
className="admin-btn-icon danger"
|
||||
@@ -237,7 +348,14 @@ export default function Projects() {
|
||||
{deletingId === p.id ? (
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
@@ -260,15 +378,18 @@ export default function Projects() {
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => {
|
||||
setDeleteTarget(null)
|
||||
setDeleteFiles(false)
|
||||
setDeleteTarget(null);
|
||||
setDeleteFiles(false);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat projekt"
|
||||
message={
|
||||
<>
|
||||
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
|
||||
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
|
||||
<label
|
||||
className="admin-form-checkbox"
|
||||
style={{ marginTop: "1rem", display: "flex" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
@@ -283,5 +404,5 @@ export default function Projects() {
|
||||
loading={!!deletingId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,144 +1,154 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import FormField from '../components/FormField'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { formatDate } from '../utils/attendanceHelpers'
|
||||
import { formatKm } from '../utils/formatters'
|
||||
import apiFetch from '../utils/api'
|
||||
const API_BASE = '/api/admin'
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormField from "../components/FormField";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface Vehicle {
|
||||
id: number | string
|
||||
spz: string
|
||||
name: string
|
||||
id: number | string;
|
||||
spz: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
vehicle_id: number | string
|
||||
trip_date: string
|
||||
start_km: number
|
||||
end_km: number
|
||||
distance?: number | null
|
||||
route_from: string
|
||||
route_to: string
|
||||
is_business: boolean
|
||||
notes?: string | null
|
||||
users?: { id: number; first_name: string; last_name: string }
|
||||
vehicles?: { id: number; name: string; spz: string }
|
||||
id: number;
|
||||
vehicle_id: number | string;
|
||||
trip_date: string;
|
||||
start_km: number;
|
||||
end_km: number;
|
||||
distance?: number | null;
|
||||
route_from: string;
|
||||
route_to: string;
|
||||
is_business: boolean;
|
||||
notes?: string | null;
|
||||
users?: { id: number; first_name: string; last_name: string };
|
||||
vehicles?: { id: number; name: string; spz: string };
|
||||
}
|
||||
|
||||
interface TripForm {
|
||||
vehicle_id: string
|
||||
trip_date: string
|
||||
start_km: string | number
|
||||
end_km: string | number
|
||||
route_from: string
|
||||
route_to: string
|
||||
is_business: number
|
||||
notes: string
|
||||
vehicle_id: string;
|
||||
trip_date: string;
|
||||
start_km: string | number;
|
||||
end_km: string | number;
|
||||
route_from: string;
|
||||
route_to: string;
|
||||
is_business: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export default function Trips() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; tripId: number | null }>({ show: false, tripId: null })
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
show: boolean;
|
||||
tripId: number | null;
|
||||
}>({ show: false, tripId: null });
|
||||
const [form, setForm] = useState<TripForm>({
|
||||
vehicle_id: '',
|
||||
trip_date: new Date().toISOString().split('T')[0],
|
||||
start_km: '',
|
||||
end_km: '',
|
||||
route_from: '',
|
||||
route_to: '',
|
||||
vehicle_id: "",
|
||||
trip_date: new Date().toISOString().split("T")[0],
|
||||
start_km: "",
|
||||
end_km: "",
|
||||
route_from: "",
|
||||
route_to: "",
|
||||
is_business: 1,
|
||||
notes: ''
|
||||
})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [, setLastKm] = useState(0)
|
||||
notes: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [, setLastKm] = useState(0);
|
||||
|
||||
const fetchData = useCallback(async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true)
|
||||
try {
|
||||
const [tripsRes, vehiclesRes] = await Promise.all([
|
||||
apiFetch(`${API_BASE}/trips`),
|
||||
apiFetch(`${API_BASE}/vehicles`),
|
||||
])
|
||||
const tripsResult = await tripsRes.json()
|
||||
const vehiclesResult = await vehiclesRes.json()
|
||||
if (tripsResult.success) {
|
||||
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : [])
|
||||
const fetchData = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const [tripsRes, vehiclesRes] = await Promise.all([
|
||||
apiFetch(`${API_BASE}/trips`),
|
||||
apiFetch(`${API_BASE}/vehicles`),
|
||||
]);
|
||||
const tripsResult = await tripsRes.json();
|
||||
const vehiclesResult = await vehiclesRes.json();
|
||||
if (tripsResult.success) {
|
||||
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : []);
|
||||
}
|
||||
if (vehiclesResult.success) {
|
||||
setVehicles(
|
||||
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
if (vehiclesResult.success) {
|
||||
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
} finally {
|
||||
if (showLoading) setLoading(false)
|
||||
}
|
||||
}, [alert])
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
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) => {
|
||||
if (!vehicleId) {
|
||||
setLastKm(0)
|
||||
return
|
||||
setLastKm(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
|
||||
const result = await response.json()
|
||||
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const km = result.data?.last_km || 0
|
||||
setLastKm(km)
|
||||
const km = result.data?.last_km || 0;
|
||||
setLastKm(km);
|
||||
if (!editingTrip) {
|
||||
setForm(prev => ({ ...prev, start_km: km }))
|
||||
setForm((prev) => ({ ...prev, start_km: km }));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
} catch { /* fallback below */ }
|
||||
setLastKm(0)
|
||||
}
|
||||
} catch {
|
||||
/* fallback below */
|
||||
}
|
||||
setLastKm(0);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingTrip(null)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingTrip(null);
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
setForm({
|
||||
vehicle_id: '',
|
||||
vehicle_id: "",
|
||||
trip_date: today,
|
||||
start_km: '',
|
||||
end_km: '',
|
||||
route_from: '',
|
||||
route_to: '',
|
||||
start_km: "",
|
||||
end_km: "",
|
||||
route_from: "",
|
||||
route_to: "",
|
||||
is_business: 1,
|
||||
notes: ''
|
||||
})
|
||||
setLastKm(0)
|
||||
setErrors({})
|
||||
setShowModal(true)
|
||||
}
|
||||
notes: "",
|
||||
});
|
||||
setLastKm(0);
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (trip: Trip) => {
|
||||
setEditingTrip(trip)
|
||||
setEditingTrip(trip);
|
||||
setForm({
|
||||
vehicle_id: String(trip.vehicle_id),
|
||||
trip_date: trip.trip_date,
|
||||
@@ -147,116 +157,146 @@ export default function Trips() {
|
||||
route_from: trip.route_from,
|
||||
route_to: trip.route_to,
|
||||
is_business: Number(trip.is_business),
|
||||
notes: trip.notes || ''
|
||||
})
|
||||
setLastKm(trip.start_km)
|
||||
setErrors({})
|
||||
setShowModal(true)
|
||||
}
|
||||
notes: trip.notes || "",
|
||||
});
|
||||
setLastKm(trip.start_km);
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleVehicleChange = (vehicleId: string) => {
|
||||
setForm(prev => ({ ...prev, vehicle_id: vehicleId }))
|
||||
fetchLastKm(vehicleId)
|
||||
}
|
||||
setForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
|
||||
fetchLastKm(vehicleId);
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!form.vehicle_id) newErrors.vehicle_id = 'Vyberte vozidlo'
|
||||
if (!form.trip_date) newErrors.trip_date = 'Zadejte datum'
|
||||
if (!form.start_km) newErrors.start_km = 'Zadejte počáteční km'
|
||||
if (!form.end_km) newErrors.end_km = 'Zadejte konečný km'
|
||||
if (form.start_km && form.end_km && parseInt(String(form.end_km)) <= parseInt(String(form.start_km))) {
|
||||
newErrors.end_km = 'Musí být větší než počáteční'
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.vehicle_id) newErrors.vehicle_id = "Vyberte vozidlo";
|
||||
if (!form.trip_date) newErrors.trip_date = "Zadejte datum";
|
||||
if (!form.start_km) newErrors.start_km = "Zadejte počáteční km";
|
||||
if (!form.end_km) newErrors.end_km = "Zadejte konečný km";
|
||||
if (
|
||||
form.start_km &&
|
||||
form.end_km &&
|
||||
parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
|
||||
) {
|
||||
newErrors.end_km = "Musí být větší než počáteční";
|
||||
}
|
||||
if (!form.route_from) newErrors.route_from = 'Zadejte místo odjezdu'
|
||||
if (!form.route_to) newErrors.route_to = 'Zadejte místo příjezdu'
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
if (!form.route_from) newErrors.route_from = "Zadejte místo odjezdu";
|
||||
if (!form.route_to) newErrors.route_to = "Zadejte místo příjezdu";
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
if (!validateForm()) return;
|
||||
|
||||
setSubmitting(true)
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const url = editingTrip
|
||||
? `${API_BASE}/trips/${editingTrip.id}`
|
||||
: `${API_BASE}/trips`
|
||||
: `${API_BASE}/trips`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: editingTrip ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form)
|
||||
})
|
||||
method: editingTrip ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setShowModal(false)
|
||||
await fetchData(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
alert.success(result.message)
|
||||
setShowModal(false);
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (tripId: number) => {
|
||||
try {
|
||||
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) {
|
||||
await fetchData(false)
|
||||
alert.success(result.message)
|
||||
await fetchData(false);
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleteConfirm({ show: false, tripId: null })
|
||||
setDeleteConfirm({ show: false, tripId: null });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDistance = (): number => {
|
||||
const start = parseInt(String(form.start_km)) || 0
|
||||
const end = parseInt(String(form.end_km)) || 0
|
||||
return end > start ? end - start : 0
|
||||
}
|
||||
const start = parseInt(String(form.start_km)) || 0;
|
||||
const end = parseInt(String(form.end_km)) || 0;
|
||||
return end > start ? end - start : 0;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
||||
</div>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '140px', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "140px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-grid admin-grid-4">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="admin-stat-card">
|
||||
<div className="admin-skeleton-line" style={{ width: '60%', height: '11px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '40%', height: '28px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '50%', height: '12px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{
|
||||
width: "60%",
|
||||
height: "11px",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{
|
||||
width: "40%",
|
||||
height: "28px",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "50%", height: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line w-1/3" />
|
||||
@@ -267,20 +307,20 @@ export default function Trips() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const totals = trips.reduce(
|
||||
(acc, t) => {
|
||||
const dist = t.distance ?? (t.end_km - t.start_km)
|
||||
acc.count++
|
||||
acc.total += dist
|
||||
if (t.is_business) acc.business += dist
|
||||
else acc.private += dist
|
||||
return acc
|
||||
const dist = t.distance ?? t.end_km - t.start_km;
|
||||
acc.count++;
|
||||
acc.total += dist;
|
||||
if (t.is_business) acc.business += dist;
|
||||
else acc.private += dist;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, business: 0, private: 0, count: 0 }
|
||||
)
|
||||
{ total: 0, business: 0, private: 0, count: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -293,12 +333,25 @@ export default function Trips() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Kniha jízd</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{new Date().toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })}
|
||||
{new Date().toLocaleDateString("cs-CZ", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -316,7 +369,16 @@ export default function Trips() {
|
||||
>
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-icon info">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
@@ -330,19 +392,39 @@ export default function Trips() {
|
||||
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.total)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Celkem naježděno</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-icon success">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||
<circle cx="5.5" cy="18" r="2" />
|
||||
@@ -351,20 +433,33 @@ export default function Trips() {
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stat-card warning">
|
||||
<div className="admin-stat-icon warning">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">{formatKm(totals.private)} km</span>
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.private)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Soukromé</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,7 +474,10 @@ export default function Trips() {
|
||||
>
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Poslední jízdy</h2>
|
||||
<Link to="/trips/history" className="admin-btn admin-btn-secondary admin-btn-sm">
|
||||
<Link
|
||||
to="/trips/history"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
Zobrazit historii
|
||||
</Link>
|
||||
</div>
|
||||
@@ -387,7 +485,16 @@ export default function Trips() {
|
||||
{trips.length === 0 ? (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
@@ -395,7 +502,10 @@ export default function Trips() {
|
||||
</svg>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -416,20 +526,37 @@ export default function Trips() {
|
||||
<tbody>
|
||||
{trips.slice(0, 10).map((trip) => (
|
||||
<tr key={trip.id}>
|
||||
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
|
||||
<td>
|
||||
<span className="admin-badge">{trip.vehicles?.spz ?? ''}</span>
|
||||
<td className="admin-mono">
|
||||
{formatDate(trip.trip_date)}
|
||||
</td>
|
||||
<td>{trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : ''}</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} → {trip.route_to}
|
||||
</span>
|
||||
</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>
|
||||
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
|
||||
{trip.is_business ? 'Služební' : 'Soukromá'}
|
||||
<span
|
||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
||||
>
|
||||
{trip.is_business ? "Služební" : "Soukromá"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -440,18 +567,38 @@ export default function Trips() {
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, tripId: trip.id })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
@@ -477,7 +624,10 @@ export default function Trips() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShowModal(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -487,19 +637,23 @@ export default function Trips() {
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">
|
||||
{editingTrip ? 'Upravit jízdu' : 'Přidat jízdu'}
|
||||
{editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Vozidlo" error={errors.vehicle_id} required>
|
||||
<FormField
|
||||
label="Vozidlo"
|
||||
error={errors.vehicle_id}
|
||||
required
|
||||
>
|
||||
<select
|
||||
value={form.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleVehicleChange(e.target.value)
|
||||
setErrors(prev => ({ ...prev, vehicle_id: '' }))
|
||||
handleVehicleChange(e.target.value);
|
||||
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
@@ -512,41 +666,53 @@ export default function Trips() {
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Datum jízdy" error={errors.trip_date} required>
|
||||
<FormField
|
||||
label="Datum jízdy"
|
||||
error={errors.trip_date}
|
||||
required
|
||||
>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.trip_date}
|
||||
onChange={(val: string) => {
|
||||
setForm({ ...form, trip_date: val })
|
||||
setErrors(prev => ({ ...prev, trip_date: '' }))
|
||||
setForm({ ...form, trip_date: val });
|
||||
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<FormField label="Počáteční stav km" error={errors.start_km} required>
|
||||
<FormField
|
||||
label="Počáteční stav km"
|
||||
error={errors.start_km}
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.start_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, start_km: e.target.value })
|
||||
setErrors(prev => ({ ...prev, start_km: '' }))
|
||||
setForm({ ...form, start_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, start_km: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Konečný stav km" error={errors.end_km} required>
|
||||
<FormField
|
||||
label="Konečný stav km"
|
||||
error={errors.end_km}
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={form.end_km}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, end_km: e.target.value })
|
||||
setErrors(prev => ({ ...prev, end_km: '' }))
|
||||
setForm({ ...form, end_km: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, end_km: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
@@ -565,26 +731,34 @@ export default function Trips() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="text"
|
||||
value={form.route_from}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_from: e.target.value })
|
||||
setErrors(prev => ({ ...prev, route_from: '' }))
|
||||
setForm({ ...form, route_from: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_from: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Místo příjezdu" error={errors.route_to} required>
|
||||
<FormField
|
||||
label="Místo příjezdu"
|
||||
error={errors.route_to}
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={form.route_to}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, route_to: e.target.value })
|
||||
setErrors(prev => ({ ...prev, route_to: '' }))
|
||||
setForm({ ...form, route_to: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, route_to: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
@@ -595,7 +769,12 @@ export default function Trips() {
|
||||
<FormField label="Typ jízdy">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
@@ -606,7 +785,9 @@ export default function Trips() {
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, notes: e.target.value })
|
||||
}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
@@ -630,7 +811,7 @@ export default function Trips() {
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Ukládám...' : 'Uložit'}
|
||||
{submitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -649,5 +830,5 @@ export default function Trips() {
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import FormField from '../components/FormField'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import { formatDate } from '../utils/attendanceHelpers'
|
||||
import { formatKm } from '../utils/formatters'
|
||||
import apiFetch from '../utils/api'
|
||||
const API_BASE = '/api/admin'
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import FormField from "../components/FormField";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface Vehicle {
|
||||
id: number | string
|
||||
spz: string
|
||||
name: string
|
||||
id: number | string;
|
||||
spz: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserShort {
|
||||
id: number | string
|
||||
name: string
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
vehicle_id: number | string
|
||||
trip_date: string
|
||||
start_km: number
|
||||
end_km: number
|
||||
distance: number
|
||||
route_from: string
|
||||
route_to: string
|
||||
is_business: number | boolean
|
||||
notes?: string
|
||||
spz: string
|
||||
driver_name: string
|
||||
id: number;
|
||||
vehicle_id: number | string;
|
||||
trip_date: string;
|
||||
start_km: number;
|
||||
end_km: number;
|
||||
distance: number;
|
||||
route_from: string;
|
||||
route_to: string;
|
||||
is_business: number | boolean;
|
||||
notes?: string;
|
||||
spz: string;
|
||||
driver_name: string;
|
||||
}
|
||||
|
||||
interface BackendTrip {
|
||||
id: number
|
||||
vehicle_id: number
|
||||
user_id: number
|
||||
trip_date: string
|
||||
start_km: number
|
||||
end_km: number
|
||||
distance: number | null
|
||||
route_from: string
|
||||
route_to: string
|
||||
is_business: boolean
|
||||
notes: string | null
|
||||
users: { id: number; first_name: string; last_name: string }
|
||||
vehicles: { id: number; name: string; spz: string }
|
||||
id: number;
|
||||
vehicle_id: number;
|
||||
user_id: number;
|
||||
trip_date: string;
|
||||
start_km: number;
|
||||
end_km: number;
|
||||
distance: number | null;
|
||||
route_from: string;
|
||||
route_to: string;
|
||||
is_business: boolean;
|
||||
notes: string | null;
|
||||
users: { id: number; first_name: string; last_name: string };
|
||||
vehicles: { id: number; name: string; spz: string };
|
||||
}
|
||||
|
||||
interface EditForm {
|
||||
vehicle_id: string
|
||||
trip_date: string
|
||||
start_km: string | number
|
||||
end_km: string | number
|
||||
route_from: string
|
||||
route_to: string
|
||||
is_business: number
|
||||
notes: string
|
||||
vehicle_id: string;
|
||||
trip_date: string;
|
||||
start_km: string | number;
|
||||
end_km: string | number;
|
||||
route_from: string;
|
||||
route_to: string;
|
||||
is_business: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function mapTrip(bt: BackendTrip): Trip {
|
||||
const distance = bt.distance ?? (bt.end_km - bt.start_km)
|
||||
const distance = bt.distance ?? bt.end_km - bt.start_km;
|
||||
return {
|
||||
id: bt.id,
|
||||
vehicle_id: bt.vehicle_id,
|
||||
@@ -80,38 +80,45 @@ function mapTrip(bt: BackendTrip): Trip {
|
||||
route_to: bt.route_to,
|
||||
is_business: bt.is_business ? 1 : 0,
|
||||
notes: bt.notes || undefined,
|
||||
spz: bt.vehicles?.spz ?? '',
|
||||
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : '',
|
||||
}
|
||||
spz: bt.vehicles?.spz ?? "",
|
||||
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function TripsAdmin() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterMonth, setFilterMonth] = useState(() => String(new Date().getMonth() + 1))
|
||||
const [filterYear, setFilterYear] = useState(() => String(new Date().getFullYear()))
|
||||
const [filterVehicleId, setFilterVehicleId] = useState('')
|
||||
const [filterUserId, setFilterUserId] = useState('')
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const [users, setUsers] = useState<UserShort[]>([])
|
||||
const printRef = useRef<HTMLDivElement>(null)
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterMonth, setFilterMonth] = useState(() =>
|
||||
String(new Date().getMonth() + 1),
|
||||
);
|
||||
const [filterYear, setFilterYear] = useState(() =>
|
||||
String(new Date().getFullYear()),
|
||||
);
|
||||
const [filterVehicleId, setFilterVehicleId] = useState("");
|
||||
const [filterUserId, setFilterUserId] = useState("");
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [users, setUsers] = useState<UserShort[]>([]);
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
|
||||
const [editForm, setEditForm] = useState<EditForm>({
|
||||
vehicle_id: '',
|
||||
trip_date: '',
|
||||
start_km: '',
|
||||
end_km: '',
|
||||
route_from: '',
|
||||
route_to: '',
|
||||
vehicle_id: "",
|
||||
trip_date: "",
|
||||
start_km: "",
|
||||
end_km: "",
|
||||
route_from: "",
|
||||
route_to: "",
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -120,53 +127,60 @@ export default function TripsAdmin() {
|
||||
const [vRes, uRes] = await Promise.all([
|
||||
apiFetch(`${API_BASE}/vehicles`),
|
||||
apiFetch(`${API_BASE}/users?limit=1000`),
|
||||
])
|
||||
const vJson = await vRes.json()
|
||||
const uJson = await uRes.json()
|
||||
if (vJson.success) setVehicles(vJson.data)
|
||||
]);
|
||||
const vJson = await vRes.json();
|
||||
const uJson = await uRes.json();
|
||||
if (vJson.success) setVehicles(vJson.data);
|
||||
if (uJson.success) {
|
||||
setUsers(uJson.data.map((u: { id: number; first_name: string; last_name: string }) => ({
|
||||
id: u.id,
|
||||
name: `${u.first_name} ${u.last_name}`,
|
||||
})))
|
||||
setUsers(
|
||||
uJson.data.map(
|
||||
(u: { id: number; first_name: string; last_name: string }) => ({
|
||||
id: u.id,
|
||||
name: `${u.first_name} ${u.last_name}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// silently fail, filters will just be empty
|
||||
}
|
||||
}
|
||||
fetchLookups()
|
||||
}, [])
|
||||
};
|
||||
fetchLookups();
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true)
|
||||
try {
|
||||
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`
|
||||
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`
|
||||
if (filterUserId) url += `&user_id=${filterUserId}`
|
||||
const fetchData = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`;
|
||||
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`;
|
||||
if (filterUserId) url += `&user_id=${filterUserId}`;
|
||||
|
||||
const response = await apiFetch(url)
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
const mapped = (result.data as BackendTrip[]).map(mapTrip)
|
||||
setTrips(mapped)
|
||||
const response = await apiFetch(url);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const mapped = (result.data as BackendTrip[]).map(mapTrip);
|
||||
setTrips(mapped);
|
||||
}
|
||||
} catch {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
} finally {
|
||||
if (showLoading) setLoading(false)
|
||||
}
|
||||
}, [filterMonth, filterYear, filterVehicleId, filterUserId, alert])
|
||||
},
|
||||
[filterMonth, filterYear, filterVehicleId, filterUserId, alert],
|
||||
);
|
||||
|
||||
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) => {
|
||||
setEditingTrip(trip)
|
||||
setEditingTrip(trip);
|
||||
setEditForm({
|
||||
vehicle_id: String(trip.vehicle_id),
|
||||
trip_date: trip.trip_date,
|
||||
@@ -175,82 +189,91 @@ export default function TripsAdmin() {
|
||||
route_from: trip.route_from,
|
||||
route_to: trip.route_to,
|
||||
is_business: Number(trip.is_business),
|
||||
notes: trip.notes || ''
|
||||
})
|
||||
setShowEditModal(true)
|
||||
}
|
||||
notes: trip.notes || "",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async () => {
|
||||
if (!editingTrip) return
|
||||
if (parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))) {
|
||||
alert.error('Konečný stav km musí být větší než počáteční')
|
||||
return
|
||||
if (!editingTrip) return;
|
||||
if (
|
||||
parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))
|
||||
) {
|
||||
alert.error("Konečný stav km musí být větší než počáteční");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editForm)
|
||||
})
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(editForm),
|
||||
});
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false)
|
||||
await fetchData(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
alert.success(result.message)
|
||||
setShowEditModal(false);
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.trip) return
|
||||
if (!deleteConfirm.trip) return;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/trips/${deleteConfirm.trip.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/trips/${deleteConfirm.trip.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, trip: null })
|
||||
await fetchData(false)
|
||||
alert.success(result.message)
|
||||
setDeleteConfirm({ show: false, trip: null });
|
||||
await fetchData(false);
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} 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 = () => {
|
||||
if (!filterVehicleId) return null
|
||||
const v = vehicles.find(v => String(v.id) === filterVehicleId)
|
||||
return v ? `${v.spz} - ${v.name}` : null
|
||||
}
|
||||
if (!filterVehicleId) return null;
|
||||
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
||||
return v ? `${v.spz} - ${v.name}` : null;
|
||||
};
|
||||
const getSelectedUserName = () => {
|
||||
if (!filterUserId) return null
|
||||
const u = users.find(u => String(u.id) === filterUserId)
|
||||
return u?.name || null
|
||||
}
|
||||
if (!filterUserId) return null;
|
||||
const u = users.find((u) => String(u.id) === filterUserId);
|
||||
return u?.name || null;
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
const periodName = getPeriodName()
|
||||
const periodName = getPeriodName();
|
||||
|
||||
setTimeout(() => {
|
||||
if (printRef.current) {
|
||||
const content = printRef.current.innerHTML
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) return
|
||||
const content = printRef.current.innerHTML;
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
@@ -324,26 +347,28 @@ export default function TripsAdmin() {
|
||||
${content}
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
printWindow.document.close()
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print()
|
||||
}
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const calculateDistance = (): number => {
|
||||
const start = parseInt(String(editForm.start_km)) || 0
|
||||
const end = parseInt(String(editForm.end_km)) || 0
|
||||
return end > start ? end - start : 0
|
||||
}
|
||||
const start = parseInt(String(editForm.start_km)) || 0;
|
||||
const end = parseInt(String(editForm.end_km)) || 0;
|
||||
return end > start ? end - start : 0;
|
||||
};
|
||||
|
||||
const totals = {
|
||||
count: trips.length,
|
||||
total: trips.reduce((sum, t) => sum + t.distance, 0),
|
||||
business: trips.filter(t => Number(t.is_business)).reduce((sum, t) => sum + t.distance, 0),
|
||||
}
|
||||
business: trips
|
||||
.filter((t) => Number(t.is_business))
|
||||
.reduce((sum, t) => sum + t.distance, 0),
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -363,7 +388,15 @@ export default function TripsAdmin() {
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk knihy jízd"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginRight: "0.5rem" }}
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
@@ -394,7 +427,9 @@ export default function TripsAdmin() {
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
@@ -406,8 +441,12 @@ export default function TripsAdmin() {
|
||||
className="admin-form-select"
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 2 + i
|
||||
return <option key={y} value={y}>{y}</option>
|
||||
const y = new Date().getFullYear() - 2 + i;
|
||||
return (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</FormField>
|
||||
@@ -451,7 +490,16 @@ export default function TripsAdmin() {
|
||||
>
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-icon info">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
@@ -464,18 +512,38 @@ export default function TripsAdmin() {
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.total)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Celkem naježděno</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-icon success">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||
<circle cx="5.5" cy="18" r="2" />
|
||||
@@ -484,7 +552,9 @@ export default function TripsAdmin() {
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,8 +569,8 @@ export default function TripsAdmin() {
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{loading && (
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line w-1/3" />
|
||||
@@ -532,25 +602,31 @@ export default function TripsAdmin() {
|
||||
<tbody>
|
||||
{trips.map((trip) => (
|
||||
<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>
|
||||
<span className="admin-badge">{trip.spz}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
|
||||
<td className="admin-mono">
|
||||
<strong>{formatKm(trip.distance)} km</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
|
||||
{trip.is_business ? 'Služební' : 'Soukromá'}
|
||||
<span
|
||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
||||
>
|
||||
{trip.is_business ? "Služební" : "Soukromá"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -561,18 +637,38 @@ export default function TripsAdmin() {
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, trip })}
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, trip })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
@@ -598,7 +694,10 @@ export default function TripsAdmin() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={() => setShowEditModal(false)} />
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -608,7 +707,12 @@ export default function TripsAdmin() {
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -619,7 +723,12 @@ export default function TripsAdmin() {
|
||||
<FormField label="Vozidlo">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{vehicles.map((v) => (
|
||||
@@ -634,7 +743,9 @@ export default function TripsAdmin() {
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={editForm.trip_date}
|
||||
onChange={(val: string) => setEditForm({ ...editForm, trip_date: val })}
|
||||
onChange={(val: string) =>
|
||||
setEditForm({ ...editForm, trip_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -645,7 +756,9 @@ export default function TripsAdmin() {
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
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"
|
||||
min="0"
|
||||
/>
|
||||
@@ -656,7 +769,9 @@ export default function TripsAdmin() {
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
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"
|
||||
min="0"
|
||||
/>
|
||||
@@ -678,7 +793,12 @@ export default function TripsAdmin() {
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</FormField>
|
||||
@@ -687,7 +807,9 @@ export default function TripsAdmin() {
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</FormField>
|
||||
@@ -696,7 +818,12 @@ export default function TripsAdmin() {
|
||||
<FormField label="Typ jízdy">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
@@ -707,7 +834,9 @@ export default function TripsAdmin() {
|
||||
<FormField label="Poznámky">
|
||||
<textarea
|
||||
value={editForm.notes}
|
||||
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, notes: e.target.value })
|
||||
}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
/>
|
||||
@@ -742,17 +871,25 @@ export default function TripsAdmin() {
|
||||
onClose={() => setDeleteConfirm({ show: false, trip: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Smazat záznam"
|
||||
message={deleteConfirm.trip ? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?` : ''}
|
||||
message={
|
||||
deleteConfirm.trip
|
||||
? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?`
|
||||
: ""
|
||||
}
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
|
||||
{/* Hidden Print Content */}
|
||||
{trips.length > 0 && (
|
||||
<div ref={printRef} style={{ display: 'none' }}>
|
||||
<div ref={printRef} style={{ display: "none" }}>
|
||||
<div className="print-header">
|
||||
<div className="print-header-left">
|
||||
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
|
||||
<img
|
||||
src="/images/logo-light.png"
|
||||
alt="BOHA"
|
||||
className="print-logo"
|
||||
/>
|
||||
<div className="print-header-text">
|
||||
<h1>KNIHA JÍZD</h1>
|
||||
<div className="company">BOHA Automation s.r.o.</div>
|
||||
@@ -760,9 +897,17 @@ export default function TripsAdmin() {
|
||||
</div>
|
||||
<div className="print-header-right">
|
||||
<div className="period">{getPeriodName()}</div>
|
||||
{getSelectedVehicleName() && <div className="filters">Vozidlo: {getSelectedVehicleName()}</div>}
|
||||
{getSelectedUserName() && <div className="filters">Řidič: {getSelectedUserName()}</div>}
|
||||
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
|
||||
{getSelectedVehicleName() && (
|
||||
<div className="filters">
|
||||
Vozidlo: {getSelectedVehicleName()}
|
||||
</div>
|
||||
)}
|
||||
{getSelectedUserName() && (
|
||||
<div className="filters">Řidič: {getSelectedUserName()}</div>
|
||||
)}
|
||||
<div className="generated">
|
||||
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -776,11 +921,15 @@ export default function TripsAdmin() {
|
||||
<div className="summary-label">Celkem</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -788,13 +937,19 @@ export default function TripsAdmin() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '70px' }}>Datum</th>
|
||||
<th style={{ width: '80px' }}>Řidič</th>
|
||||
<th style={{ width: '70px' }}>Vozidlo</th>
|
||||
<th style={{ width: "70px" }}>Datum</th>
|
||||
<th style={{ width: "80px" }}>Řidič</th>
|
||||
<th style={{ width: "70px" }}>Vozidlo</th>
|
||||
<th>Trasa</th>
|
||||
<th style={{ width: '70px' }} className="text-right">Stav km</th>
|
||||
<th style={{ width: '60px' }} className="text-right">Vzdálenost</th>
|
||||
<th style={{ width: '55px' }} className="text-center">Typ</th>
|
||||
<th style={{ width: "70px" }} className="text-right">
|
||||
Stav km
|
||||
</th>
|
||||
<th style={{ width: "60px" }} className="text-right">
|
||||
Vzdálenost
|
||||
</th>
|
||||
<th style={{ width: "55px" }} className="text-center">
|
||||
Typ
|
||||
</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -804,22 +959,34 @@ export default function TripsAdmin() {
|
||||
<td>{formatDate(trip.trip_date)}</td>
|
||||
<td>{trip.driver_name}</td>
|
||||
<td>{trip.spz}</td>
|
||||
<td>{trip.route_from} → {trip.route_to}</td>
|
||||
<td className="text-right">{formatKm(trip.start_km)} - {formatKm(trip.end_km)}</td>
|
||||
<td className="text-right"><strong>{formatKm(trip.distance)} km</strong></td>
|
||||
<td>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<strong>{formatKm(trip.distance)} km</strong>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<span className={`badge ${trip.is_business ? 'badge-success' : 'badge-warning'}`}>
|
||||
{trip.is_business ? 'Služební' : 'Soukromá'}
|
||||
<span
|
||||
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
|
||||
>
|
||||
{trip.is_business ? "Služební" : "Soukromá"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{trip.notes || ''}</td>
|
||||
<td>{trip.notes || ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={5} className="text-right">Celkem:</td>
|
||||
<td className="text-right"><strong>{formatKm(totals.total)} km</strong></td>
|
||||
<td colSpan={5} className="text-right">
|
||||
Celkem:
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<strong>{formatKm(totals.total)} km</strong>
|
||||
</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -827,5 +994,5 @@ export default function TripsAdmin() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,110 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { motion } from 'framer-motion'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { formatDate } from '../utils/attendanceHelpers'
|
||||
import { formatKm } from '../utils/formatters'
|
||||
import FormField from '../components/FormField'
|
||||
import apiFetch from '../utils/api'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { motion } from "framer-motion";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate } from "../utils/attendanceHelpers";
|
||||
import { formatKm } from "../utils/formatters";
|
||||
import FormField from "../components/FormField";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface Vehicle {
|
||||
id: number | string
|
||||
spz: string
|
||||
name: string
|
||||
id: number | string;
|
||||
spz: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
trip_date: string
|
||||
spz: string
|
||||
driver_name: string
|
||||
route_from: string
|
||||
route_to: string
|
||||
start_km: number
|
||||
end_km: number
|
||||
distance: number
|
||||
is_business: number | boolean
|
||||
notes?: string
|
||||
id: number;
|
||||
trip_date: string;
|
||||
spz: string;
|
||||
driver_name: string;
|
||||
route_from: string;
|
||||
route_to: string;
|
||||
start_km: number;
|
||||
end_km: number;
|
||||
distance: number;
|
||||
is_business: number | boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export default function TripsHistory() {
|
||||
const alert = useAlert()
|
||||
const { user, hasPermission } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const alert = useAlert();
|
||||
const { user, hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
})
|
||||
const [vehicleId, setVehicleId] = useState('')
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
});
|
||||
const [vehicleId, setVehicleId] = useState("");
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
|
||||
const totals = trips.reduce(
|
||||
(acc, t) => ({
|
||||
total: acc.total + (t.distance || 0),
|
||||
business: acc.business + (t.is_business ? (t.distance || 0) : 0),
|
||||
business: acc.business + (t.is_business ? t.distance || 0 : 0),
|
||||
count: acc.count + 1,
|
||||
}),
|
||||
{ total: 0, business: 0, count: 0 }
|
||||
)
|
||||
{ total: 0, business: 0, count: 0 },
|
||||
);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ month })
|
||||
if (user?.id) params.set('user_id', String(user.id))
|
||||
if (vehicleId) params.set('vehicle_id', vehicleId)
|
||||
const params = new URLSearchParams({ month });
|
||||
if (user?.id) params.set("user_id", String(user.id));
|
||||
if (vehicleId) params.set("vehicle_id", vehicleId);
|
||||
|
||||
const [tripsRes, vehiclesRes] = await Promise.all([
|
||||
apiFetch(`${API_BASE}/trips?${params}`),
|
||||
apiFetch(`${API_BASE}/vehicles`),
|
||||
])
|
||||
if (tripsRes.status === 401) return
|
||||
const tripsResult = await tripsRes.json()
|
||||
const vehiclesResult = await vehiclesRes.json()
|
||||
]);
|
||||
if (tripsRes.status === 401) return;
|
||||
const tripsResult = await tripsRes.json();
|
||||
const vehiclesResult = await vehiclesRes.json();
|
||||
if (tripsResult.success) {
|
||||
const raw = Array.isArray(tripsResult.data) ? tripsResult.data : tripsResult.data?.items || []
|
||||
setTrips(raw.map((t: Record<string, unknown>) => ({
|
||||
...t,
|
||||
spz: (t.vehicles as Record<string, string>)?.spz || '',
|
||||
driver_name: t.users
|
||||
? `${(t.users as Record<string, string>).first_name || ''} ${(t.users as Record<string, string>).last_name || ''}`.trim()
|
||||
: '',
|
||||
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
|
||||
})))
|
||||
const raw = Array.isArray(tripsResult.data)
|
||||
? tripsResult.data
|
||||
: tripsResult.data?.items || [];
|
||||
setTrips(
|
||||
raw.map((t: Record<string, unknown>) => ({
|
||||
...t,
|
||||
spz: (t.vehicles as Record<string, string>)?.spz || "",
|
||||
driver_name: t.users
|
||||
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
|
||||
: "",
|
||||
distance:
|
||||
((t.end_km as number) || 0) - ((t.start_km as number) || 0),
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (vehiclesResult.success) {
|
||||
setVehicles(Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [])
|
||||
setVehicles(
|
||||
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, [month, vehicleId, alert, user?.id])
|
||||
}, [month, vehicleId, alert, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (!hasPermission('trips.history')) return <Forbidden />
|
||||
if (!hasPermission("trips.history")) return <Forbidden />;
|
||||
|
||||
const getMonthName = (monthStr: string): string => {
|
||||
const [yearStr, monthNum] = monthStr.split('-')
|
||||
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1)
|
||||
return date.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
|
||||
}
|
||||
const [yearStr, monthNum] = monthStr.split("-");
|
||||
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1);
|
||||
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -155,7 +162,16 @@ export default function TripsHistory() {
|
||||
>
|
||||
<div className="admin-stat-card info">
|
||||
<div className="admin-stat-icon info">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
@@ -168,18 +184,38 @@ export default function TripsHistory() {
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
|
||||
<span className="admin-stat-value">
|
||||
{formatKm(totals.total)} km
|
||||
</span>
|
||||
<span className="admin-stat-label">Celkem naježděno</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card success">
|
||||
<div className="admin-stat-icon success">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||
<circle cx="5.5" cy="18" r="2" />
|
||||
@@ -188,7 +224,9 @@ export default function TripsHistory() {
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +242,7 @@ export default function TripsHistory() {
|
||||
<div className="admin-card-body">
|
||||
{loading && (
|
||||
<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 className="admin-skeleton-line w-1/4" />
|
||||
<div className="admin-skeleton-line w-1/3" />
|
||||
@@ -236,29 +274,47 @@ export default function TripsHistory() {
|
||||
<tbody>
|
||||
{trips.map((trip) => (
|
||||
<tr key={trip.id}>
|
||||
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
|
||||
<td className="admin-mono">
|
||||
{formatDate(trip.trip_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge">{trip.spz}</span>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>{trip.driver_name}</td>
|
||||
<td style={{ color: "var(--text-secondary)" }}>
|
||||
{trip.driver_name}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{trip.route_from} → {trip.route_to}
|
||||
</span>
|
||||
</td>
|
||||
<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)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono"><strong>{formatKm(trip.distance)} km</strong></td>
|
||||
<td className="admin-mono">
|
||||
<strong>{formatKm(trip.distance)} km</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
|
||||
{trip.is_business ? 'Služební' : 'Soukromá'}
|
||||
<span
|
||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
||||
>
|
||||
{trip.is_business ? "Služební" : "Soukromá"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-secondary)', maxWidth: '200px' }}>
|
||||
{trip.notes || '—'}
|
||||
<td
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
maxWidth: "200px",
|
||||
}}
|
||||
>
|
||||
{trip.notes || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -269,5 +325,5 @@ export default function TripsHistory() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,252 +1,284 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import FormField from '../components/FormField'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormField from "../components/FormField";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
|
||||
import apiFetch from '../utils/api'
|
||||
const API_BASE = '/api/admin'
|
||||
import apiFetch from "../utils/api";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
role_id: number
|
||||
roles?: { id: number; name: string; display_name: string } | null
|
||||
is_active: boolean
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role_id: number;
|
||||
roles?: { id: number; name: string; display_name: string } | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
role_id: number | string
|
||||
is_active: boolean
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role_id: number | string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { user: currentUser, updateUser, hasPermission } = useAuth()
|
||||
const alert = useAlert()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user: User | null }>({ isOpen: false, user: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const { user: currentUser, updateUser, hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean;
|
||||
user: User | null;
|
||||
}>({ isOpen: false, user: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
role_id: '',
|
||||
is_active: true
|
||||
})
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role_id: "",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
useModalLock(showModal)
|
||||
useModalLock(showModal);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const usersRes = await apiFetch(`${API_BASE}/users`)
|
||||
const usersData = await usersRes.json()
|
||||
const usersRes = await apiFetch(`${API_BASE}/users`);
|
||||
const usersData = await usersRes.json();
|
||||
|
||||
if (usersData.success) {
|
||||
setUsers(Array.isArray(usersData.data) ? usersData.data : [])
|
||||
setUsers(Array.isArray(usersData.data) ? usersData.data : []);
|
||||
} 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
|
||||
try {
|
||||
const rolesRes = await apiFetch(`${API_BASE}/roles`)
|
||||
const rolesData = await rolesRes.json()
|
||||
const rolesRes = await apiFetch(`${API_BASE}/roles`);
|
||||
const rolesData = await rolesRes.json();
|
||||
if (rolesData.success) {
|
||||
setRoles(Array.isArray(rolesData.data) ? rolesData.data : [])
|
||||
setRoles(Array.isArray(rolesData.data) ? rolesData.data : []);
|
||||
}
|
||||
} catch { /* roles not accessible */ }
|
||||
} catch {
|
||||
/* roles not accessible */
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
if (!hasPermission('users.view')) return <Forbidden />
|
||||
if (!hasPermission("users.view")) return <Forbidden />;
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingUser(null)
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
role_id: roles[0]?.id || '',
|
||||
is_active: true
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role_id: roles[0]?.id || "",
|
||||
is_active: true,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
password: "",
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role_id: user.role_id,
|
||||
is_active: user.is_active
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
is_active: user.is_active,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setEditingUser(null)
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
e?.preventDefault();
|
||||
|
||||
const dataToSave = { ...formData }
|
||||
const wasEditing = editingUser
|
||||
const editingId = editingUser?.id
|
||||
const dataToSave = { ...formData };
|
||||
const wasEditing = editingUser;
|
||||
const editingId = editingUser?.id;
|
||||
|
||||
try {
|
||||
const url = wasEditing
|
||||
? `${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, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dataToSave)
|
||||
})
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(dataToSave),
|
||||
});
|
||||
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (wasEditing && currentUser && Number(editingId) === Number(currentUser.id)) {
|
||||
if (
|
||||
wasEditing &&
|
||||
currentUser &&
|
||||
Number(editingId) === Number(currentUser.id)
|
||||
) {
|
||||
updateUser({
|
||||
username: dataToSave.username,
|
||||
email: dataToSave.email,
|
||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
|
||||
})
|
||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
||||
});
|
||||
}
|
||||
closeModal()
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
alert.success(wasEditing ? 'Uživatel byl upraven' : 'Uživatel byl vytvořen')
|
||||
fetchUsers()
|
||||
closeModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
|
||||
);
|
||||
fetchUsers();
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se uložit uživatele')
|
||||
alert.error(data.error || "Nepodařilo se uložit uživatele");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteModal = (user: User) => {
|
||||
setDeleteModal({ isOpen: true, user })
|
||||
}
|
||||
setDeleteModal({ isOpen: true, user });
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setDeleteModal({ isOpen: false, user: null })
|
||||
}
|
||||
setDeleteModal({ isOpen: false, user: null });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteModal.user) return
|
||||
if (!deleteModal.user) return;
|
||||
|
||||
setDeleting(true)
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/users/${deleteModal.user.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/users/${deleteModal.user.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
closeDeleteModal()
|
||||
fetchUsers()
|
||||
alert.success('Uživatel byl smazán')
|
||||
closeDeleteModal();
|
||||
fetchUsers();
|
||||
alert.success("Uživatel byl smazán");
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se smazat uživatele')
|
||||
alert.error(data.error || "Nepodařilo se smazat uživatele");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (user: User) => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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) {
|
||||
fetchUsers()
|
||||
alert.success(user.is_active ? 'Uživatel byl deaktivován' : 'Uživatel byl aktivován')
|
||||
fetchUsers();
|
||||
alert.success(
|
||||
user.is_active
|
||||
? "Uživatel byl deaktivován"
|
||||
: "Uživatel byl aktivován",
|
||||
);
|
||||
} else {
|
||||
alert.error(data.error || 'Nepodařilo se změnit stav uživatele')
|
||||
alert.error(data.error || "Nepodařilo se změnit stav uživatele");
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeClass = (roleName: string): string => {
|
||||
switch (roleName) {
|
||||
case 'admin': return 'admin-badge admin-badge-admin'
|
||||
default: return 'admin-badge admin-badge-viewer'
|
||||
case "admin":
|
||||
return "admin-badge admin-badge-admin";
|
||||
default:
|
||||
return "admin-badge admin-badge-viewer";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
||||
</div>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '160px', borderRadius: '8px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "160px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3 mb-2" />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</div>
|
||||
@@ -254,7 +286,7 @@ export default function Users() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -267,10 +299,22 @@ export default function Users() {
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Uživatelé</h1>
|
||||
<p className="admin-page-subtitle">Správa uživatelských účtů a oprávnění</p>
|
||||
<p className="admin-page-subtitle">
|
||||
Správa uživatelských účtů a oprávnění
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -302,30 +346,43 @@ export default function Users() {
|
||||
<td>
|
||||
<div className="admin-table-user">
|
||||
<div className="admin-table-avatar">
|
||||
{(user.first_name || user.username).charAt(0).toUpperCase()}
|
||||
{(user.first_name || user.username)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="admin-table-name">
|
||||
{user.first_name} {user.last_name}
|
||||
</div>
|
||||
<div className="admin-table-username">@{user.username}</div>
|
||||
<div className="admin-table-username">
|
||||
@{user.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<span className={getRoleBadgeClass(user.roles?.name ?? '')}>
|
||||
{user.roles?.display_name || user.roles?.name || '—'}
|
||||
<span
|
||||
className={getRoleBadgeClass(user.roles?.name ?? "")}
|
||||
>
|
||||
{user.roles?.display_name || user.roles?.name || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => user.id !== currentUser?.id && toggleActive(user)}
|
||||
onClick={() =>
|
||||
user.id !== currentUser?.id && toggleActive(user)
|
||||
}
|
||||
disabled={user.id === currentUser?.id}
|
||||
className={`admin-badge ${user.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
|
||||
style={{ cursor: user.id === currentUser?.id ? 'not-allowed' : 'pointer' }}
|
||||
className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
|
||||
style={{
|
||||
cursor:
|
||||
user.id === currentUser?.id
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{user.is_active ? 'Aktivní' : 'Neaktivní'}
|
||||
{user.is_active ? "Aktivní" : "Neaktivní"}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
@@ -336,7 +393,14 @@ export default function Users() {
|
||||
title="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="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
@@ -348,7 +412,14 @@ export default function Users() {
|
||||
title="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" />
|
||||
<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>
|
||||
@@ -383,7 +454,9 @@ export default function Users() {
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -394,7 +467,12 @@ export default function Users() {
|
||||
<input
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
first_name: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
@@ -403,7 +481,12 @@ export default function Users() {
|
||||
<input
|
||||
type="text"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
last_name: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
@@ -414,7 +497,9 @@ export default function Users() {
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
@@ -424,17 +509,23 @@ export default function Users() {
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={`Heslo ${editingUser ? '(ponechte prázdné pro zachování stávajícího)' : ''}`}>
|
||||
<FormField
|
||||
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
required={!editingUser}
|
||||
className="admin-form-input"
|
||||
/>
|
||||
@@ -443,7 +534,9 @@ export default function Users() {
|
||||
<FormField label="Role">
|
||||
<select
|
||||
value={formData.role_id}
|
||||
onChange={(e) => setFormData({ ...formData, role_id: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, role_id: e.target.value })
|
||||
}
|
||||
required
|
||||
className="admin-form-select"
|
||||
>
|
||||
@@ -459,7 +552,12 @@ export default function Users() {
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
</label>
|
||||
@@ -467,11 +565,19 @@ export default function Users() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</button>
|
||||
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">
|
||||
{editingUser ? 'Uložit změny' : 'Vytvořit uživatele'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -491,5 +597,5 @@ export default function Users() {
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,209 +1,234 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
|
||||
import { formatKm } from '../utils/formatters'
|
||||
import apiFetch from '../utils/api'
|
||||
import FormField from '../components/FormField'
|
||||
const API_BASE = '/api/admin'
|
||||
import { formatKm } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
import FormField from "../components/FormField";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface Vehicle {
|
||||
id: number
|
||||
spz: string
|
||||
name: string
|
||||
brand?: string
|
||||
model?: string
|
||||
initial_km: number
|
||||
current_km: number
|
||||
trip_count: number
|
||||
is_active: boolean | number
|
||||
id: number;
|
||||
spz: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
initial_km: number;
|
||||
current_km: number;
|
||||
trip_count: number;
|
||||
is_active: boolean | number;
|
||||
}
|
||||
|
||||
interface VehicleForm {
|
||||
spz: string
|
||||
name: string
|
||||
brand: string
|
||||
model: string
|
||||
initial_km: number
|
||||
is_active: boolean
|
||||
spz: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
initial_km: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function Vehicles() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
|
||||
const [form, setForm] = useState<VehicleForm>({
|
||||
spz: '',
|
||||
name: '',
|
||||
brand: '',
|
||||
model: '',
|
||||
spz: "",
|
||||
name: "",
|
||||
brand: "",
|
||||
model: "",
|
||||
initial_km: 0,
|
||||
is_active: true
|
||||
})
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; vehicle: Vehicle | null }>({ show: false, vehicle: null })
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
show: boolean;
|
||||
vehicle: Vehicle | null;
|
||||
}>({ show: false, vehicle: null });
|
||||
|
||||
const fetchData = useCallback(async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/vehicles`)
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setVehicles(Array.isArray(result.data) ? result.data : [])
|
||||
const fetchData = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/vehicles`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setVehicles(Array.isArray(result.data) ? result.data : []);
|
||||
}
|
||||
} catch {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
} finally {
|
||||
if (showLoading) setLoading(false)
|
||||
}
|
||||
}, [alert])
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useModalLock(showModal)
|
||||
useModalLock(showModal);
|
||||
|
||||
if (!hasPermission('trips.vehicles')) return <Forbidden />
|
||||
if (!hasPermission("trips.vehicles")) return <Forbidden />;
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingVehicle(null)
|
||||
setEditingVehicle(null);
|
||||
setForm({
|
||||
spz: '',
|
||||
name: '',
|
||||
brand: '',
|
||||
model: '',
|
||||
spz: "",
|
||||
name: "",
|
||||
brand: "",
|
||||
model: "",
|
||||
initial_km: 0,
|
||||
is_active: true
|
||||
})
|
||||
setErrors({})
|
||||
setShowModal(true)
|
||||
}
|
||||
is_active: true,
|
||||
});
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (vehicle: Vehicle) => {
|
||||
setEditingVehicle(vehicle)
|
||||
setEditingVehicle(vehicle);
|
||||
setForm({
|
||||
spz: vehicle.spz,
|
||||
name: vehicle.name,
|
||||
brand: vehicle.brand || '',
|
||||
model: vehicle.model || '',
|
||||
brand: vehicle.brand || "",
|
||||
model: vehicle.model || "",
|
||||
initial_km: vehicle.initial_km,
|
||||
is_active: Boolean(vehicle.is_active)
|
||||
})
|
||||
setErrors({})
|
||||
setShowModal(true)
|
||||
}
|
||||
is_active: Boolean(vehicle.is_active),
|
||||
});
|
||||
setErrors({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!form.spz) newErrors.spz = 'Zadejte SPZ'
|
||||
if (!form.name) newErrors.name = 'Zadejte název'
|
||||
setErrors(newErrors)
|
||||
if (Object.keys(newErrors).length > 0) return
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.spz) newErrors.spz = "Zadejte SPZ";
|
||||
if (!form.name) newErrors.name = "Zadejte název";
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
try {
|
||||
const url = editingVehicle
|
||||
? `${API_BASE}/vehicles/${editingVehicle.id}`
|
||||
: `${API_BASE}/vehicles`
|
||||
const method = editingVehicle ? 'PUT' : 'POST'
|
||||
: `${API_BASE}/vehicles`;
|
||||
const method = editingVehicle ? "PUT" : "POST";
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form)
|
||||
})
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setShowModal(false)
|
||||
await fetchData(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
alert.success(result.message)
|
||||
setShowModal(false);
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.vehicle) return
|
||||
if (!deleteConfirm.vehicle) return;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, vehicle: null })
|
||||
await fetchData(false)
|
||||
alert.success(result.message)
|
||||
setDeleteConfirm({ show: false, vehicle: null });
|
||||
await fetchData(false);
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (vehicle: Vehicle) => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
spz: vehicle.spz,
|
||||
name: vehicle.name,
|
||||
brand: vehicle.brand || '',
|
||||
model: vehicle.model || '',
|
||||
brand: vehicle.brand || "",
|
||||
model: vehicle.model || "",
|
||||
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) {
|
||||
fetchData(false)
|
||||
alert.success(vehicle.is_active ? 'Vozidlo bylo deaktivováno' : 'Vozidlo bylo aktivováno')
|
||||
fetchData(false);
|
||||
alert.success(
|
||||
vehicle.is_active
|
||||
? "Vozidlo bylo deaktivováno"
|
||||
: "Vozidlo bylo aktivováno",
|
||||
);
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
alert.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px" }}
|
||||
/>
|
||||
</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 className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3 mb-2" />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</div>
|
||||
@@ -211,7 +236,7 @@ export default function Vehicles() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -226,8 +251,18 @@ export default function Vehicles() {
|
||||
<h1 className="admin-page-title">Správa vozidel</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -246,7 +281,16 @@ export default function Vehicles() {
|
||||
{vehicles.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" />
|
||||
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
|
||||
<circle cx="5.5" cy="18.5" r="2.5" />
|
||||
@@ -254,7 +298,10 @@ export default function Vehicles() {
|
||||
</svg>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -276,23 +323,32 @@ export default function Vehicles() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>{vehicle.name}</td>
|
||||
<td>
|
||||
{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 className="admin-mono">{formatKm(vehicle.initial_km)} km</td>
|
||||
<td className="admin-mono fw-500">{formatKm(vehicle.current_km)} km</td>
|
||||
<td className="admin-mono">{vehicle.trip_count}</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => toggleActive(vehicle)}
|
||||
className={`admin-badge ${vehicle.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}`}
|
||||
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
|
||||
>
|
||||
{vehicle.is_active ? 'Aktivní' : 'Neaktivní'}
|
||||
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
@@ -303,18 +359,38 @@ export default function Vehicles() {
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ show: true, vehicle })}
|
||||
onClick={() =>
|
||||
setDeleteConfirm({ show: true, vehicle })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
@@ -340,7 +416,10 @@ export default function Vehicles() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShowModal(false)}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -350,7 +429,7 @@ export default function Vehicles() {
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">
|
||||
{editingVehicle ? 'Upravit vozidlo' : 'Přidat vozidlo'}
|
||||
{editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -362,8 +441,11 @@ export default function Vehicles() {
|
||||
type="text"
|
||||
value={form.spz}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, spz: e.target.value.toUpperCase() })
|
||||
setErrors(prev => ({ ...prev, spz: '' }))
|
||||
setForm({
|
||||
...form,
|
||||
spz: e.target.value.toUpperCase(),
|
||||
});
|
||||
setErrors((prev) => ({ ...prev, spz: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="1AB 2345"
|
||||
@@ -376,8 +458,8 @@ export default function Vehicles() {
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value })
|
||||
setErrors(prev => ({ ...prev, name: '' }))
|
||||
setForm({ ...form, name: e.target.value });
|
||||
setErrors((prev) => ({ ...prev, name: "" }));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Služební #1"
|
||||
@@ -391,7 +473,9 @@ export default function Vehicles() {
|
||||
<input
|
||||
type="text"
|
||||
value={form.brand}
|
||||
onChange={(e) => setForm({ ...form, brand: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, brand: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Škoda"
|
||||
/>
|
||||
@@ -401,7 +485,9 @@ export default function Vehicles() {
|
||||
<input
|
||||
type="text"
|
||||
value={form.model}
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, model: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Octavia Combi"
|
||||
/>
|
||||
@@ -409,12 +495,19 @@ export default function Vehicles() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
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"
|
||||
min="0"
|
||||
/>
|
||||
@@ -427,7 +520,9 @@ export default function Vehicles() {
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
</label>
|
||||
@@ -461,10 +556,14 @@ export default function Vehicles() {
|
||||
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
|
||||
onConfirm={handleDelete}
|
||||
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"
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,109 @@
|
||||
let showSessionExpiredAlert = false
|
||||
let showLogoutAlert = false
|
||||
let getTokenFn: (() => string | null) | null = null
|
||||
let refreshFn: (() => Promise<boolean>) | null = null
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
let showSessionExpiredAlert = false;
|
||||
let showLogoutAlert = false;
|
||||
let getTokenFn: (() => string | null) | null = null;
|
||||
let refreshFn: (() => Promise<boolean>) | null = null;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
export const shouldShowSessionExpiredAlert = (): boolean => {
|
||||
if (showSessionExpiredAlert) {
|
||||
showSessionExpiredAlert = false
|
||||
return true
|
||||
showSessionExpiredAlert = false;
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setSessionExpired = (): void => {
|
||||
showSessionExpiredAlert = true
|
||||
}
|
||||
showSessionExpiredAlert = true;
|
||||
};
|
||||
|
||||
export const shouldShowLogoutAlert = (): boolean => {
|
||||
if (showLogoutAlert) {
|
||||
showLogoutAlert = false
|
||||
return true
|
||||
showLogoutAlert = false;
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setLogoutAlert = (): void => {
|
||||
showLogoutAlert = true
|
||||
}
|
||||
showLogoutAlert = true;
|
||||
};
|
||||
|
||||
export const setTokenGetter = (fn: () => string | null): void => {
|
||||
getTokenFn = fn
|
||||
}
|
||||
getTokenFn = fn;
|
||||
};
|
||||
|
||||
export const setRefreshFn = (fn: () => Promise<boolean>): void => {
|
||||
refreshFn = fn
|
||||
}
|
||||
refreshFn = fn;
|
||||
};
|
||||
|
||||
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
let token: string | null = null
|
||||
export const apiFetch = async (
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = getTokenFn ? getTokenFn() : null
|
||||
token = getTokenFn ? getTokenFn() : null;
|
||||
} catch {
|
||||
// token retrieval failed
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
};
|
||||
|
||||
if (!headers['Content-Type'] && options.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
if (
|
||||
!headers["Content-Type"] &&
|
||||
options.body &&
|
||||
!(options.body instanceof FormData)
|
||||
) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.status === 401 && refreshFn) {
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshFn().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
const refreshed = await refreshPromise
|
||||
const refreshed = await refreshPromise;
|
||||
if (refreshed) {
|
||||
token = getTokenFn ? getTokenFn() : null
|
||||
token = getTokenFn ? getTokenFn() : null;
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
credentials: "include",
|
||||
});
|
||||
} else {
|
||||
setSessionExpired()
|
||||
setSessionExpired();
|
||||
}
|
||||
} catch {
|
||||
setSessionExpired()
|
||||
setSessionExpired();
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
try {
|
||||
return getTokenFn ? getTokenFn() : null
|
||||
return getTokenFn ? getTokenFn() : null;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default apiFetch
|
||||
export default apiFetch;
|
||||
|
||||
@@ -1,169 +1,189 @@
|
||||
interface AttendanceRecord {
|
||||
arrival_time?: string | null
|
||||
departure_time?: string | null
|
||||
break_start?: string | null
|
||||
break_end?: string | null
|
||||
leave_type?: string
|
||||
leave_hours?: number
|
||||
shift_date?: string
|
||||
notes?: string
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
break_start?: string | null;
|
||||
break_end?: string | null;
|
||||
leave_type?: string;
|
||||
leave_hours?: number;
|
||||
shift_date?: string;
|
||||
notes?: string;
|
||||
project_logs?: Array<{
|
||||
id?: number
|
||||
project_id?: number
|
||||
project_name?: string
|
||||
started_at?: string
|
||||
ended_at?: string | null
|
||||
hours?: string | number | null
|
||||
minutes?: string | number | null
|
||||
}>
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
project_name?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string | null;
|
||||
hours?: string | number | null;
|
||||
minutes?: string | number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const formatDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('cs-CZ')
|
||||
}
|
||||
if (!dateStr) return "—";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("cs-CZ");
|
||||
};
|
||||
|
||||
/** Extract time as HH:MM from a datetime string without timezone conversion */
|
||||
const extractTime = (datetime: string): string => {
|
||||
// Try ISO format: "2026-03-23T08:00:00.000Z" or "2026-03-23T08:00:00"
|
||||
const tMatch = datetime.match(/T(\d{2}):(\d{2})/)
|
||||
if (tMatch) return `${tMatch[1]}:${tMatch[2]}`
|
||||
const tMatch = datetime.match(/T(\d{2}):(\d{2})/);
|
||||
if (tMatch) return `${tMatch[1]}:${tMatch[2]}`;
|
||||
// Try space format: "2026-03-23 08:00:00"
|
||||
const sMatch = datetime.match(/\s(\d{2}):(\d{2})/)
|
||||
if (sMatch) return `${sMatch[1]}:${sMatch[2]}`
|
||||
const sMatch = datetime.match(/\s(\d{2}):(\d{2})/);
|
||||
if (sMatch) return `${sMatch[1]}:${sMatch[2]}`;
|
||||
// Fallback: try parsing time-only "08:00"
|
||||
const hMatch = datetime.match(/^(\d{2}):(\d{2})/)
|
||||
if (hMatch) return `${hMatch[1]}:${hMatch[2]}`
|
||||
return datetime
|
||||
}
|
||||
const hMatch = datetime.match(/^(\d{2}):(\d{2})/);
|
||||
if (hMatch) return `${hMatch[1]}:${hMatch[2]}`;
|
||||
return datetime;
|
||||
};
|
||||
|
||||
export const formatDatetime = (datetime: string | null | undefined): string => {
|
||||
if (!datetime) return '—'
|
||||
if (!datetime) return "—";
|
||||
// Extract date part without timezone conversion
|
||||
const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/)
|
||||
const datePart = dMatch ? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.` : ''
|
||||
return `${datePart} ${extractTime(datetime)}`
|
||||
}
|
||||
const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
const datePart = dMatch
|
||||
? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.`
|
||||
: "";
|
||||
return `${datePart} ${extractTime(datetime)}`;
|
||||
};
|
||||
|
||||
export const formatTime = (datetime: string | null | undefined): string => {
|
||||
if (!datetime) return '—'
|
||||
return extractTime(datetime)
|
||||
}
|
||||
if (!datetime) return "—";
|
||||
return extractTime(datetime);
|
||||
};
|
||||
|
||||
export const calculateWorkMinutes = (record: AttendanceRecord): number => {
|
||||
if (!record.arrival_time || !record.departure_time) return 0
|
||||
const arrival = new Date(record.arrival_time).getTime()
|
||||
const departure = new Date(record.departure_time).getTime()
|
||||
let minutes = (departure - arrival) / 60000
|
||||
if (!record.arrival_time || !record.departure_time) return 0;
|
||||
const arrival = new Date(record.arrival_time).getTime();
|
||||
const departure = new Date(record.departure_time).getTime();
|
||||
let minutes = (departure - arrival) / 60000;
|
||||
|
||||
if (record.break_start && record.break_end) {
|
||||
const breakStart = new Date(record.break_start).getTime()
|
||||
const breakEnd = new Date(record.break_end).getTime()
|
||||
minutes -= (breakEnd - breakStart) / 60000
|
||||
const breakStart = new Date(record.break_start).getTime();
|
||||
const breakEnd = new Date(record.break_end).getTime();
|
||||
minutes -= (breakEnd - breakStart) / 60000;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(minutes))
|
||||
}
|
||||
return Math.max(0, Math.floor(minutes));
|
||||
};
|
||||
|
||||
export const formatMinutes = (minutes: number, withUnit = false): string => {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return `${h}:${String(m).padStart(2, '0')}${withUnit ? ' h' : ''}`
|
||||
}
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${h}:${String(m).padStart(2, "0")}${withUnit ? " h" : ""}`;
|
||||
};
|
||||
|
||||
export const getLeaveTypeName = (type: string): string => {
|
||||
const types: Record<string, string> = {
|
||||
work: 'Práce',
|
||||
vacation: 'Dovolená',
|
||||
sick: 'Nemoc',
|
||||
holiday: 'Svátek',
|
||||
unpaid: 'Neplacené volno',
|
||||
}
|
||||
return types[type] || 'Práce'
|
||||
}
|
||||
work: "Práce",
|
||||
vacation: "Dovolená",
|
||||
sick: "Nemoc",
|
||||
holiday: "Svátek",
|
||||
unpaid: "Neplacené volno",
|
||||
};
|
||||
return types[type] || "Práce";
|
||||
};
|
||||
|
||||
export const getLeaveTypeBadgeClass = (type: string): string => {
|
||||
const classes: Record<string, string> = {
|
||||
vacation: 'badge-vacation',
|
||||
sick: 'badge-sick',
|
||||
holiday: 'badge-holiday',
|
||||
unpaid: 'badge-unpaid',
|
||||
}
|
||||
return classes[type] || ''
|
||||
}
|
||||
vacation: "badge-vacation",
|
||||
sick: "badge-sick",
|
||||
holiday: "badge-holiday",
|
||||
unpaid: "badge-unpaid",
|
||||
};
|
||||
return classes[type] || "";
|
||||
};
|
||||
|
||||
export const getDatePart = (datetime: string | null | undefined): string => {
|
||||
if (!datetime) return ''
|
||||
if (datetime.includes('T')) {
|
||||
return datetime.split('T')[0]
|
||||
if (!datetime) return "";
|
||||
if (datetime.includes("T")) {
|
||||
return datetime.split("T")[0];
|
||||
}
|
||||
return datetime.split(' ')[0]
|
||||
}
|
||||
return datetime.split(" ")[0];
|
||||
};
|
||||
|
||||
export const getTimePart = (datetime: string | null | undefined): string => {
|
||||
if (!datetime) return ''
|
||||
const d = new Date(datetime)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
if (!datetime) return "";
|
||||
const d = new Date(datetime);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
export const calcProjectMinutesTotal = (logs: Array<{ project_id?: number; hours?: string | number; minutes?: string | number }>): number => {
|
||||
return logs.filter(l => l.project_id).reduce((sum, l) => {
|
||||
return sum + (parseInt(String(l.hours)) || 0) * 60 + (parseInt(String(l.minutes)) || 0)
|
||||
}, 0)
|
||||
}
|
||||
export const calcProjectMinutesTotal = (
|
||||
logs: Array<{
|
||||
project_id?: number;
|
||||
hours?: string | number;
|
||||
minutes?: string | number;
|
||||
}>,
|
||||
): number => {
|
||||
return logs
|
||||
.filter((l) => l.project_id)
|
||||
.reduce((sum, l) => {
|
||||
return (
|
||||
sum +
|
||||
(parseInt(String(l.hours)) || 0) * 60 +
|
||||
(parseInt(String(l.minutes)) || 0)
|
||||
);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
interface ShiftForm {
|
||||
arrival_time?: string
|
||||
departure_time?: string
|
||||
arrival_date?: string
|
||||
departure_date?: string
|
||||
break_start_time?: string
|
||||
break_end_time?: string
|
||||
break_start_date?: string
|
||||
break_end_date?: string
|
||||
arrival_time?: string;
|
||||
departure_time?: string;
|
||||
arrival_date?: string;
|
||||
departure_date?: string;
|
||||
break_start_time?: string;
|
||||
break_end_time?: string;
|
||||
break_start_date?: string;
|
||||
break_end_date?: string;
|
||||
}
|
||||
|
||||
export const calcFormWorkMinutes = (form: ShiftForm): number => {
|
||||
if (!form.arrival_time || !form.departure_time) return 0
|
||||
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`
|
||||
const departureStr = `${form.departure_date}T${form.departure_time}`
|
||||
let mins = (new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000
|
||||
if (!form.arrival_time || !form.departure_time) return 0;
|
||||
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`;
|
||||
const departureStr = `${form.departure_date}T${form.departure_time}`;
|
||||
let mins =
|
||||
(new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000;
|
||||
if (form.break_start_time && form.break_end_time) {
|
||||
const bsStr = `${form.break_start_date}T${form.break_start_time}`
|
||||
const beStr = `${form.break_end_date}T${form.break_end_time}`
|
||||
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000
|
||||
const bsStr = `${form.break_start_date}T${form.break_start_time}`;
|
||||
const beStr = `${form.break_end_date}T${form.break_end_time}`;
|
||||
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000;
|
||||
}
|
||||
return Math.max(0, Math.floor(mins))
|
||||
}
|
||||
return Math.max(0, Math.floor(mins));
|
||||
};
|
||||
|
||||
export const formatTimeOrDatetimePrint = (datetime: string | null | undefined, shiftDate: string): string => {
|
||||
if (!datetime) return '—'
|
||||
export const formatTimeOrDatetimePrint = (
|
||||
datetime: string | null | undefined,
|
||||
shiftDate: string,
|
||||
): string => {
|
||||
if (!datetime) return "—";
|
||||
// Extract date from the datetime string directly (no timezone conversion)
|
||||
const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/)
|
||||
const timeDate = dateMatch ? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}` : ''
|
||||
const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
const timeDate = dateMatch
|
||||
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
|
||||
: "";
|
||||
if (timeDate && timeDate !== shiftDate) {
|
||||
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`
|
||||
return `${datePart} ${extractTime(datetime)}`
|
||||
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
|
||||
return `${datePart} ${extractTime(datetime)}`;
|
||||
}
|
||||
return extractTime(datetime)
|
||||
}
|
||||
return extractTime(datetime);
|
||||
};
|
||||
|
||||
export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
|
||||
const leaveType = record.leave_type || 'work'
|
||||
if (leaveType !== 'work') {
|
||||
return (Number(record.leave_hours) || 8) * 60
|
||||
const leaveType = record.leave_type || "work";
|
||||
if (leaveType !== "work") {
|
||||
return (Number(record.leave_hours) || 8) * 60;
|
||||
}
|
||||
if (!record.arrival_time || !record.departure_time) return 0
|
||||
const arrival = new Date(record.arrival_time).getTime()
|
||||
const departure = new Date(record.departure_time).getTime()
|
||||
let minutes = (departure - arrival) / 60000
|
||||
if (!record.arrival_time || !record.departure_time) return 0;
|
||||
const arrival = new Date(record.arrival_time).getTime();
|
||||
const departure = new Date(record.departure_time).getTime();
|
||||
let minutes = (departure - arrival) / 60000;
|
||||
|
||||
if (record.break_start && record.break_end) {
|
||||
const breakStart = new Date(record.break_start).getTime()
|
||||
const breakEnd = new Date(record.break_end).getTime()
|
||||
minutes -= (breakEnd - breakStart) / 60000
|
||||
const breakStart = new Date(record.break_start).getTime();
|
||||
const breakEnd = new Date(record.break_end).getTime();
|
||||
minutes -= (breakEnd - breakStart) / 60000;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(minutes))
|
||||
}
|
||||
return Math.max(0, Math.floor(minutes));
|
||||
};
|
||||
|
||||
@@ -1,79 +1,114 @@
|
||||
export const LEAVE_TYPE_LABELS: Record<string, string> = {
|
||||
vacation: 'Dovolená',
|
||||
sick: 'Nemoc',
|
||||
holiday: 'Svátek',
|
||||
unpaid: 'Neplacené volno',
|
||||
}
|
||||
vacation: "Dovolená",
|
||||
sick: "Nemoc",
|
||||
holiday: "Svátek",
|
||||
unpaid: "Neplacené volno",
|
||||
};
|
||||
|
||||
export const STATUS_DOT_CLASS: Record<string, string> = {
|
||||
in: 'dash-status-in',
|
||||
away: 'dash-status-away',
|
||||
out: 'dash-status-out',
|
||||
leave: 'dash-status-leave',
|
||||
}
|
||||
in: "dash-status-in",
|
||||
away: "dash-status-away",
|
||||
out: "dash-status-out",
|
||||
leave: "dash-status-leave",
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
in: 'Přítomen',
|
||||
away: 'Přestávka',
|
||||
out: 'Nepřihlášen',
|
||||
leave: 'Nepřítomen',
|
||||
}
|
||||
in: "Přítomen",
|
||||
away: "Přestávka",
|
||||
out: "Nepřihlášen",
|
||||
leave: "Nepřítomen",
|
||||
};
|
||||
|
||||
export const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
user: 'Uživatel',
|
||||
attendance: 'Docházka',
|
||||
leave_request: 'Žádost o nepřítomnost',
|
||||
offers_quotation: 'Nabídka',
|
||||
offers_customer: 'Zákazník',
|
||||
offers_item_template: 'Šablona položky',
|
||||
offers_scope_template: 'Šablona rozsahu',
|
||||
offers_settings: 'Nastavení nabídek',
|
||||
orders_order: 'Objednávka',
|
||||
invoices_invoice: 'Faktura',
|
||||
projects_project: 'Projekt',
|
||||
role: 'Role',
|
||||
trips: 'Jízda',
|
||||
vehicles: 'Vozidlo',
|
||||
bank_account: 'Bankovní účet',
|
||||
}
|
||||
user: "Uživatel",
|
||||
attendance: "Docházka",
|
||||
leave_request: "Žádost o nepřítomnost",
|
||||
offers_quotation: "Nabídka",
|
||||
offers_customer: "Zákazník",
|
||||
offers_item_template: "Šablona položky",
|
||||
offers_scope_template: "Šablona rozsahu",
|
||||
offers_settings: "Nastavení nabídek",
|
||||
orders_order: "Objednávka",
|
||||
invoices_invoice: "Faktura",
|
||||
projects_project: "Projekt",
|
||||
role: "Role",
|
||||
trips: "Jízda",
|
||||
vehicles: "Vozidlo",
|
||||
bank_account: "Bankovní účet",
|
||||
};
|
||||
|
||||
export const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'Vytvořil',
|
||||
update: 'Upravil',
|
||||
delete: 'Smazal',
|
||||
login: 'Přihlášení',
|
||||
}
|
||||
create: "Vytvořil",
|
||||
update: "Upravil",
|
||||
delete: "Smazal",
|
||||
login: "Přihlášení",
|
||||
};
|
||||
|
||||
export function getCzechDate(): string {
|
||||
const now = new Date()
|
||||
const days = ['Neděle', 'Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota']
|
||||
const months = ['ledna', 'února', 'března', 'dubna', 'května', 'června', 'července', 'srpna', 'září', 'října', 'listopadu', 'prosince']
|
||||
const day = days[now.getDay()]
|
||||
const oneJan = new Date(now.getFullYear(), 0, 1)
|
||||
const week = Math.ceil(((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7)
|
||||
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`
|
||||
const now = new Date();
|
||||
const days = [
|
||||
"Neděle",
|
||||
"Pondělí",
|
||||
"Úterý",
|
||||
"Středa",
|
||||
"Čtvrtek",
|
||||
"Pátek",
|
||||
"Sobota",
|
||||
];
|
||||
const months = [
|
||||
"ledna",
|
||||
"února",
|
||||
"března",
|
||||
"dubna",
|
||||
"května",
|
||||
"června",
|
||||
"července",
|
||||
"srpna",
|
||||
"září",
|
||||
"října",
|
||||
"listopadu",
|
||||
"prosince",
|
||||
];
|
||||
const day = days[now.getDay()];
|
||||
const oneJan = new Date(now.getFullYear(), 0, 1);
|
||||
const week = Math.ceil(
|
||||
((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7,
|
||||
);
|
||||
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`;
|
||||
}
|
||||
|
||||
export function getActivityIconClass(action: string): string {
|
||||
const map: Record<string, string> = { create: 'success', update: 'info', delete: 'danger', login: 'accent' }
|
||||
return map[action] || 'muted'
|
||||
const map: Record<string, string> = {
|
||||
create: "success",
|
||||
update: "info",
|
||||
delete: "danger",
|
||||
login: "accent",
|
||||
};
|
||||
return map[action] || "muted";
|
||||
}
|
||||
|
||||
export function formatActivityTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
if (diff < 60000) return 'Právě teď'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 60000) return "Právě teď";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`;
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
|
||||
return date.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 {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('cs-CZ', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("cs-CZ", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
export function formatCurrency(amount: number | string, currency: string): string {
|
||||
const num = Number(amount) || 0
|
||||
export function formatCurrency(
|
||||
amount: number | string,
|
||||
currency: string,
|
||||
): string {
|
||||
const num = Number(amount) || 0;
|
||||
switch (currency) {
|
||||
case 'EUR': return `${num.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
case 'USD': return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
case 'CZK': return `${num.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
|
||||
case 'GBP': return `£${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
default: return `${num.toFixed(2)} ${currency}`
|
||||
case "EUR":
|
||||
return `${num.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
||||
case "USD":
|
||||
return `$${num.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
case "CZK":
|
||||
return `${num.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`;
|
||||
case "GBP":
|
||||
return `£${num.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
default:
|
||||
return `${num.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('cs-CZ')
|
||||
if (!dateStr) return "—";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("cs-CZ");
|
||||
}
|
||||
|
||||
export function formatKm(km: number | string): string {
|
||||
return new Intl.NumberFormat('cs-CZ').format(Number(km) || 0)
|
||||
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
|
||||
}
|
||||
|
||||
export function czechPlural(n: number, one: string, few: string, many: string): string {
|
||||
if (n === 1) return one
|
||||
if (n >= 2 && n <= 4) return few
|
||||
return many
|
||||
export function czechPlural(
|
||||
n: number,
|
||||
one: string,
|
||||
few: string,
|
||||
many: string,
|
||||
): string {
|
||||
if (n === 1) return one;
|
||||
if (n >= 2 && n <= 4) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import dotenv from 'dotenv';
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
Date.prototype.toJSON = function () {
|
||||
const y = this.getFullYear();
|
||||
const m = String(this.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(this.getDate()).padStart(2, '0');
|
||||
const h = String(this.getHours()).padStart(2, '0');
|
||||
const min = String(this.getMinutes()).padStart(2, '0');
|
||||
const s = String(this.getSeconds()).padStart(2, '0');
|
||||
const m = String(this.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(this.getDate()).padStart(2, "0");
|
||||
const h = String(this.getHours()).padStart(2, "0");
|
||||
const min = String(this.getMinutes()).padStart(2, "0");
|
||||
const s = String(this.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}T${h}:${min}:${s}`;
|
||||
};
|
||||
|
||||
@@ -25,43 +25,49 @@ function required(key: string): string {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
host: process.env.HOST || '127.0.0.1',
|
||||
appEnv: process.env.APP_ENV || 'local',
|
||||
isProduction: process.env.APP_ENV === 'production',
|
||||
port: parseInt(process.env.PORT || "3001", 10),
|
||||
host: process.env.HOST || "127.0.0.1",
|
||||
appEnv: process.env.APP_ENV || "local",
|
||||
isProduction: process.env.APP_ENV === "production",
|
||||
|
||||
db: {
|
||||
url: required('DATABASE_URL'),
|
||||
url: required("DATABASE_URL"),
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: required('JWT_SECRET'),
|
||||
accessTokenExpiry: parseInt(process.env.ACCESS_TOKEN_EXPIRY || '900', 10),
|
||||
refreshTokenSessionExpiry: parseInt(process.env.REFRESH_TOKEN_SESSION_EXPIRY || '3600', 10),
|
||||
refreshTokenRememberExpiry: parseInt(process.env.REFRESH_TOKEN_REMEMBER_EXPIRY || '2592000', 10),
|
||||
secret: required("JWT_SECRET"),
|
||||
accessTokenExpiry: parseInt(process.env.ACCESS_TOKEN_EXPIRY || "900", 10),
|
||||
refreshTokenSessionExpiry: parseInt(
|
||||
process.env.REFRESH_TOKEN_SESSION_EXPIRY || "3600",
|
||||
10,
|
||||
),
|
||||
refreshTokenRememberExpiry: parseInt(
|
||||
process.env.REFRESH_TOKEN_REMEMBER_EXPIRY || "2592000",
|
||||
10,
|
||||
),
|
||||
},
|
||||
|
||||
totp: {
|
||||
encryptionKey: required('TOTP_ENCRYPTION_KEY'),
|
||||
encryptionKey: required("TOTP_ENCRYPTION_KEY"),
|
||||
},
|
||||
|
||||
nas: {
|
||||
path: process.env.NAS_PATH || 'Z:/02_PROJEKTY',
|
||||
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || '52428800', 10),
|
||||
path: process.env.NAS_PATH || "Z:/02_PROJEKTY",
|
||||
maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10),
|
||||
},
|
||||
|
||||
email: {
|
||||
contactTo: process.env.CONTACT_EMAIL_TO || '',
|
||||
contactFrom: process.env.CONTACT_EMAIL_FROM || '',
|
||||
smtpFrom: process.env.SMTP_FROM || '',
|
||||
smtpFromName: process.env.SMTP_FROM_NAME || 'BOHA Automation',
|
||||
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || '',
|
||||
contactTo: process.env.CONTACT_EMAIL_TO || "",
|
||||
contactFrom: process.env.CONTACT_EMAIL_FROM || "",
|
||||
smtpFrom: process.env.SMTP_FROM || "",
|
||||
smtpFromName: process.env.SMTP_FROM_NAME || "BOHA Automation",
|
||||
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || "",
|
||||
},
|
||||
|
||||
appUrl: process.env.APP_URL || '',
|
||||
appUrl: process.env.APP_URL || "",
|
||||
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean),
|
||||
origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean),
|
||||
},
|
||||
|
||||
security: {
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: string
|
||||
toggleTheme: () => void
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('boha-theme') || 'dark'
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("boha-theme") || "dark";
|
||||
}
|
||||
return 'dark'
|
||||
})
|
||||
return "dark";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('boha-theme', theme)
|
||||
const themeColor = theme === 'dark' ? '#12121a' : '#ffffff'
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor)
|
||||
}, [theme])
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("boha-theme", theme);
|
||||
const themeColor = theme === "dark" ? "#12121a" : "#ffffff";
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute("content", themeColor);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
return context
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
20
src/main.tsx
20
src/main.tsx
@@ -1,15 +1,17 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<BrowserRouter
|
||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { verifyAccessToken } from '../services/auth';
|
||||
import { error } from '../utils/response';
|
||||
import { AuthData } from '../types';
|
||||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
import { verifyAccessToken } from "../services/auth";
|
||||
import { error } from "../utils/response";
|
||||
import { AuthData } from "../types";
|
||||
|
||||
export async function requireAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return error(reply, 'Vyžadována autentizace', 401);
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return error(reply, "Vyžadována autentizace", 401);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const authData = await verifyAccessToken(token);
|
||||
|
||||
if (!authData) {
|
||||
return error(reply, 'Neplatný nebo expirovaný token', 401);
|
||||
return error(reply, "Neplatný nebo expirovaný token", 401);
|
||||
}
|
||||
|
||||
request.authData = authData;
|
||||
@@ -27,25 +27,30 @@ export async function optionalAuth(
|
||||
_reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) return;
|
||||
if (!authHeader?.startsWith("Bearer ")) return;
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
request.authData = (await verifyAccessToken(token)) ?? undefined;
|
||||
}
|
||||
|
||||
export function requirePermission(...permissionNames: string[]) {
|
||||
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
return async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> => {
|
||||
await requireAuth(request, reply);
|
||||
if (reply.sent) return;
|
||||
|
||||
const authData = request.authData!;
|
||||
|
||||
// Admin has all permissions
|
||||
if (authData.roleName === 'admin') return;
|
||||
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) {
|
||||
return error(reply, 'Nedostatečná oprávnění', 403);
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { config } from '../config/env';
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { config } from "../config/env";
|
||||
|
||||
export async function securityHeaders(
|
||||
_request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('X-Frame-Options', 'DENY');
|
||||
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)');
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
reply.header("X-Frame-Options", "DENY");
|
||||
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
reply.header(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(self)",
|
||||
);
|
||||
|
||||
if (config.isProduction) {
|
||||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
reply.header(
|
||||
'Content-Security-Policy',
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains",
|
||||
);
|
||||
reply.header(
|
||||
"Content-Security-Policy",
|
||||
[
|
||||
"default-src 'self'",
|
||||
"script-src 'self' https://unpkg.com",
|
||||
@@ -21,7 +27,7 @@ export async function securityHeaders(
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob: https://*.tile.openstreetmap.org",
|
||||
"connect-src 'self' https://nominatim.openstreetmap.org",
|
||||
].join('; '),
|
||||
].join("; "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireAuth, requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error, parseId } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
AttendanceNotesSchema,
|
||||
AttendanceUpdateAddressSchema,
|
||||
@@ -14,124 +14,158 @@ import {
|
||||
AttendancePunchSchema,
|
||||
CreateAttendanceSchema,
|
||||
UpdateAttendanceSchema,
|
||||
} from '../../schemas/attendance.schema';
|
||||
import * as attendanceService from '../../services/attendance.service';
|
||||
|
||||
export default async function attendanceRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
} from "../../schemas/attendance.schema";
|
||||
import * as attendanceService from "../../services/attendance.service";
|
||||
|
||||
export default async function attendanceRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// GET /api/admin/attendance/status — clock-in/out page data
|
||||
fastify.get('/status', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const data = await attendanceService.getStatus(authData.userId);
|
||||
return reply.send({ success: true, data });
|
||||
});
|
||||
fastify.get(
|
||||
"/status",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const data = await attendanceService.getStatus(authData.userId);
|
||||
return reply.send({ success: true, data });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/attendance/notes — save shift notes
|
||||
fastify.post('/notes', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceNotesSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/notes",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceNotesSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await attendanceService.saveNotes(authData.userId, 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');
|
||||
});
|
||||
const result = await attendanceService.saveNotes(
|
||||
authData.userId,
|
||||
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
|
||||
fastify.post('/update-address', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceUpdateAddressSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/update-address",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceUpdateAddressSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await attendanceService.updateAddress(authData.userId, body.address ?? null, body.punch_action);
|
||||
if ('error' in result) return error(reply, result.error!, 404);
|
||||
return success(reply, null, 200, 'Adresa aktualizována');
|
||||
});
|
||||
const result = await attendanceService.updateAddress(
|
||||
authData.userId,
|
||||
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
|
||||
fastify.post('/switch-project', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceSwitchProjectSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/switch-project",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceSwitchProjectSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const newProjectId = body.project_id ? Number(body.project_id) : null;
|
||||
const result = await attendanceService.switchProject(authData.userId, newProjectId);
|
||||
if ('error' in result) return error(reply, result.error!, 400);
|
||||
return success(reply, null, 200, 'Projekt přepnut');
|
||||
});
|
||||
const newProjectId = body.project_id ? Number(body.project_id) : null;
|
||||
const result = await attendanceService.switchProject(
|
||||
authData.userId,
|
||||
newProjectId,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error!, 400);
|
||||
return success(reply, null, 200, "Projekt přepnut");
|
||||
},
|
||||
);
|
||||
|
||||
// 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 authData = request.authData!;
|
||||
const action = query.action ? String(query.action) : null;
|
||||
|
||||
// --- action=balances: leave balance overview for all users ---
|
||||
if (action === 'balances') {
|
||||
if (action === "balances") {
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getBalances(yr);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=workfund: monthly work fund overview ---
|
||||
if (action === 'workfund') {
|
||||
if (action === "workfund") {
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getWorkfund(yr);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=project_report: monthly project hours ---
|
||||
if (action === 'project_report') {
|
||||
if (action === "project_report") {
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getProjectReport(yr);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=print: attendance print data for admin ---
|
||||
if (action === 'print') {
|
||||
if (!authData.permissions.includes('attendance.admin')) {
|
||||
return error(reply, 'Nedostatečná oprávnění', 403);
|
||||
if (action === "print") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
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 data = await attendanceService.getPrintData(monthStr, filterUserId);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=projects: active projects for attendance project switching ---
|
||||
if (action === 'projects') {
|
||||
if (action === "projects") {
|
||||
const data = await attendanceService.getActiveProjects();
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
if (!attendanceId) return error(reply, 'Missing attendance_id', 400);
|
||||
if (!attendanceId) return error(reply, "Missing attendance_id", 400);
|
||||
const data = await attendanceService.getProjectLogs(attendanceId);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=location: single record with GPS data ---
|
||||
if (action === 'location') {
|
||||
if (action === "location") {
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
|
||||
// --- Default: paginated records list ---
|
||||
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 result = await attendanceService.listAttendance({
|
||||
page, limit, skip, order, userId, isAdmin,
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
order,
|
||||
userId,
|
||||
isAdmin,
|
||||
authUserId: authData.userId,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
@@ -145,19 +179,19 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
});
|
||||
|
||||
// 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 authData = request.authData!;
|
||||
const postQuery = request.query as Record<string, unknown>;
|
||||
|
||||
// --- action=balances: edit or reset leave balance ---
|
||||
if (postQuery.action === 'balances') {
|
||||
if (!authData.permissions.includes('attendance.balances')) {
|
||||
return error(reply, 'Nedostatečná oprávnění', 403);
|
||||
if (postQuery.action === "balances") {
|
||||
if (!authData.permissions.includes("attendance.balances")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
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 result = await attendanceService.handleBalances({
|
||||
@@ -169,12 +203,15 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
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({
|
||||
request, authData, action: 'update', entityType: 'leave_balance',
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "leave_balance",
|
||||
entityId: balBody.user_id,
|
||||
description: result.message.includes('resetována')
|
||||
description: result.message.includes("resetována")
|
||||
? `Resetována bilance 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 ---
|
||||
if (postQuery.action === 'bulk_attendance') {
|
||||
if (!authData.permissions.includes('attendance.admin')) {
|
||||
return error(reply, 'Nedostatečná oprávnění', 403);
|
||||
if (postQuery.action === "bulk_attendance") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
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 result = await attendanceService.bulkCreateAttendance({
|
||||
@@ -201,36 +238,48 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request, authData, action: 'create', entityType: 'attendance',
|
||||
entityId: 0, description: `Hromadně vytvořeno ${result.inserted} záznamů docházky pro ${bulkBody.month}`,
|
||||
request,
|
||||
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 ---
|
||||
if (postQuery.action === 'leave') {
|
||||
if (postQuery.action === "leave") {
|
||||
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 result = await attendanceService.createLeave({
|
||||
user_id: leaveBody.user_id,
|
||||
date_from: leaveBody.date_from,
|
||||
date_to: leaveBody.date_to,
|
||||
leave_type: leaveBody.leave_type,
|
||||
leave_hours: leaveBody.leave_hours,
|
||||
notes: leaveBody.notes ?? undefined,
|
||||
}, authData.userId);
|
||||
const result = await attendanceService.createLeave(
|
||||
{
|
||||
user_id: leaveBody.user_id,
|
||||
date_from: leaveBody.date_from,
|
||||
date_to: leaveBody.date_to,
|
||||
leave_type: leaveBody.leave_type,
|
||||
leave_hours: leaveBody.leave_hours,
|
||||
notes: leaveBody.notes ?? undefined,
|
||||
},
|
||||
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!);
|
||||
}
|
||||
|
||||
// Punch action (arrival / departure / break_start) from Dashboard or Attendance page
|
||||
if (rawBody.punch_action) {
|
||||
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 result = await attendanceService.punchAction(authData.userId, {
|
||||
@@ -241,106 +290,132 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
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({
|
||||
request, authData, action: result.auditAction, entityType: 'attendance',
|
||||
entityId: result.id, description: result.auditDescription,
|
||||
request,
|
||||
authData,
|
||||
action: result.auditAction,
|
||||
entityType: "attendance",
|
||||
entityId: result.id,
|
||||
description: result.auditDescription,
|
||||
});
|
||||
return success(reply, { id: result.id }, result.status, result.message);
|
||||
}
|
||||
|
||||
// Standard attendance record creation (from admin forms)
|
||||
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 result = await attendanceService.createAttendance({
|
||||
user_id: body.user_id,
|
||||
shift_date: body.shift_date,
|
||||
arrival_time: body.arrival_time,
|
||||
arrival_lat: body.arrival_lat,
|
||||
arrival_lng: body.arrival_lng,
|
||||
arrival_accuracy: body.arrival_accuracy,
|
||||
arrival_address: body.arrival_address,
|
||||
departure_time: body.departure_time,
|
||||
departure_lat: body.departure_lat,
|
||||
departure_lng: body.departure_lng,
|
||||
departure_accuracy: body.departure_accuracy,
|
||||
departure_address: body.departure_address,
|
||||
notes: body.notes,
|
||||
project_id: body.project_id,
|
||||
leave_type: body.leave_type,
|
||||
leave_hours: body.leave_hours,
|
||||
project_logs: body.project_logs,
|
||||
}, authData.userId);
|
||||
const result = await attendanceService.createAttendance(
|
||||
{
|
||||
user_id: body.user_id,
|
||||
shift_date: body.shift_date,
|
||||
arrival_time: body.arrival_time,
|
||||
arrival_lat: body.arrival_lat,
|
||||
arrival_lng: body.arrival_lng,
|
||||
arrival_accuracy: body.arrival_accuracy,
|
||||
arrival_address: body.arrival_address,
|
||||
departure_time: body.departure_time,
|
||||
departure_lat: body.departure_lat,
|
||||
departure_lng: body.departure_lng,
|
||||
departure_accuracy: body.departure_accuracy,
|
||||
departure_address: body.departure_address,
|
||||
notes: body.notes,
|
||||
project_id: body.project_id,
|
||||
leave_type: body.leave_type,
|
||||
leave_hours: body.leave_hours,
|
||||
project_logs: body.project_logs,
|
||||
},
|
||||
authData.userId,
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: 'create',
|
||||
entityType: 'attendance',
|
||||
action: "create",
|
||||
entityType: "attendance",
|
||||
entityId: result.id,
|
||||
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
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateAttendanceSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateAttendanceSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const authData = request.authData!;
|
||||
const isAdmin = authData.permissions.includes('attendance.admin');
|
||||
const authData = request.authData!;
|
||||
const isAdmin = authData.permissions.includes("attendance.admin");
|
||||
|
||||
const result = await attendanceService.updateAttendance(id, {
|
||||
arrival_time: body.arrival_time,
|
||||
departure_time: body.departure_time,
|
||||
break_start: body.break_start,
|
||||
break_end: body.break_end,
|
||||
notes: body.notes ?? undefined,
|
||||
project_id: body.project_id != null ? Number(body.project_id) : (body.project_id as number | null | undefined),
|
||||
leave_type: body.leave_type,
|
||||
leave_hours: body.leave_hours != null ? Number(body.leave_hours) : (body.leave_hours as number | null | undefined),
|
||||
project_logs: body.project_logs,
|
||||
}, authData.userId, isAdmin);
|
||||
const result = await attendanceService.updateAttendance(
|
||||
id,
|
||||
{
|
||||
arrival_time: body.arrival_time,
|
||||
departure_time: body.departure_time,
|
||||
break_start: body.break_start,
|
||||
break_end: body.break_end,
|
||||
notes: body.notes ?? undefined,
|
||||
project_id:
|
||||
body.project_id != null
|
||||
? Number(body.project_id)
|
||||
: (body.project_id as number | null | undefined),
|
||||
leave_type: body.leave_type,
|
||||
leave_hours:
|
||||
body.leave_hours != null
|
||||
? Number(body.leave_hours)
|
||||
: (body.leave_hours as number | null | undefined),
|
||||
project_logs: body.project_logs,
|
||||
},
|
||||
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({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'update',
|
||||
entityType: 'attendance',
|
||||
entityId: id,
|
||||
description: `Upraven záznam docházky`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "attendance",
|
||||
entityId: id,
|
||||
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
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('attendance.admin') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("attendance.admin") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const result = await attendanceService.deleteAttendance(id);
|
||||
if ('error' in result) return error(reply, result.error!, 404);
|
||||
const result = await attendanceService.deleteAttendance(id);
|
||||
if ("error" in result) return error(reply, result.error!, 404);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'delete',
|
||||
entityType: 'attendance',
|
||||
entityId: id,
|
||||
description: `Smazán záznam docházky`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "attendance",
|
||||
entityId: id,
|
||||
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");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,76 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { success, paginated, error } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { success, paginated, error } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
|
||||
export default async function auditLogRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
export default async function auditLogRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("settings.audit") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.action) where.action = String(query.action);
|
||||
if (query.entity_type) where.entity_type = String(query.entity_type);
|
||||
if (query.user_id) where.user_id = Number(query.user_id);
|
||||
if (search) where.description = { contains: search };
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.action) where.action = String(query.action);
|
||||
if (query.entity_type) where.entity_type = String(query.entity_type);
|
||||
if (query.user_id) where.user_id = Number(query.user_id);
|
||||
if (search) where.description = { contains: search };
|
||||
|
||||
if (query.date_from || query.date_to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
|
||||
if (query.date_to) dateFilter.lte = new Date(String(query.date_to) + 'T23:59:59');
|
||||
where.created_at = dateFilter;
|
||||
}
|
||||
if (query.date_from || query.date_to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
|
||||
if (query.date_to)
|
||||
dateFilter.lte = new Date(String(query.date_to) + "T23:59:59");
|
||||
where.created_at = dateFilter;
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.audit_logs.findMany({ where, skip, take: limit, orderBy: { created_at: order } }),
|
||||
prisma.audit_logs.count({ where }),
|
||||
]);
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.audit_logs.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { created_at: order },
|
||||
}),
|
||||
prisma.audit_logs.count({ where }),
|
||||
]);
|
||||
|
||||
return paginated(reply, logs, buildPaginationMeta(total, page, limit));
|
||||
});
|
||||
return paginated(reply, logs, buildPaginationMeta(total, page, limit));
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/audit-log/cleanup — delete old audit logs
|
||||
fastify.post('/cleanup', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const days = body.days !== undefined ? Number(body.days) : null;
|
||||
fastify.post(
|
||||
"/cleanup",
|
||||
{ preHandler: requirePermission("settings.audit") },
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const days = body.days !== undefined ? Number(body.days) : null;
|
||||
|
||||
// days === 0 means "delete all" (from frontend "Vše" option)
|
||||
if (days === 0 || body.action === 'all') {
|
||||
const result = await prisma.audit_logs.deleteMany({});
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
|
||||
}
|
||||
// days === 0 means "delete all" (from frontend "Vše" option)
|
||||
if (days === 0 || body.action === "all") {
|
||||
const result = await prisma.audit_logs.deleteMany({});
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
|
||||
}
|
||||
|
||||
if (days && days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const result = await prisma.audit_logs.deleteMany({ where: { created_at: { lt: cutoff } } });
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů starších než ${days} dní`);
|
||||
}
|
||||
if (days && days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const result = await prisma.audit_logs.deleteMany({
|
||||
where: { created_at: { lt: cutoff } },
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
null,
|
||||
200,
|
||||
`Smazáno ${result.count} záznamů starších než ${days} dní`,
|
||||
);
|
||||
}
|
||||
|
||||
return error(reply, 'Zadejte počet dní', 400);
|
||||
});
|
||||
return error(reply, "Zadejte počet dní", 400);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,164 +1,208 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { login, refreshAccessToken, logout, verifyAccessToken } from '../../services/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error } from '../../utils/response';
|
||||
import { config } from '../../config/env';
|
||||
import { LoginRequest, TotpVerifyRequest } from '../../types';
|
||||
import prisma from '../../config/database';
|
||||
import crypto from 'crypto';
|
||||
import { OTPAuth } from '../../utils/totp';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {
|
||||
login,
|
||||
refreshAccessToken,
|
||||
logout,
|
||||
verifyAccessToken,
|
||||
} from "../../services/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { config } from "../../config/env";
|
||||
import { LoginRequest, TotpVerifyRequest } from "../../types";
|
||||
import prisma from "../../config/database";
|
||||
import crypto from "crypto";
|
||||
import { OTPAuth } from "../../utils/totp";
|
||||
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
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
|
||||
reply.setCookie('refresh_token', token, {
|
||||
reply.setCookie("refresh_token", token, {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/admin',
|
||||
sameSite: "strict",
|
||||
path: "/api/admin",
|
||||
maxAge,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function authRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// POST /api/admin/login
|
||||
fastify.post<{ Body: LoginRequest }>('/login', {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
timeWindow: '1 minute',
|
||||
fastify.post<{ Body: LoginRequest }>(
|
||||
"/login",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
}, async (request, reply) => {
|
||||
const parsed = parseBody(LoginSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const { username, password, remember_me } = parsed.data;
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(LoginSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
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") {
|
||||
await logAudit({
|
||||
request,
|
||||
action: "login_failed",
|
||||
entityType: "user",
|
||||
description: `Neúspěšný pokus o přihlášení: ${username}`,
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
if (result.type === "totp_required") {
|
||||
return success(reply, {
|
||||
totp_required: true,
|
||||
login_token: result.loginToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.type === 'error') {
|
||||
await logAudit({
|
||||
request,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
description: `Neúspěšný pokus o přihlášení: ${username}`,
|
||||
authData: result.user,
|
||||
action: "login",
|
||||
entityType: "user",
|
||||
entityId: result.user.userId,
|
||||
description: `Přihlášení uživatele ${result.user.username}`,
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
if (result.type === 'totp_required') {
|
||||
return success(reply, { totp_required: true, login_token: result.loginToken });
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: result.user,
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: result.user.userId,
|
||||
description: `Přihlášení uživatele ${result.user.username}`,
|
||||
});
|
||||
|
||||
setRefreshCookie(reply, result.refreshToken, remember_me);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
});
|
||||
setRefreshCookie(reply, result.refreshToken, remember_me);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/login/totp
|
||||
fastify.post<{ Body: TotpVerifyRequest }>('/login/totp', { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, totp_code } = parsed.data;
|
||||
const rawBody = request.body as unknown as Record<string, unknown>;
|
||||
const rememberMe = rawBody.remember_me === true || rawBody.remember_me === 'true';
|
||||
fastify.post<{ Body: TotpVerifyRequest }>(
|
||||
"/login/totp",
|
||||
{ bodyLimit: 10240 },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, totp_code } = parsed.data;
|
||||
const rawBody = request.body as unknown as Record<string, unknown>;
|
||||
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({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, 'Neplatný nebo expirovaný login token', 401);
|
||||
}
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
return error(reply, 'Uživatel nenalezen', 401);
|
||||
}
|
||||
if (!user || !user.totp_secret) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
|
||||
if (!isValid) {
|
||||
return error(reply, 'Neplatný TOTP kód', 401);
|
||||
}
|
||||
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
|
||||
if (!isValid) {
|
||||
return error(reply, "Neplatný TOTP kód", 401);
|
||||
}
|
||||
|
||||
// Delete used login token
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
// Delete used login token
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() },
|
||||
});
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create tokens directly — password was already verified before TOTP was requested
|
||||
const authData = await (await import('../../services/auth')).loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return error(reply, 'Chyba načítání uživatele', 500);
|
||||
}
|
||||
// Create tokens directly — password was already verified before TOTP was requested
|
||||
const authData = await (
|
||||
await import("../../services/auth")
|
||||
).loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return error(reply, "Chyba načítání uživatele", 500);
|
||||
}
|
||||
|
||||
// Create tokens manually since password was already verified
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const accessToken = jwt.default.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
// Create tokens manually since password was already verified
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const accessToken = jwt.default.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.roles?.name ?? null,
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
|
||||
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
|
||||
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
|
||||
const refreshTokenRaw = crypto.randomBytes(32).toString("hex");
|
||||
const refreshTokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(refreshTokenRaw)
|
||||
.digest("hex");
|
||||
|
||||
const expiresIn = rememberMe
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
const expiresIn = rememberMe
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(Date.now() + expiresIn * 1000),
|
||||
remember_me: rememberMe,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers['user-agent'] ?? null,
|
||||
},
|
||||
});
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(Date.now() + expiresIn * 1000),
|
||||
remember_me: rememberMe,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
setRefreshCookie(reply, refreshTokenRaw, rememberMe);
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
});
|
||||
setRefreshCookie(reply, refreshTokenRaw, rememberMe);
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
},
|
||||
);
|
||||
|
||||
// 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;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, 'Refresh token chybí', 401);
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
|
||||
if (result.type === 'error') {
|
||||
reply.clearCookie('refresh_token', { path: '/api/admin', httpOnly: true, secure: config.isProduction, sameSite: 'strict' });
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
@@ -171,28 +215,33 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
|
||||
});
|
||||
|
||||
// POST /api/admin/logout
|
||||
fastify.post('/logout', async (request, reply) => {
|
||||
fastify.post("/logout", async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (refreshTokenRaw) {
|
||||
await logout(refreshTokenRaw);
|
||||
}
|
||||
|
||||
reply.clearCookie('refresh_token', { path: '/api/admin', httpOnly: true, secure: config.isProduction, sameSite: 'strict' });
|
||||
return success(reply, null, 200, 'Odhlášení úspěšné');
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return success(reply, null, 200, "Odhlášení úspěšné");
|
||||
});
|
||||
|
||||
// GET /api/admin/session
|
||||
fastify.get('/session', async (request, reply) => {
|
||||
fastify.get("/session", async (request, reply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return error(reply, 'Vyžadována autentizace', 401);
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return error(reply, "Vyžadována autentizace", 401);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const authData = await verifyAccessToken(token);
|
||||
|
||||
if (!authData) {
|
||||
return error(reply, 'Neplatný token', 401);
|
||||
return error(reply, "Neplatný token", 401);
|
||||
}
|
||||
|
||||
return success(reply, { user: authData });
|
||||
|
||||
@@ -1,74 +1,155 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error, parseId } from '../../utils/response';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { CreateBankAccountSchema, UpdateBankAccountSchema } from '../../schemas/bank-accounts.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
CreateBankAccountSchema,
|
||||
UpdateBankAccountSchema,
|
||||
} from "../../schemas/bank-accounts.schema";
|
||||
|
||||
export default async function bankAccountsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (_request, reply) => {
|
||||
const accounts = await prisma.bank_accounts.findMany({ orderBy: { position: 'asc' } });
|
||||
return success(reply, accounts);
|
||||
});
|
||||
export default async function bankAccountsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (_request, reply) => {
|
||||
const accounts = await prisma.bank_accounts.findMany({
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
return success(reply, accounts);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateBankAccountSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
const account = await prisma.bank_accounts.create({
|
||||
data: {
|
||||
account_name: body.account_name ? String(body.account_name) : null,
|
||||
bank_name: body.bank_name ? String(body.bank_name) : null,
|
||||
account_number: body.account_number ? String(body.account_number) : null,
|
||||
iban: body.iban ? String(body.iban) : null,
|
||||
bic: body.bic ? String(body.bic) : null,
|
||||
currency: body.currency ? String(body.currency) : 'CZK',
|
||||
is_default: body.is_default === true || body.is_default === 1 || body.is_default === '1',
|
||||
position: body.position ? Number(body.position) : 0,
|
||||
},
|
||||
});
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateBankAccountSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
const account = await prisma.bank_accounts.create({
|
||||
data: {
|
||||
account_name: body.account_name ? String(body.account_name) : null,
|
||||
bank_name: body.bank_name ? String(body.bank_name) : null,
|
||||
account_number: body.account_number
|
||||
? String(body.account_number)
|
||||
: null,
|
||||
iban: body.iban ? String(body.iban) : null,
|
||||
bic: body.bic ? String(body.bic) : null,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
is_default:
|
||||
body.is_default === true ||
|
||||
body.is_default === 1 ||
|
||||
body.is_default === "1",
|
||||
position: body.position ? Number(body.position) : 0,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'bank_account', entityId: account.id, description: `Vytvořen bankovní účet ${account.account_name}` });
|
||||
return success(reply, { id: account.id }, 201, 'Bankovní účet vytvořen');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "bank_account",
|
||||
entityId: account.id,
|
||||
description: `Vytvořen bankovní účet ${account.account_name}`,
|
||||
});
|
||||
return success(reply, { id: account.id }, 201, "Bankovní účet vytvořen");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateBankAccountSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateBankAccountSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Účet nenalezen', 404);
|
||||
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Účet nenalezen", 404);
|
||||
|
||||
await prisma.bank_accounts.update({
|
||||
where: { id },
|
||||
data: {
|
||||
account_name: body.account_name !== undefined ? (body.account_name ? String(body.account_name) : null) : undefined,
|
||||
bank_name: body.bank_name !== undefined ? (body.bank_name ? String(body.bank_name) : null) : undefined,
|
||||
account_number: body.account_number !== undefined ? (body.account_number ? String(body.account_number) : null) : undefined,
|
||||
iban: body.iban !== undefined ? (body.iban ? String(body.iban) : null) : undefined,
|
||||
bic: body.bic !== undefined ? (body.bic ? String(body.bic) : null) : undefined,
|
||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||
is_default: body.is_default !== undefined ? (body.is_default === true || body.is_default === 1 || body.is_default === '1') : undefined,
|
||||
position: body.position !== undefined ? Number(body.position) : undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'bank_account', entityId: id, description: `Upraven bankovní účet` });
|
||||
return success(reply, { id }, 200, 'Bankovní účet uložen');
|
||||
});
|
||||
await prisma.bank_accounts.update({
|
||||
where: { id },
|
||||
data: {
|
||||
account_name:
|
||||
body.account_name !== undefined
|
||||
? body.account_name
|
||||
? String(body.account_name)
|
||||
: null
|
||||
: undefined,
|
||||
bank_name:
|
||||
body.bank_name !== undefined
|
||||
? body.bank_name
|
||||
? String(body.bank_name)
|
||||
: null
|
||||
: undefined,
|
||||
account_number:
|
||||
body.account_number !== undefined
|
||||
? body.account_number
|
||||
? String(body.account_number)
|
||||
: null
|
||||
: undefined,
|
||||
iban:
|
||||
body.iban !== undefined
|
||||
? body.iban
|
||||
? String(body.iban)
|
||||
: null
|
||||
: undefined,
|
||||
bic:
|
||||
body.bic !== undefined
|
||||
? body.bic
|
||||
? String(body.bic)
|
||||
: null
|
||||
: undefined,
|
||||
currency:
|
||||
body.currency !== undefined ? String(body.currency) : undefined,
|
||||
is_default:
|
||||
body.is_default !== undefined
|
||||
? body.is_default === true ||
|
||||
body.is_default === 1 ||
|
||||
body.is_default === "1"
|
||||
: undefined,
|
||||
position:
|
||||
body.position !== undefined ? Number(body.position) : undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "bank_account",
|
||||
entityId: id,
|
||||
description: `Upraven bankovní účet`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Bankovní účet uložen");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Účet nenalezen', 404);
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Účet nenalezen", 404);
|
||||
|
||||
await prisma.bank_accounts.delete({ where: { id } });
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'bank_account', entityId: id, description: `Smazán bankovní účet` });
|
||||
return success(reply, null, 200, 'Účet smazán');
|
||||
});
|
||||
await prisma.bank_accounts.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "bank_account",
|
||||
entityId: id,
|
||||
description: `Smazán bankovní účet`,
|
||||
});
|
||||
return success(reply, null, 200, "Účet smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth, requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error } from '../../utils/response';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { UpdateCompanySettingsSchema } from '../../schemas/company-settings.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error } from "../../utils/response";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { UpdateCompanySettingsSchema } from "../../schemas/company-settings.schema";
|
||||
|
||||
/** 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 o = Array.isArray(fieldOrder) ? fieldOrder : [];
|
||||
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 */
|
||||
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: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// PHP format: { fields: [...], field_order: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
|
||||
return { custom_fields: parsed.fields || [], supplier_field_order: parsed.field_order || [] };
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
"fields" in parsed
|
||||
) {
|
||||
return {
|
||||
custom_fields: parsed.fields || [],
|
||||
supplier_field_order: parsed.field_order || [],
|
||||
};
|
||||
}
|
||||
// Legacy TS format: raw array
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -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 } });
|
||||
|
||||
// GET /api/admin/company-settings/logo
|
||||
fastify.get('/logo', { preHandler: requireAuth }, async (_request, reply) => {
|
||||
const settings = await prisma.company_settings.findFirst({ select: { logo_data: true } });
|
||||
if (!settings?.logo_data) return error(reply, 'Logo nenalezeno', 404);
|
||||
fastify.get("/logo", { preHandler: requireAuth }, async (_request, reply) => {
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { logo_data: true },
|
||||
});
|
||||
if (!settings?.logo_data) return error(reply, "Logo nenalezeno", 404);
|
||||
|
||||
// Detect image type from magic bytes
|
||||
const buf = settings.logo_data;
|
||||
let mime = 'image/png';
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
|
||||
return reply.type(mime).send(buf);
|
||||
});
|
||||
|
||||
// POST /api/admin/company-settings/logo
|
||||
fastify.post('/logo', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, 'Nebyl nahrán žádný soubor', 400);
|
||||
fastify.post(
|
||||
"/logo",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor", 400);
|
||||
|
||||
const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
return error(reply, 'Nepodporovaný formát. Povoleno: PNG, JPG, GIF, WebP', 400);
|
||||
}
|
||||
const allowed = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
return error(
|
||||
reply,
|
||||
"Nepodporovaný formát. Povoleno: PNG, JPG, GIF, WebP",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const existing = await prisma.company_settings.findFirst();
|
||||
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
|
||||
const buffer = await file.toBuffer();
|
||||
const existing = await prisma.company_settings.findFirst();
|
||||
if (!existing) return error(reply, "Nastavení nenalezeno", 404);
|
||||
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
|
||||
});
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Nahráno logo' });
|
||||
return success(reply, null, 200, 'Logo nahráno');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "company_settings",
|
||||
entityId: existing.id,
|
||||
description: "Nahráno logo",
|
||||
});
|
||||
return success(reply, null, 200, "Logo nahráno");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get('/', { preHandler: requireAuth }, async (_request, reply) => {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
|
||||
let settings = await prisma.company_settings.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -102,9 +135,9 @@ export default async function companySettingsRoutes(fastify: FastifyInstance): P
|
||||
if (!settings) {
|
||||
settings = await prisma.company_settings.create({
|
||||
data: {
|
||||
company_name: '',
|
||||
quotation_prefix: 'N',
|
||||
default_currency: 'EUR',
|
||||
company_name: "",
|
||||
quotation_prefix: "N",
|
||||
default_currency: "EUR",
|
||||
default_vat_rate: 21.0,
|
||||
},
|
||||
select: {
|
||||
@@ -136,49 +169,97 @@ export default async function companySettingsRoutes(fastify: FastifyInstance): P
|
||||
where: { id: settings.id },
|
||||
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) => {
|
||||
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.put(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const existing = await prisma.company_settings.findFirst();
|
||||
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
|
||||
const existing = await prisma.company_settings.findFirst();
|
||||
if (!existing) return error(reply, "Nastavení nenalezeno", 404);
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = ['company_name', 'street', 'city', 'postal_code', 'country', 'company_id', 'vat_id', 'quotation_prefix', 'default_currency', 'order_type_code', 'invoice_type_code'];
|
||||
const bodyRec = body as Record<string, unknown>;
|
||||
for (const f of strFields) {
|
||||
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.require_2fa !== undefined) data.require_2fa = body.require_2fa === true || body.require_2fa === 1 || body.require_2fa === '1';
|
||||
if (body.custom_fields !== undefined || body.supplier_field_order !== undefined) {
|
||||
let existingFields: unknown[] = [];
|
||||
let existingOrder: unknown[] = [];
|
||||
if (existing.custom_fields) {
|
||||
try {
|
||||
const parsed = JSON.parse(existing.custom_fields);
|
||||
existingFields = parsed?.fields || [];
|
||||
existingOrder = parsed?.field_order || [];
|
||||
} catch { /* invalid JSON, use defaults */ }
|
||||
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 bodyRec = body as Record<string, unknown>;
|
||||
for (const f of strFields) {
|
||||
if (bodyRec[f] !== undefined)
|
||||
data[f] = bodyRec[f] ? String(bodyRec[f]) : null;
|
||||
}
|
||||
data.custom_fields = encodeCustomFields(
|
||||
body.custom_fields !== undefined ? body.custom_fields : existingFields,
|
||||
body.supplier_field_order !== undefined ? body.supplier_field_order : existingOrder,
|
||||
);
|
||||
}
|
||||
data.sync_version = (existing.sync_version ?? 0) + 1;
|
||||
if (body.default_vat_rate !== undefined)
|
||||
data.default_vat_rate = Number(body.default_vat_rate);
|
||||
if (body.require_2fa !== undefined)
|
||||
data.require_2fa =
|
||||
body.require_2fa === true ||
|
||||
body.require_2fa === 1 ||
|
||||
body.require_2fa === "1";
|
||||
if (
|
||||
body.custom_fields !== undefined ||
|
||||
body.supplier_field_order !== undefined
|
||||
) {
|
||||
let existingFields: unknown[] = [];
|
||||
let existingOrder: unknown[] = [];
|
||||
if (existing.custom_fields) {
|
||||
try {
|
||||
const parsed = JSON.parse(existing.custom_fields);
|
||||
existingFields = parsed?.fields || [];
|
||||
existingOrder = parsed?.field_order || [];
|
||||
} catch {
|
||||
/* invalid JSON, use defaults */
|
||||
}
|
||||
}
|
||||
data.custom_fields = encodeCustomFields(
|
||||
body.custom_fields !== undefined
|
||||
? body.custom_fields
|
||||
: existingFields,
|
||||
body.supplier_field_order !== undefined
|
||||
? body.supplier_field_order
|
||||
: existingOrder,
|
||||
);
|
||||
}
|
||||
data.sync_version = (existing.sync_version ?? 0) + 1;
|
||||
|
||||
await prisma.company_settings.update({ where: { id: existing.id }, data });
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Upraveno firemní nastavení' });
|
||||
return success(reply, { id: existing.id }, 200, 'Nastavení bylo uloženo');
|
||||
});
|
||||
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");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth, requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error, parseId } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { CreateCustomerSchema, UpdateCustomerSchema } from '../../schemas/customers.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
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) */
|
||||
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
|
||||
function encodeCustomFields(
|
||||
fields: unknown,
|
||||
fieldOrder: unknown,
|
||||
): string | null {
|
||||
const f = Array.isArray(fields) ? fields : [];
|
||||
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
|
||||
if (f.length === 0 && o.length === 0) return null;
|
||||
@@ -18,13 +24,24 @@ function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null
|
||||
}
|
||||
|
||||
/** 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: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// PHP format: { fields: [...], field_order: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
|
||||
return { custom_fields: parsed.fields || [], customer_field_order: parsed.field_order || [] };
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
"fields" in parsed
|
||||
) {
|
||||
return {
|
||||
custom_fields: parsed.fields || [],
|
||||
customer_field_order: parsed.field_order || [],
|
||||
};
|
||||
}
|
||||
// Legacy TS format: raw array
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -36,111 +53,221 @@ function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; cus
|
||||
}
|
||||
}
|
||||
|
||||
export default async function customersRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(request.query as Record<string, unknown>);
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'name';
|
||||
export default async function customersRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
|
||||
|
||||
const where = search
|
||||
? { OR: [{ name: { contains: search } }, { company_id: { contains: search } }] }
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customers.findMany({
|
||||
where, skip, take: limit, orderBy: { [sortField]: order },
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = customers.map(c => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(c.custom_fields);
|
||||
return { ...c, custom_fields, customer_field_order, quotation_count: c._count?.quotations ?? 0 };
|
||||
const enriched = customers.map((c) => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
c.custom_fields,
|
||||
);
|
||||
return {
|
||||
...c,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
quotation_count: c._count?.quotations ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
|
||||
});
|
||||
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const customer = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!customer) return error(reply, 'Zákazník nenalezen', 404);
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(customer.custom_fields);
|
||||
return success(reply, { ...customer, custom_fields, customer_field_order });
|
||||
});
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateCustomerSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const name = body.name;
|
||||
|
||||
const customer = await prisma.customers.create({
|
||||
data: {
|
||||
name,
|
||||
street: body.street ? String(body.street) : null,
|
||||
city: body.city ? String(body.city) : null,
|
||||
postal_code: body.postal_code ? String(body.postal_code) : null,
|
||||
country: body.country ? String(body.country) : null,
|
||||
company_id: body.company_id ? String(body.company_id) : null,
|
||||
vat_id: body.vat_id ? String(body.vat_id) : null,
|
||||
custom_fields: encodeCustomFields(body.custom_fields, body.customer_field_order),
|
||||
},
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: enriched,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'customer', entityId: customer.id, description: `Vytvořen zákazník ${customer.name}` });
|
||||
return success(reply, { id: customer.id }, 201, 'Zákazník byl vytvořen');
|
||||
});
|
||||
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateCustomerSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const customer = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!customer) return error(reply, "Zákazník nenalezen", 404);
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
customer.custom_fields,
|
||||
);
|
||||
return success(reply, {
|
||||
...customer,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("customers.manage") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateCustomerSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
await prisma.customers.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
street: body.street !== undefined ? (body.street ? String(body.street) : null) : undefined,
|
||||
city: body.city !== undefined ? (body.city ? String(body.city) : null) : undefined,
|
||||
postal_code: body.postal_code !== undefined ? (body.postal_code ? String(body.postal_code) : null) : undefined,
|
||||
country: body.country !== undefined ? (body.country ? String(body.country) : null) : undefined,
|
||||
company_id: body.company_id !== undefined ? (body.company_id ? String(body.company_id) : null) : undefined,
|
||||
vat_id: body.vat_id !== undefined ? (body.vat_id ? String(body.vat_id) : null) : undefined,
|
||||
custom_fields: body.custom_fields !== undefined ? encodeCustomFields(body.custom_fields, body.customer_field_order) : undefined,
|
||||
},
|
||||
});
|
||||
const name = body.name;
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'customer', entityId: id, description: `Upraven zákazník ${existing.name}` });
|
||||
return success(reply, { id }, 200, 'Zákazník byl uložen');
|
||||
});
|
||||
const customer = await prisma.customers.create({
|
||||
data: {
|
||||
name,
|
||||
street: body.street ? String(body.street) : null,
|
||||
city: body.city ? String(body.city) : null,
|
||||
postal_code: body.postal_code ? String(body.postal_code) : null,
|
||||
country: body.country ? String(body.country) : null,
|
||||
company_id: body.company_id ? String(body.company_id) : null,
|
||||
vat_id: body.vat_id ? String(body.vat_id) : null,
|
||||
custom_fields: encodeCustomFields(
|
||||
body.custom_fields,
|
||||
body.customer_field_order,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "customer",
|
||||
entityId: customer.id,
|
||||
description: `Vytvořen zákazník ${customer.name}`,
|
||||
});
|
||||
return success(reply, { id: customer.id }, 201, "Zákazník byl vytvořen");
|
||||
},
|
||||
);
|
||||
|
||||
// Check for FK references before deleting
|
||||
const [quotCount, orderCount, invoiceCount, projectCount] = await Promise.all([
|
||||
prisma.quotations.count({ where: { customer_id: id } }),
|
||||
prisma.orders.count({ where: { customer_id: id } }),
|
||||
prisma.invoices.count({ where: { customer_id: id } }),
|
||||
prisma.projects.count({ where: { customer_id: id } }),
|
||||
]);
|
||||
if (quotCount + orderCount + invoiceCount + projectCount > 0) {
|
||||
return error(reply, 'Zákazníka nelze smazat — existují propojené nabídky, objednávky, faktury nebo projekty', 400);
|
||||
}
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("customers.manage") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateCustomerSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
await prisma.customers.delete({ where: { id } });
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'customer', entityId: id, description: `Smazán zákazník ${existing.name}` });
|
||||
return success(reply, null, 200, 'Zákazník smazán');
|
||||
});
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Zákazník nenalezen", 404);
|
||||
|
||||
await prisma.customers.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
street:
|
||||
body.street !== undefined
|
||||
? body.street
|
||||
? String(body.street)
|
||||
: null
|
||||
: undefined,
|
||||
city:
|
||||
body.city !== undefined
|
||||
? body.city
|
||||
? String(body.city)
|
||||
: null
|
||||
: undefined,
|
||||
postal_code:
|
||||
body.postal_code !== undefined
|
||||
? body.postal_code
|
||||
? String(body.postal_code)
|
||||
: null
|
||||
: undefined,
|
||||
country:
|
||||
body.country !== undefined
|
||||
? body.country
|
||||
? String(body.country)
|
||||
: null
|
||||
: undefined,
|
||||
company_id:
|
||||
body.company_id !== undefined
|
||||
? body.company_id
|
||||
? String(body.company_id)
|
||||
: null
|
||||
: undefined,
|
||||
vat_id:
|
||||
body.vat_id !== undefined
|
||||
? body.vat_id
|
||||
? String(body.vat_id)
|
||||
: null
|
||||
: undefined,
|
||||
custom_fields:
|
||||
body.custom_fields !== undefined
|
||||
? encodeCustomFields(
|
||||
body.custom_fields,
|
||||
body.customer_field_order,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
description: `Upraven zákazník ${existing.name}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Zákazník byl uložen");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("customers.manage") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Zákazník nenalezen", 404);
|
||||
|
||||
// Check for FK references before deleting
|
||||
const [quotCount, orderCount, invoiceCount, projectCount] =
|
||||
await Promise.all([
|
||||
prisma.quotations.count({ where: { customer_id: id } }),
|
||||
prisma.orders.count({ where: { customer_id: id } }),
|
||||
prisma.invoices.count({ where: { customer_id: id } }),
|
||||
prisma.projects.count({ where: { customer_id: id } }),
|
||||
]);
|
||||
if (quotCount + orderCount + invoiceCount + projectCount > 0) {
|
||||
return error(
|
||||
reply,
|
||||
"Zákazníka nelze smazat — existují propojené nabídky, objednávky, faktury nebo projekty",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.customers.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
description: `Smazán zákazník ${existing.name}`,
|
||||
});
|
||||
return success(reply, null, 200, "Zákazník smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
import { success } from '../../utils/response';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth } from "../../middleware/auth";
|
||||
import { success } from "../../utils/response";
|
||||
|
||||
export default async function dashboardRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
export default async function dashboardRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
const todayStart = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
);
|
||||
const todayEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 1,
|
||||
);
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const authData = request.authData!;
|
||||
@@ -18,65 +28,87 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
// My shift — always available for authenticated users with attendance.record
|
||||
if (has('attendance.record')) {
|
||||
if (has("attendance.record")) {
|
||||
const myShift = await prisma.attendance.findFirst({
|
||||
where: { user_id: userId, arrival_time: { not: null }, departure_time: null },
|
||||
orderBy: { created_at: 'desc' },
|
||||
where: {
|
||||
user_id: userId,
|
||||
arrival_time: { not: null },
|
||||
departure_time: null,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
result.my_shift = { has_ongoing: myShift !== null };
|
||||
}
|
||||
|
||||
// Attendance admin — only for attendance.admin
|
||||
if (has('attendance.admin')) {
|
||||
if (has("attendance.admin")) {
|
||||
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
|
||||
prisma.attendance.findMany({
|
||||
where: {
|
||||
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 } } },
|
||||
orderBy: { arrival_time: 'asc' },
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
},
|
||||
orderBy: { arrival_time: "asc" },
|
||||
}),
|
||||
prisma.attendance.findMany({
|
||||
where: {
|
||||
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 } }),
|
||||
]);
|
||||
|
||||
const userAttendanceMap = new Map<number, typeof todayAttendance[0]>();
|
||||
const userAttendanceMap = new Map<number, (typeof todayAttendance)[0]>();
|
||||
for (const a of todayAttendance) {
|
||||
const existing = userAttendanceMap.get(a.users.id);
|
||||
if (!existing || (a.arrival_time && existing.arrival_time && a.arrival_time > existing.arrival_time)) {
|
||||
if (
|
||||
!existing ||
|
||||
(a.arrival_time &&
|
||||
existing.arrival_time &&
|
||||
a.arrival_time > existing.arrival_time)
|
||||
) {
|
||||
userAttendanceMap.set(a.users.id, a);
|
||||
}
|
||||
}
|
||||
|
||||
let presentCount = 0;
|
||||
const attendanceUsers: Array<{
|
||||
user_id: number; name: string; initials: string;
|
||||
status: string; arrived_at: string | null; leave_type?: string;
|
||||
user_id: number;
|
||||
name: string;
|
||||
initials: string;
|
||||
status: string;
|
||||
arrived_at: string | null;
|
||||
leave_type?: string;
|
||||
}> = [];
|
||||
|
||||
for (const a of userAttendanceMap.values()) {
|
||||
const user = a.users;
|
||||
const firstInitial = user.first_name?.charAt(0) ?? '';
|
||||
const lastInitial = user.last_name?.charAt(0) ?? '';
|
||||
let status = 'out';
|
||||
const firstInitial = user.first_name?.charAt(0) ?? "";
|
||||
const lastInitial = user.last_name?.charAt(0) ?? "";
|
||||
let status = "out";
|
||||
if (a.arrival_time) {
|
||||
if (a.departure_time) status = 'out';
|
||||
else if (a.break_start && !a.break_end) status = 'away';
|
||||
else { status = 'in'; presentCount++; }
|
||||
if (a.departure_time) status = "out";
|
||||
else if (a.break_start && !a.break_end) status = "away";
|
||||
else {
|
||||
status = "in";
|
||||
presentCount++;
|
||||
}
|
||||
}
|
||||
attendanceUsers.push({
|
||||
user_id: user.id,
|
||||
name: `${user.first_name} ${user.last_name}`,
|
||||
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
|
||||
status,
|
||||
arrived_at: a.arrival_time ? `${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({
|
||||
user_id: user.id,
|
||||
name: `${user.first_name} ${user.last_name}`,
|
||||
initials: `${user.first_name?.charAt(0) ?? ''}${user.last_name?.charAt(0) ?? ''}`.toUpperCase(),
|
||||
status: 'leave',
|
||||
initials:
|
||||
`${user.first_name?.charAt(0) ?? ""}${user.last_name?.charAt(0) ?? ""}`.toUpperCase(),
|
||||
status: "leave",
|
||||
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
|
||||
if (has('offers.view')) {
|
||||
const [openCount, convertedCount, expiredCount, createdThisMonth] = await Promise.all([
|
||||
prisma.quotations.count({ where: { status: 'active' } }),
|
||||
prisma.quotations.count({ where: { status: 'converted' } }),
|
||||
prisma.quotations.count({ where: { status: 'expired' } }),
|
||||
prisma.quotations.count({ where: { created_at: { gte: monthStart, lt: monthEnd } } }),
|
||||
]);
|
||||
result.offers = { open_count: openCount, converted_count: convertedCount, expired_count: expiredCount, created_this_month: createdThisMonth };
|
||||
if (has("offers.view")) {
|
||||
const [openCount, convertedCount, expiredCount, createdThisMonth] =
|
||||
await Promise.all([
|
||||
prisma.quotations.count({ where: { status: "active" } }),
|
||||
prisma.quotations.count({ where: { status: "converted" } }),
|
||||
prisma.quotations.count({ where: { status: "expired" } }),
|
||||
prisma.quotations.count({
|
||||
where: { created_at: { gte: monthStart, lt: monthEnd } },
|
||||
}),
|
||||
]);
|
||||
result.offers = {
|
||||
open_count: openCount,
|
||||
converted_count: convertedCount,
|
||||
expired_count: expiredCount,
|
||||
created_this_month: createdThisMonth,
|
||||
};
|
||||
}
|
||||
|
||||
// Projects — only for projects.view
|
||||
if (has('projects.view')) {
|
||||
if (has("projects.view")) {
|
||||
const [activeCount, activeList] = await Promise.all([
|
||||
prisma.projects.count({ where: { status: 'aktivni' } }),
|
||||
prisma.projects.count({ where: { status: "aktivni" } }),
|
||||
prisma.projects.findMany({
|
||||
where: { status: 'aktivni' },
|
||||
where: { status: "aktivni" },
|
||||
include: { customers: { select: { name: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
result.active_projects = activeCount;
|
||||
result.projects = {
|
||||
active_projects: activeList.map(p => ({
|
||||
id: p.id, name: p.name ?? '', customer_name: p.customers?.name ?? null,
|
||||
active_projects: activeList.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name ?? "",
|
||||
customer_name: p.customers?.name ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Invoices — only for invoices.view
|
||||
if (has('invoices.view')) {
|
||||
if (has("invoices.view")) {
|
||||
const [unpaidCount, issuedThisMonth] = await Promise.all([
|
||||
prisma.invoices.count({ where: { status: 'issued' } }),
|
||||
prisma.invoices.count({ where: { status: "issued" } }),
|
||||
prisma.invoices.findMany({
|
||||
where: { issue_date: { gte: monthStart, lt: monthEnd } },
|
||||
include: { invoice_items: true },
|
||||
@@ -146,48 +189,70 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
||||
|
||||
const revenueByCurrency: Record<string, number> = {};
|
||||
for (const inv of issuedThisMonth) {
|
||||
const currency = inv.currency ?? 'CZK';
|
||||
const currency = inv.currency ?? "CZK";
|
||||
let total = 0;
|
||||
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 = {
|
||||
revenue_this_month: Object.entries(revenueByCurrency).map(([currency, amount]) => ({
|
||||
amount: Math.round(amount * 100) / 100, currency,
|
||||
})),
|
||||
revenue_this_month: Object.entries(revenueByCurrency).map(
|
||||
([currency, amount]) => ({
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
}),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
// Orders — only for orders.view
|
||||
if (has('orders.view')) {
|
||||
result.pending_orders = await prisma.orders.count({ where: { status: 'prijata' } });
|
||||
if (has("orders.view")) {
|
||||
result.pending_orders = await prisma.orders.count({
|
||||
where: { status: "prijata" },
|
||||
});
|
||||
}
|
||||
|
||||
// Leave pending — only for attendance.approve
|
||||
if (has('attendance.approve')) {
|
||||
const count = await prisma.leave_requests.count({ where: { status: 'pending' } });
|
||||
if (has("attendance.approve")) {
|
||||
const count = await prisma.leave_requests.count({
|
||||
where: { status: "pending" },
|
||||
});
|
||||
result.leave_pending = { count };
|
||||
result.pending_leave_requests = count;
|
||||
}
|
||||
|
||||
// Recent activity — only for settings.audit (admin)
|
||||
if (has('settings.audit')) {
|
||||
if (has("settings.audit")) {
|
||||
const logs = await prisma.audit_logs.findMany({
|
||||
orderBy: { created_at: 'desc' },
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 8,
|
||||
where: { action: { in: ['create', 'update', 'delete', 'login'] } },
|
||||
select: { id: true, action: true, entity_type: true, description: true, username: true, created_at: true },
|
||||
where: { action: { in: ["create", "update", "delete", "login"] } },
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
entity_type: true,
|
||||
description: true,
|
||||
username: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
result.recent_activity = logs.map(log => ({
|
||||
id: log.id, action: log.action, entity_type: log.entity_type ?? '',
|
||||
description: log.description ?? '', username: log.username ?? null,
|
||||
created_at: log.created_at ? log.created_at.toISOString() : '',
|
||||
result.recent_activity = logs.map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
entity_type: log.entity_type ?? "",
|
||||
description: log.description ?? "",
|
||||
username: log.username ?? null,
|
||||
created_at: log.created_at ? log.created_at.toISOString() : "",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +1,85 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import QRCode from 'qrcode';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = new Date(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 {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
if (!html) return "";
|
||||
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(/<(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(
|
||||
/<(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,
|
||||
"",
|
||||
);
|
||||
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(/( )/g, ' ');
|
||||
let prev = '';
|
||||
s = s.replace(/( )/g, " ");
|
||||
let prev = "";
|
||||
while (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;
|
||||
}
|
||||
|
||||
interface AddressResult { name: string; lines: string[] }
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isSupplier: boolean,
|
||||
tObj: Record<string, string>,
|
||||
): AddressResult {
|
||||
if (!entity) return { name: '', lines: [] };
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
const nameKey = isSupplier ? 'company_name' : 'name';
|
||||
const name = String(entity[nameKey] || '');
|
||||
const nameKey = isSupplier ? "company_name" : "name";
|
||||
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;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData = ((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;
|
||||
cfData =
|
||||
((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)) {
|
||||
cfData = parsed;
|
||||
}
|
||||
@@ -72,29 +88,37 @@ function buildAddressLines(
|
||||
|
||||
if (Array.isArray(fieldOrder)) {
|
||||
const legacyMap: Record<string, string> = {
|
||||
Name: 'name', CompanyName: 'company_name',
|
||||
Street: 'street', CityPostal: 'city_postal',
|
||||
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id',
|
||||
Name: "name",
|
||||
CompanyName: "company_name",
|
||||
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> = {};
|
||||
if (name) fieldMap[nameKey] = name;
|
||||
if (entity.street) fieldMap.street = String(entity.street);
|
||||
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String);
|
||||
const cityPostal = cityParts.join(' ').trim();
|
||||
const cityParts = [entity.city || "", entity.postal_code || ""]
|
||||
.filter(Boolean)
|
||||
.map(String);
|
||||
const cityPostal = cityParts.join(" ").trim();
|
||||
if (cityPostal) fieldMap.city_postal = cityPostal;
|
||||
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}`;
|
||||
|
||||
cfData.forEach((cf, i) => {
|
||||
const cfName = (cf.name || '').trim();
|
||||
const cfValue = (cf.value || '').trim();
|
||||
const cfName = (cf.name || "").trim();
|
||||
const cfValue = (cf.value || "").trim();
|
||||
const showLabel = cf.showLabel !== false;
|
||||
if (cfValue) {
|
||||
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
|
||||
fieldMap[`custom_${i}`] =
|
||||
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,251 +146,282 @@ function buildAddressLines(
|
||||
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
cs: {
|
||||
title: 'Faktura',
|
||||
heading: 'FAKTURA - DAŇOVÝ DOKLAD č.',
|
||||
supplier: 'Dodavatel',
|
||||
customer: 'Odběratel',
|
||||
bank: 'Banka:',
|
||||
swift: 'SWIFT:',
|
||||
iban: 'IBAN:',
|
||||
account_no: 'Číslo účtu:',
|
||||
var_symbol: 'Variabilní s.:',
|
||||
const_symbol: 'Konstantní s.:',
|
||||
order_no: 'Objednávka č.:',
|
||||
issue_date: 'Datum vystavení:',
|
||||
due_date: 'Datum splatnosti:',
|
||||
tax_date: 'Datum uskutečnění plnění:',
|
||||
payment_method: 'Forma úhrady:',
|
||||
billing: 'Fakturujeme Vám za:',
|
||||
col_no: 'Č.',
|
||||
col_desc: 'Popis',
|
||||
col_qty: 'Množství',
|
||||
col_unit_price: 'Jedn. cena',
|
||||
col_price: 'Cena',
|
||||
col_vat_pct: '%DPH',
|
||||
col_vat: 'DPH',
|
||||
col_total: 'Celkem',
|
||||
subtotal: 'Mezisoučet:',
|
||||
vat_label: 'DPH',
|
||||
total: 'Celkem k úhradě',
|
||||
amounts_in: 'Částky jsou uvedeny v',
|
||||
notes: 'Poznámky',
|
||||
issued_by: 'Vystavil:',
|
||||
notice: 'Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti'
|
||||
+ ' uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp.'
|
||||
+ ' zákonné výši a smluvní pokutu (byla-li sjednána).',
|
||||
vat_recap: 'Rekapitulace DPH v Kč:',
|
||||
vat_base: 'Základ v Kč',
|
||||
vat_rate: 'Sazba',
|
||||
vat_amount: 'DPH v Kč',
|
||||
vat_with_total: 'Celkem s DPH v Kč',
|
||||
received_by: 'Převzal:',
|
||||
stamp: 'Razítko:',
|
||||
ico: 'IČ: ',
|
||||
dic: 'DIČ: ',
|
||||
title: "Faktura",
|
||||
heading: "FAKTURA - DAŇOVÝ DOKLAD č.",
|
||||
supplier: "Dodavatel",
|
||||
customer: "Odběratel",
|
||||
bank: "Banka:",
|
||||
swift: "SWIFT:",
|
||||
iban: "IBAN:",
|
||||
account_no: "Číslo účtu:",
|
||||
var_symbol: "Variabilní s.:",
|
||||
const_symbol: "Konstantní s.:",
|
||||
order_no: "Objednávka č.:",
|
||||
issue_date: "Datum vystavení:",
|
||||
due_date: "Datum splatnosti:",
|
||||
tax_date: "Datum uskutečnění plnění:",
|
||||
payment_method: "Forma úhrady:",
|
||||
billing: "Fakturujeme Vám za:",
|
||||
col_no: "Č.",
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
col_total: "Celkem",
|
||||
subtotal: "Mezisoučet:",
|
||||
vat_label: "DPH",
|
||||
total: "Celkem k úhradě",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
notes: "Poznámky",
|
||||
issued_by: "Vystavil:",
|
||||
notice:
|
||||
"Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti" +
|
||||
" uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp." +
|
||||
" zákonné výši a smluvní pokutu (byla-li sjednána).",
|
||||
vat_recap: "Rekapitulace DPH v Kč:",
|
||||
vat_base: "Základ v Kč",
|
||||
vat_rate: "Sazba",
|
||||
vat_amount: "DPH v Kč",
|
||||
vat_with_total: "Celkem s DPH v Kč",
|
||||
received_by: "Převzal:",
|
||||
stamp: "Razítko:",
|
||||
ico: "IČ: ",
|
||||
dic: "DIČ: ",
|
||||
},
|
||||
en: {
|
||||
title: 'Invoice',
|
||||
heading: 'INVOICE - TAX DOCUMENT No.',
|
||||
supplier: 'Supplier',
|
||||
customer: 'Customer',
|
||||
bank: 'Bank:',
|
||||
swift: 'SWIFT:',
|
||||
iban: 'IBAN:',
|
||||
account_no: 'Account No.:',
|
||||
var_symbol: 'Variable symbol:',
|
||||
const_symbol: 'Constant symbol:',
|
||||
order_no: 'Order No.:',
|
||||
issue_date: 'Issue date:',
|
||||
due_date: 'Due date:',
|
||||
tax_date: 'Tax point date:',
|
||||
payment_method: 'Payment method:',
|
||||
billing: 'We invoice you for:',
|
||||
col_no: 'No.',
|
||||
col_desc: 'Description',
|
||||
col_qty: 'Quantity',
|
||||
col_unit_price: 'Unit price',
|
||||
col_price: 'Price',
|
||||
col_vat_pct: 'VAT%',
|
||||
col_vat: 'VAT',
|
||||
col_total: 'Total',
|
||||
subtotal: 'Subtotal:',
|
||||
vat_label: 'VAT',
|
||||
total: 'Total to pay',
|
||||
amounts_in: 'Amounts are in',
|
||||
notes: 'Notes',
|
||||
issued_by: 'Issued by:',
|
||||
notice: 'Please note that in case of late payment, we will charge default interest'
|
||||
+ ' at the agreed or statutory rate and a contractual penalty (if agreed).',
|
||||
vat_recap: 'VAT recapitulation in CZK:',
|
||||
vat_base: 'Tax base in CZK',
|
||||
vat_rate: 'Rate',
|
||||
vat_amount: 'VAT in CZK',
|
||||
vat_with_total: 'Total incl. VAT in CZK',
|
||||
received_by: 'Received by:',
|
||||
stamp: 'Stamp:',
|
||||
ico: 'Reg. No.: ',
|
||||
dic: 'Tax ID: ',
|
||||
title: "Invoice",
|
||||
heading: "INVOICE - TAX DOCUMENT No.",
|
||||
supplier: "Supplier",
|
||||
customer: "Customer",
|
||||
bank: "Bank:",
|
||||
swift: "SWIFT:",
|
||||
iban: "IBAN:",
|
||||
account_no: "Account No.:",
|
||||
var_symbol: "Variable symbol:",
|
||||
const_symbol: "Constant symbol:",
|
||||
order_no: "Order No.:",
|
||||
issue_date: "Issue date:",
|
||||
due_date: "Due date:",
|
||||
tax_date: "Tax point date:",
|
||||
payment_method: "Payment method:",
|
||||
billing: "We invoice you for:",
|
||||
col_no: "No.",
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
col_total: "Total",
|
||||
subtotal: "Subtotal:",
|
||||
vat_label: "VAT",
|
||||
total: "Total to pay",
|
||||
amounts_in: "Amounts are in",
|
||||
notes: "Notes",
|
||||
issued_by: "Issued by:",
|
||||
notice:
|
||||
"Please note that in case of late payment, we will charge default interest" +
|
||||
" at the agreed or statutory rate and a contractual penalty (if agreed).",
|
||||
vat_recap: "VAT recapitulation in CZK:",
|
||||
vat_base: "Tax base in CZK",
|
||||
vat_rate: "Rate",
|
||||
vat_amount: "VAT in CZK",
|
||||
vat_with_total: "Total incl. VAT in CZK",
|
||||
received_by: "Received by:",
|
||||
stamp: "Stamp:",
|
||||
ico: "Reg. No.: ",
|
||||
dic: "Tax ID: ",
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function invoicesPdfRoutes(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 query = request.query as Record<string, string>;
|
||||
const lang = query.lang === 'en' ? 'en' : 'cs';
|
||||
const t = translations[lang];
|
||||
export default async function invoicesPdfRoutes(
|
||||
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 query = request.query as Record<string, string>;
|
||||
const lang = query.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
return reply.status(404).type('text/html').send('<html><body><h1>Faktura nenalezena</h1></body></html>');
|
||||
}
|
||||
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
}) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const settings = await prisma.company_settings.findFirst() as Record<string, unknown> | null;
|
||||
|
||||
// Order number lookup
|
||||
let orderNumber = '';
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: { order_number: true, customer_order_number: true, created_at: true },
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(String(orderRow.customer_order_number || orderRow.order_number || ''));
|
||||
|
||||
if (!invoice) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||||
}
|
||||
}
|
||||
|
||||
// Logo
|
||||
let logoImg = '';
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = 'image/png';
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp';
|
||||
const b64 = buf.toString('base64');
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
const currency = invoice.currency || 'CZK';
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
// Calculations
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat += lineSubtotal * rate / 100;
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = (await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
})) as Record<string, unknown> | null;
|
||||
}
|
||||
}
|
||||
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = '';
|
||||
try {
|
||||
const spaydParts = [
|
||||
'SPD*1.0',
|
||||
'ACC:' + (invoice.bank_iban || '').replace(/ /g, ''),
|
||||
'AM:' + totalToPay.toFixed(2),
|
||||
'CC:' + currency,
|
||||
'X-VS:' + (invoice.invoice_number || ''),
|
||||
'X-KS:' + (invoice.constant_symbol || '0308'),
|
||||
'MSG:' + t.title + ' ' + (invoice.invoice_number || ''),
|
||||
];
|
||||
const spaydString = spaydParts.join('*');
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: 'svg',
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
// Order number lookup
|
||||
let orderNumber = "";
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: {
|
||||
order_number: true,
|
||||
customer_order_number: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(
|
||||
String(
|
||||
orderRow.customer_order_number || orderRow.order_number || "",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VAT recapitulation (always in CZK)
|
||||
const isForeign = currency.toUpperCase() !== 'CZK';
|
||||
const cnbRate = 1.0; // Skip CNB rate conversion
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{ rate: number; base: number; vat: number; total: number }> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
// Logo
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Address lines
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
const suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
|
||||
const custLinesHtml = cust.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
|
||||
// Calculations
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
// Supplier email/web from custom_fields
|
||||
let suppEmail = '';
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === 'email' && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat += (lineSubtotal * rate) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = "";
|
||||
try {
|
||||
const spaydParts = [
|
||||
"SPD*1.0",
|
||||
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
|
||||
"AM:" + totalToPay.toFixed(2),
|
||||
"CC:" + currency,
|
||||
"X-VS:" + (invoice.invoice_number || ""),
|
||||
"X-KS:" + (invoice.constant_symbol || "0308"),
|
||||
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
|
||||
];
|
||||
const spaydString = spaydParts.join("*");
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
|
||||
// VAT recapitulation (always in CZK)
|
||||
const isForeign = currency.toUpperCase() !== "CZK";
|
||||
const cnbRate = 1.0; // Skip CNB rate conversion
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
base: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Address lines
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.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
|
||||
let suppEmail = "";
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === "email" && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
|
||||
// Items HTML
|
||||
const itemsHtml = items.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? lineSubtotal * vatRate / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
// Items HTML
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
|
||||
return `<tr>
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}</td>
|
||||
@@ -376,53 +431,58 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
})
|
||||
.join("");
|
||||
|
||||
// VAT recap rows
|
||||
const vatRecapHtml = vatRecap.map(vr => `<tr>
|
||||
// VAT recap rows
|
||||
const vatRecapHtml = vatRecap
|
||||
.map(
|
||||
(vr) => `<tr>
|
||||
<td class="right">${formatNum(vr.base)}</td>
|
||||
<td class="center">${Math.floor(vr.rate)}%</td>
|
||||
<td class="right">${formatNum(vr.vat)}</td>
|
||||
<td class="right">${formatNum(vr.total)}</td>
|
||||
</tr>`).join('');
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// VAT detail rows for totals section
|
||||
let vatDetailHtml = '';
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
// VAT detail rows for totals section
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes section
|
||||
const notesRaw = invoice.notes ?? '';
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, '').trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
// Notes section
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<!-- Poznamky -->
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = '';
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -833,7 +893,7 @@ ${indentCSS}
|
||||
<!-- Hlavicka -->
|
||||
<div class="invoice-header">
|
||||
<div class="left">
|
||||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ''}
|
||||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
|
||||
</div>
|
||||
<div class="invoice-title">${escapeHtml(t.heading)} ${invoiceNumber}</div>
|
||||
</div>
|
||||
@@ -866,7 +926,7 @@ ${indentCSS}
|
||||
<div class="vs-block">
|
||||
${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong>
|
||||
${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 class="col">
|
||||
@@ -923,8 +983,8 @@ ${indentCSS}
|
||||
|
||||
<!-- Vystavil -->
|
||||
<div class="issued-by">
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || '')}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ''}
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ""}
|
||||
</div>
|
||||
|
||||
<!-- Upozorneni -->
|
||||
@@ -967,6 +1027,7 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return reply.type('text/html').send(html);
|
||||
});
|
||||
return reply.type("text/html").send(html);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error, parseId } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
CreateInvoiceSchema,
|
||||
UpdateInvoiceSchema,
|
||||
} from "../../schemas/invoices.schema";
|
||||
import {
|
||||
markOverdueInvoices,
|
||||
listInvoices,
|
||||
@@ -15,108 +18,181 @@ import {
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
deleteInvoice,
|
||||
} from '../../services/invoices.service';
|
||||
|
||||
export default async function invoicesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
} from "../../services/invoices.service";
|
||||
|
||||
export default async function invoicesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
|
||||
fastify.addHook('onRequest', async (request) => {
|
||||
if (request.method !== 'GET') return;
|
||||
fastify.addHook("onRequest", async (request) => {
|
||||
if (request.method !== "GET") return;
|
||||
await markOverdueInvoices();
|
||||
});
|
||||
|
||||
// GET /api/admin/invoices
|
||||
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
|
||||
const result = await listInvoices({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort: String(query.sort || ''),
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
const result = await listInvoices({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort: String(query.sort || ""),
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : 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
|
||||
fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => {
|
||||
const result = await getNextInvoiceNumberFormatted();
|
||||
return success(reply, result);
|
||||
});
|
||||
fastify.get(
|
||||
"/next-number",
|
||||
{ preHandler: requirePermission("invoices.create") },
|
||||
async (_request, reply) => {
|
||||
const result = await getNextInvoiceNumberFormatted();
|
||||
return success(reply, result);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/stats
|
||||
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const month = query.month ? Number(query.month) : undefined;
|
||||
const year = query.year ? Number(query.year) : undefined;
|
||||
const stats = await getInvoiceStats(month, year);
|
||||
return success(reply, stats);
|
||||
});
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const month = query.month ? Number(query.month) : undefined;
|
||||
const year = query.year ? Number(query.year) : undefined;
|
||||
const stats = await getInvoiceStats(month, year);
|
||||
return success(reply, stats);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/order-data/:id
|
||||
fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
|
||||
const orderId = parseId(request.params.id, reply);
|
||||
if (orderId === null) return;
|
||||
const result = await getOrderDataForInvoice(orderId);
|
||||
if (!result) return error(reply, 'Objednávka nenalezena', 404);
|
||||
return success(reply, result);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/order-data/:id",
|
||||
{ preHandler: requirePermission("invoices.create") },
|
||||
async (request, reply) => {
|
||||
const orderId = parseId(request.params.id, reply);
|
||||
if (orderId === null) return;
|
||||
const result = await getOrderDataForInvoice(orderId);
|
||||
if (!result) return error(reply, "Objednávka nenalezena", 404);
|
||||
return success(reply, result);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/:id
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await getInvoice(id);
|
||||
if (!invoice) return error(reply, 'Faktura nenalezena', 404);
|
||||
return success(reply, invoice);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await getInvoice(id);
|
||||
if (!invoice) return error(reply, "Faktura nenalezena", 404);
|
||||
return success(reply, invoice);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/invoices
|
||||
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateInvoiceSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("invoices.create") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateInvoiceSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
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}` });
|
||||
// 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');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
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
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateInvoiceSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateInvoiceSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await updateInvoice(id, body);
|
||||
const result = await updateInvoice(id, body);
|
||||
|
||||
if ('error' in result) {
|
||||
if (result.error === 'not_found') 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);
|
||||
}
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
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}` });
|
||||
return success(reply, { id }, 200, 'Faktura byla aktualizována');
|
||||
});
|
||||
await logAudit({
|
||||
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
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await deleteInvoice(id);
|
||||
if (!existing) return error(reply, 'Faktura nenalezena', 404);
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await deleteInvoice(id);
|
||||
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}` });
|
||||
return success(reply, null, 200, 'Faktura smazána');
|
||||
});
|
||||
await logAudit({
|
||||
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");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { attendance_leave_type, leave_requests_leave_type, leave_requests_status } from '@prisma/client';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth, requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error, parseId } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { CreateLeaveRequestSchema, ReviewLeaveRequestSchema } from '../../schemas/leave-requests.schema';
|
||||
import { notifyNewLeaveRequest } from '../../services/leave-notification';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {
|
||||
attendance_leave_type,
|
||||
leave_requests_leave_type,
|
||||
leave_requests_status,
|
||||
} from "@prisma/client";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
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_REVIEW_STATUSES = ['approved', 'rejected'] as const;
|
||||
const VALID_LEAVE_TYPES = ["vacation", "sick", "unpaid"] as const;
|
||||
const VALID_REVIEW_STATUSES = ["approved", "rejected"] as const;
|
||||
|
||||
export default async function leaveRequestsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
export default async function leaveRequestsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
const authData = request.authData!;
|
||||
const isAdmin = authData.permissions.includes('attendance.approve');
|
||||
const isAdmin = authData.permissions.includes("attendance.approve");
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
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([
|
||||
prisma.leave_requests.findMany({
|
||||
where, skip, take: limit, orderBy: { created_at: order },
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { created_at: order },
|
||||
include: {
|
||||
users_leave_requests_user_idTousers: { select: { id: true, first_name: true, last_name: true } },
|
||||
users_leave_requests_reviewer_idTousers: { select: { id: true, first_name: true, last_name: true } },
|
||||
users_leave_requests_user_idTousers: {
|
||||
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 }),
|
||||
]);
|
||||
|
||||
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);
|
||||
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 authData = request.authData!;
|
||||
|
||||
const leaveType = body.leave_type;
|
||||
if (!VALID_LEAVE_TYPES.includes(leaveType as typeof VALID_LEAVE_TYPES[number])) {
|
||||
return error(reply, 'Neplatný typ nepřítomnosti', 400);
|
||||
if (
|
||||
!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 dateTo = new Date(body.date_to);
|
||||
|
||||
if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) {
|
||||
return error(reply, 'Neplatné datum', 400);
|
||||
return error(reply, "Neplatné datum", 400);
|
||||
}
|
||||
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)
|
||||
@@ -69,7 +93,7 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -81,177 +105,258 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
|
||||
total_hours: businessDays * 8,
|
||||
total_days: businessDays,
|
||||
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)
|
||||
try {
|
||||
const employeeName = `${authData.firstName} ${authData.lastName}`.trim() || authData.username;
|
||||
notifyNewLeaveRequest({
|
||||
leave_type: leaveType,
|
||||
date_from: body.date_from,
|
||||
date_to: body.date_to,
|
||||
total_days: businessDays,
|
||||
total_hours: businessDays * 8,
|
||||
notes: body.notes,
|
||||
}, employeeName).catch(err => request.log.error(err, 'Leave notification error'));
|
||||
const employeeName =
|
||||
`${authData.firstName} ${authData.lastName}`.trim() ||
|
||||
authData.username;
|
||||
notifyNewLeaveRequest(
|
||||
{
|
||||
leave_type: leaveType,
|
||||
date_from: body.date_from,
|
||||
date_to: body.date_to,
|
||||
total_days: businessDays,
|
||||
total_hours: businessDays * 8,
|
||||
notes: body.notes,
|
||||
},
|
||||
employeeName,
|
||||
).catch((err) => request.log.error(err, "Leave notification error"));
|
||||
} 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)
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('attendance.approve') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(ReviewLeaveRequestSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
const authData = request.authData!;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("attendance.approve") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(ReviewLeaveRequestSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
const authData = request.authData!;
|
||||
|
||||
const status = body.status;
|
||||
if (!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 } });
|
||||
if (!existing) return error(reply, 'Žádost nenalezena', 404);
|
||||
|
||||
if (existing.status !== 'pending') {
|
||||
return error(reply, 'Lze schválit/zamítnout pouze čekající žádosti', 400);
|
||||
}
|
||||
|
||||
if (status === 'approved') {
|
||||
// --- APPROVAL: create attendance records + update leave balance (matching PHP) ---
|
||||
const leaveType = existing.leave_type as string;
|
||||
const dateFrom = new Date(existing.date_from);
|
||||
const dateTo = new Date(existing.date_to);
|
||||
|
||||
// For vacation: re-check balance at approval time
|
||||
if (leaveType === 'vacation') {
|
||||
const year = dateFrom.getFullYear();
|
||||
const balance = await prisma.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
const vacTotal = balance ? Number(balance.vacation_total) : 160;
|
||||
const vacUsed = balance ? Number(balance.vacation_used) : 0;
|
||||
const vacRemaining = vacTotal - vacUsed;
|
||||
const totalHours = Number(existing.total_hours) || 0;
|
||||
if (totalHours > vacRemaining) {
|
||||
return error(reply, `Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`, 400);
|
||||
}
|
||||
const status = body.status;
|
||||
if (
|
||||
!VALID_REVIEW_STATUSES.includes(
|
||||
status as (typeof VALID_REVIEW_STATUSES)[number],
|
||||
)
|
||||
) {
|
||||
return error(reply, "Neplatný stav", 400);
|
||||
}
|
||||
|
||||
// Count business days and create attendance records
|
||||
let totalBusinessDays = 0;
|
||||
const current = new Date(dateFrom);
|
||||
const attendanceCreates: Array<{
|
||||
user_id: number;
|
||||
shift_date: Date;
|
||||
leave_type: attendance_leave_type;
|
||||
leave_hours: number;
|
||||
notes: string;
|
||||
}> = [];
|
||||
const existing = await prisma.leave_requests.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) return error(reply, "Žádost nenalezena", 404);
|
||||
|
||||
while (current <= dateTo) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
totalBusinessDays++;
|
||||
attendanceCreates.push({
|
||||
user_id: existing.user_id,
|
||||
shift_date: new Date(Date.UTC(current.getFullYear(), current.getMonth(), current.getDate(), 12, 0, 0)),
|
||||
leave_type: leaveType as attendance_leave_type,
|
||||
leave_hours: 8,
|
||||
notes: `Schválená žádost #${id}`,
|
||||
});
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
if (existing.status !== "pending") {
|
||||
return error(
|
||||
reply,
|
||||
"Lze schválit/zamítnout pouze čekající žádosti",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
if (status === "approved") {
|
||||
// --- APPROVAL: create attendance records + update leave balance (matching PHP) ---
|
||||
const leaveType = existing.leave_type as string;
|
||||
const dateFrom = new Date(existing.date_from);
|
||||
const dateTo = new Date(existing.date_to);
|
||||
|
||||
// Run everything in a transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create attendance records for each business day
|
||||
if (attendanceCreates.length > 0) {
|
||||
await tx.attendance.createMany({ data: attendanceCreates });
|
||||
}
|
||||
|
||||
// 2. Update leave balance (vacation/sick only — not unpaid)
|
||||
if (leaveType === 'vacation' || leaveType === 'sick') {
|
||||
// For vacation: re-check balance at approval time
|
||||
if (leaveType === "vacation") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
const balance = await prisma.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = { updated_at: new Date() };
|
||||
if (leaveType === 'vacation') {
|
||||
updateData.vacation_used = Number(existingBalance.vacation_used) + totalHours;
|
||||
} else {
|
||||
updateData.sick_used = Number(existingBalance.sick_used) + totalHours;
|
||||
}
|
||||
await tx.leave_balances.update({ where: { id: existingBalance.id }, data: updateData });
|
||||
} else {
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === 'vacation' ? totalHours : 0,
|
||||
sick_used: leaveType === 'sick' ? totalHours : 0,
|
||||
},
|
||||
});
|
||||
const vacTotal = balance ? Number(balance.vacation_total) : 160;
|
||||
const vacUsed = balance ? Number(balance.vacation_used) : 0;
|
||||
const vacRemaining = vacTotal - vacUsed;
|
||||
const totalHours = Number(existing.total_hours) || 0;
|
||||
if (totalHours > vacRemaining) {
|
||||
return error(
|
||||
reply,
|
||||
`Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update request status
|
||||
await tx.leave_requests.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'approved' as leave_requests_status,
|
||||
reviewer_id: authData.userId,
|
||||
reviewed_at: new Date(),
|
||||
},
|
||||
// Count business days and create attendance records
|
||||
let totalBusinessDays = 0;
|
||||
const current = new Date(dateFrom);
|
||||
const attendanceCreates: Array<{
|
||||
user_id: number;
|
||||
shift_date: Date;
|
||||
leave_type: attendance_leave_type;
|
||||
leave_hours: number;
|
||||
notes: string;
|
||||
}> = [];
|
||||
|
||||
while (current <= dateTo) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
totalBusinessDays++;
|
||||
attendanceCreates.push({
|
||||
user_id: existing.user_id,
|
||||
shift_date: new Date(
|
||||
Date.UTC(
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
),
|
||||
leave_type: leaveType as attendance_leave_type,
|
||||
leave_hours: 8,
|
||||
notes: `Schválená žádost #${id}`,
|
||||
});
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
|
||||
// Run everything in a transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create attendance records for each business day
|
||||
if (attendanceCreates.length > 0) {
|
||||
await tx.attendance.createMany({ data: attendanceCreates });
|
||||
}
|
||||
|
||||
// 2. Update leave balance (vacation/sick only — not unpaid)
|
||||
if (leaveType === "vacation" || leaveType === "sick") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (leaveType === "vacation") {
|
||||
updateData.vacation_used =
|
||||
Number(existingBalance.vacation_used) + totalHours;
|
||||
} else {
|
||||
updateData.sick_used =
|
||||
Number(existingBalance.sick_used) + totalHours;
|
||||
}
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? totalHours : 0,
|
||||
sick_used: leaveType === "sick" ? totalHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update request status
|
||||
await tx.leave_requests.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "approved" as leave_requests_status,
|
||||
reviewer_id: authData.userId,
|
||||
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)`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Žádost byla schválena");
|
||||
}
|
||||
|
||||
// --- REJECTION: just update status ---
|
||||
await prisma.leave_requests.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "rejected" as leave_requests_status,
|
||||
reviewer_id: authData.userId,
|
||||
reviewer_note: body.reviewer_note ? String(body.reviewer_note) : null,
|
||||
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)` });
|
||||
return success(reply, { id }, 200, 'Žádost byla schválena');
|
||||
}
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "leave_request",
|
||||
entityId: id,
|
||||
description: "Žádost zamítnuta",
|
||||
});
|
||||
return success(reply, { id }, 200, "Žádost byla zamítnuta");
|
||||
},
|
||||
);
|
||||
|
||||
// --- REJECTION: just update status ---
|
||||
await prisma.leave_requests.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'rejected' as leave_requests_status,
|
||||
reviewer_id: authData.userId,
|
||||
reviewer_note: body.reviewer_note ? String(body.reviewer_note) : null,
|
||||
reviewed_at: new Date(),
|
||||
},
|
||||
});
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.leave_requests.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) return error(reply, "Žádost nenalezena", 404);
|
||||
|
||||
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: 'Žádost zamítnuta' });
|
||||
return success(reply, { id }, 200, 'Žádost byla zamítnuta');
|
||||
});
|
||||
if (existing.status !== "pending") {
|
||||
return error(reply, "Lze zrušit pouze čekající žádosti", 400);
|
||||
}
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.leave_requests.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Žádost nenalezena', 404);
|
||||
|
||||
if (existing.status !== 'pending') {
|
||||
return error(reply, 'Lze zrušit pouze čekající žádosti', 400);
|
||||
}
|
||||
|
||||
await prisma.leave_requests.update({ where: { id }, 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');
|
||||
});
|
||||
await prisma.leave_requests.update({
|
||||
where: { id },
|
||||
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");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = new Date(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 */
|
||||
function formatNum(n: number, decimals: number): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
@@ -22,66 +22,92 @@ function formatNum(n: number, decimals: number): string {
|
||||
function formatCurrency(amount: number, currency: string): string {
|
||||
const n = Number(amount) || 0;
|
||||
switch (currency) {
|
||||
case 'EUR': return `${formatNum(n, 2)} \u20AC`;
|
||||
case 'USD': return `$${Math.abs(n).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}`;
|
||||
case "EUR":
|
||||
return `${formatNum(n, 2)} \u20AC`;
|
||||
case "USD":
|
||||
return `$${Math.abs(n)
|
||||
.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 {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
const allowedTags = '<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>';
|
||||
if (!html) return "";
|
||||
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
|
||||
let s = html;
|
||||
// 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(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, '');
|
||||
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(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
// 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
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, ' ');
|
||||
s = s.replace(/( )/g, " ");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = '';
|
||||
let prev = "";
|
||||
while (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;
|
||||
}
|
||||
|
||||
interface AddressResult { name: string; lines: string[] }
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isSupplier: boolean,
|
||||
t: (key: string) => string,
|
||||
): AddressResult {
|
||||
if (!entity) return { name: '', lines: [] };
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
const nameKey = isSupplier ? 'company_name' : 'name';
|
||||
const name = String(entity[nameKey] || '');
|
||||
const nameKey = isSupplier ? "company_name" : "name";
|
||||
const name = String(entity[nameKey] || "");
|
||||
|
||||
// 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;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData = ((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;
|
||||
cfData =
|
||||
((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)) {
|
||||
cfData = parsed;
|
||||
}
|
||||
@@ -91,29 +117,37 @@ function buildAddressLines(
|
||||
// Legacy PascalCase key compat
|
||||
if (Array.isArray(fieldOrder)) {
|
||||
const legacyMap: Record<string, string> = {
|
||||
Name: 'name', CompanyName: 'company_name',
|
||||
Street: 'street', CityPostal: 'city_postal',
|
||||
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id',
|
||||
Name: "name",
|
||||
CompanyName: "company_name",
|
||||
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> = {};
|
||||
if (name) fieldMap[nameKey] = name;
|
||||
if (entity.street) fieldMap.street = String(entity.street);
|
||||
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String);
|
||||
const cityPostal = cityParts.join(' ').trim();
|
||||
const cityParts = [entity.city || "", entity.postal_code || ""]
|
||||
.filter(Boolean)
|
||||
.map(String);
|
||||
const cityPostal = cityParts.join(" ").trim();
|
||||
if (cityPostal) fieldMap.city_postal = cityPostal;
|
||||
if (entity.country) fieldMap.country = String(entity.country);
|
||||
if (entity.company_id) fieldMap.company_id = `${t('ico')}: ${entity.company_id}`;
|
||||
if (entity.vat_id) fieldMap.vat_id = `${t('dic')}: ${entity.vat_id}`;
|
||||
if (entity.company_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) => {
|
||||
const cfName = (cf.name || '').trim();
|
||||
const cfValue = (cf.value || '').trim();
|
||||
const cfName = (cf.name || "").trim();
|
||||
const cfValue = (cf.value || "").trim();
|
||||
const showLabel = cf.showLabel !== false;
|
||||
if (cfValue) {
|
||||
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
|
||||
fieldMap[`custom_${i}`] =
|
||||
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -138,172 +172,199 @@ function buildAddressLines(
|
||||
}
|
||||
|
||||
const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
title: { EN: 'PRICE QUOTATION', CZ: 'CENOV\u00C1 NAB\u00CDDKA' },
|
||||
scope_title: { EN: 'SCOPE OF THE PROJECT', CZ: 'ROZSAH PROJEKTU' },
|
||||
valid_until: { EN: 'Valid until', CZ: 'Platnost do' },
|
||||
customer: { EN: 'Customer', CZ: 'Z\u00E1kazn\u00EDk' },
|
||||
supplier: { EN: 'Supplier', CZ: 'Dodavatel' },
|
||||
no: { EN: 'N.', CZ: '\u010C.' },
|
||||
description: { EN: 'Description', CZ: 'Popis' },
|
||||
qty: { EN: 'Qty', CZ: 'Mn.' },
|
||||
unit_price: { EN: 'Unit Price', CZ: 'Jedn. cena' },
|
||||
included: { EN: 'Included', CZ: 'Zahrnuto' },
|
||||
total: { EN: 'Total', CZ: 'Celkem' },
|
||||
subtotal: { EN: 'Subtotal', CZ: 'Mezisou\u010Det' },
|
||||
vat: { EN: 'VAT', CZ: 'DPH' },
|
||||
total_to_pay: { EN: 'Total to pay', CZ: 'Celkem k \u00FAhrad\u011B' },
|
||||
exchange_rate: { EN: 'Exchange rate', CZ: 'Sm\u011Bnn\u00FD kurz' },
|
||||
ico: { EN: 'ID', CZ: 'I\u010CO' },
|
||||
dic: { EN: 'VAT ID', CZ: 'DI\u010C' },
|
||||
page: { EN: 'Page', CZ: 'Strana' },
|
||||
of: { EN: 'of', CZ: 'z' },
|
||||
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
|
||||
scope_title: { EN: "SCOPE OF THE PROJECT", CZ: "ROZSAH PROJEKTU" },
|
||||
valid_until: { EN: "Valid until", CZ: "Platnost do" },
|
||||
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
|
||||
supplier: { EN: "Supplier", CZ: "Dodavatel" },
|
||||
no: { EN: "N.", CZ: "\u010C." },
|
||||
description: { EN: "Description", CZ: "Popis" },
|
||||
qty: { EN: "Qty", CZ: "Mn." },
|
||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||
total: { EN: "Total", CZ: "Celkem" },
|
||||
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
||||
vat: { EN: "VAT", CZ: "DPH" },
|
||||
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
|
||||
exchange_rate: { EN: "Exchange rate", CZ: "Sm\u011Bnn\u00FD kurz" },
|
||||
ico: { EN: "ID", CZ: "I\u010CO" },
|
||||
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
||||
page: { EN: "Page", CZ: "Strana" },
|
||||
of: { EN: "of", CZ: "z" },
|
||||
};
|
||||
|
||||
export default async function offersPdfRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
export default async function offersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: 'asc' } },
|
||||
scope_sections: { orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!quotation) {
|
||||
return reply.status(404).type('text/html').send('<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>');
|
||||
}
|
||||
if (!quotation) {
|
||||
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 isCzech = (quotation.language ?? 'EN') !== 'EN';
|
||||
const langKey = isCzech ? 'CZ' : 'EN';
|
||||
const currency = quotation.currency || 'EUR';
|
||||
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const isCzech = (quotation.language ?? "EN") !== "EN";
|
||||
const langKey = isCzech ? "CZ" : "EN";
|
||||
const currency = quotation.currency || "EUR";
|
||||
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
|
||||
|
||||
// Logo
|
||||
let logoImg = '';
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data);
|
||||
let mime = 'image/png';
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp';
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString('base64')}" class="logo" />`;
|
||||
}
|
||||
// Logo
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Calculations
|
||||
const items = quotation.quotation_items;
|
||||
let subtotal = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
subtotal += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
const applyVat = !!quotation.apply_vat;
|
||||
const vatRate = Number(quotation.vat_rate) || 21;
|
||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||
const totalToPay = subtotal + vatAmount;
|
||||
const exchangeRate = Number(quotation.exchange_rate) || 0;
|
||||
// Calculations
|
||||
const items = quotation.quotation_items;
|
||||
let subtotal = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
subtotal +=
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
const applyVat = !!quotation.apply_vat;
|
||||
const vatRate = Number(quotation.vat_rate) || 21;
|
||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||
const totalToPay = subtotal + vatAmount;
|
||||
const exchangeRate = Number(quotation.exchange_rate) || 0;
|
||||
|
||||
// Scope content check
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || '').trim() || (s.title || '').trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Scope content check
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || "").trim() || (s.title || "").trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Addresses
|
||||
const cust = buildAddressLines(quotation.customers as unknown as Record<string, unknown>, false, t);
|
||||
const supp = buildAddressLines(settings as unknown as Record<string, unknown>, true, t);
|
||||
// Addresses
|
||||
const cust = buildAddressLines(
|
||||
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 suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
|
||||
const custLinesHtml = cust.lines
|
||||
.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
|
||||
let indentCSS = '';
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Indentation CSS for Quill
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
// Items HTML
|
||||
let itemsHtml = '';
|
||||
items.forEach((item, i) => {
|
||||
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const subDesc = item.item_description || '';
|
||||
const evenClass = (i % 2 === 1) ? ' class="even"' : '';
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
// Items HTML
|
||||
let itemsHtml = "";
|
||||
items.forEach((item, i) => {
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const subDesc = item.item_description || "";
|
||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
<td class="row-num">${i + 1}</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="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="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Totals HTML
|
||||
let totalsHtml = '';
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
// Totals HTML
|
||||
let totalsHtml = "";
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t('subtotal'))}:</span>
|
||||
<span class="label">${escapeHtml(t("subtotal"))}:</span>
|
||||
<span class="value">${formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t('total_to_pay'))}</span>
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
|
||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
||||
</div>`;
|
||||
if (exchangeRate > 0) {
|
||||
totalsHtml += `<div class="exchange-rate">${escapeHtml(t('exchange_rate'))}: ${formatNum(exchangeRate, 4)}</div>`;
|
||||
}
|
||||
if (exchangeRate > 0) {
|
||||
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
|
||||
let scopeHtml = '';
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `<div class="page-header">
|
||||
// Scope HTML
|
||||
let scopeHtml = "";
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `<div class="page-header">
|
||||
<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>
|
||||
${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>
|
||||
${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>
|
||||
${logoImg ? `<div class="right">${logoImg}</div>` : ''}
|
||||
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
|
||||
</div>
|
||||
<hr class="separator" />`;
|
||||
|
||||
for (const section of quotation.scope_sections) {
|
||||
const title = isCzech && (section.title_cz || '').trim() ? section.title_cz : (section.title || '');
|
||||
const content = (section.content || '').trim();
|
||||
if (!title && !content) continue;
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title) scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content) scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
|
||||
scopeHtml += '</div>';
|
||||
}
|
||||
scopeHtml += '</div>';
|
||||
}
|
||||
for (const section of quotation.scope_sections) {
|
||||
const title =
|
||||
isCzech && (section.title_cz || "").trim()
|
||||
? section.title_cz
|
||||
: section.title || "";
|
||||
const content = (section.content || "").trim();
|
||||
if (!title && !content) continue;
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title)
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
|
||||
const pageLabel = escapeHtml(t('page'));
|
||||
const ofLabel = escapeHtml(t('of'));
|
||||
const pageLabel = escapeHtml(t("page"));
|
||||
const ofLabel = escapeHtml(t("of"));
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${isCzech ? 'cs' : 'en'}">
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${isCzech ? "cs" : "en"}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${quotationNumber}</title>
|
||||
@@ -655,22 +716,22 @@ ${indentCSS}
|
||||
<div class="first-content">
|
||||
<div class="page-header">
|
||||
<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>
|
||||
${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>
|
||||
${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>
|
||||
</div>
|
||||
<hr class="separator" />
|
||||
|
||||
<div class="addresses">
|
||||
<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>
|
||||
${custLinesHtml}
|
||||
</div>
|
||||
<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>
|
||||
${suppLinesHtml}
|
||||
</div>
|
||||
@@ -679,11 +740,11 @@ ${indentCSS}
|
||||
<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:5%">${escapeHtml(t('no'))}</th>
|
||||
<th style="width:44%">${escapeHtml(t('description'))}</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:20%">${escapeHtml(t('total'))}</th>
|
||||
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
|
||||
<th style="width:44%">${escapeHtml(t("description"))}</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:20%">${escapeHtml(t("total"))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -707,11 +768,16 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return reply.type('text/html').send(html);
|
||||
|
||||
} catch (err) {
|
||||
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.type("text/html").send(html);
|
||||
} catch (err) {
|
||||
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>",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error, parseId } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { CreateOrderFromQuotationSchema, CreateOrderSchema, UpdateOrderSchema } from '../../schemas/orders.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
CreateOrderFromQuotationSchema,
|
||||
CreateOrderSchema,
|
||||
UpdateOrderSchema,
|
||||
} from "../../schemas/orders.schema";
|
||||
import {
|
||||
listOrders,
|
||||
getOrder,
|
||||
@@ -14,140 +18,258 @@ import {
|
||||
updateOrder,
|
||||
deleteOrder,
|
||||
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 } });
|
||||
|
||||
// GET /api/admin/orders/next-number
|
||||
fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => {
|
||||
const number = await getNextOrderNumber();
|
||||
return success(reply, { number, next_number: number });
|
||||
});
|
||||
fastify.get(
|
||||
"/next-number",
|
||||
{ preHandler: requirePermission("orders.create") },
|
||||
async (_request, reply) => {
|
||||
const number = await getNextOrderNumber();
|
||||
return success(reply, { number, next_number: number });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get('/', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order } = parsePagination(query);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order } = parsePagination(query);
|
||||
|
||||
const result = await listOrders({
|
||||
page, limit, skip, sort, order,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
const result = await listOrders({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort,
|
||||
order,
|
||||
status: query.status ? String(query.status) : 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) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const order = await getOrder(id);
|
||||
if (!order) return error(reply, 'Objednávka nenalezena', 404);
|
||||
return success(reply, order);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const order = await getOrder(id);
|
||||
if (!order) return error(reply, "Objednávka nenalezena", 404);
|
||||
return success(reply, order);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/orders/:id/attachment
|
||||
fastify.get<{ Params: { id: string } }>('/:id/attachment', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, 'Příloha nenalezena', 404);
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id/attachment",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
||||
|
||||
return reply
|
||||
.type('application/pdf')
|
||||
.header('Content-Disposition', `inline; filename="${attachment.filename}"`)
|
||||
.send(attachment.data);
|
||||
});
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${attachment.filename}"`,
|
||||
)
|
||||
.send(attachment.data);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation)
|
||||
fastify.post('/', { preHandler: requirePermission('orders.create') }, async (request, reply) => {
|
||||
const isMultipart = request.headers['content-type']?.includes('multipart');
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("orders.create") },
|
||||
async (request, reply) => {
|
||||
const isMultipart =
|
||||
request.headers["content-type"]?.includes("multipart");
|
||||
|
||||
if (isMultipart) {
|
||||
// === Order from quotation flow (multipart) ===
|
||||
const fields: Record<string, string> = {};
|
||||
let attachmentBuffer: Buffer | null = null;
|
||||
let attachmentName: string | null = null;
|
||||
if (isMultipart) {
|
||||
// === Order from quotation flow (multipart) ===
|
||||
const fields: Record<string, string> = {};
|
||||
let attachmentBuffer: Buffer | null = null;
|
||||
let attachmentName: string | null = null;
|
||||
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'field') {
|
||||
fields[part.fieldname] = String(part.value);
|
||||
} else if (part.type === 'file' && part.fieldname === 'attachment') {
|
||||
attachmentBuffer = await part.toBuffer();
|
||||
attachmentName = part.filename;
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "field") {
|
||||
fields[part.fieldname] = String(part.value);
|
||||
} else if (part.type === "file" && part.fieldname === "attachment") {
|
||||
attachmentBuffer = await part.toBuffer();
|
||||
attachmentName = part.filename;
|
||||
}
|
||||
}
|
||||
|
||||
const quotationId = parseInt(fields.quotationId, 10);
|
||||
const customerOrderNumber = fields.customerOrderNumber || "";
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, "Chybí ID nabídky", 400);
|
||||
}
|
||||
|
||||
const result = await createOrderFromQuotation({
|
||||
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}`,
|
||||
});
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
const quotationId = parseInt(fields.quotationId, 10);
|
||||
const customerOrderNumber = fields.customerOrderNumber || '';
|
||||
// === JSON body — either from-quotation (no attachment) or manual order ===
|
||||
const rawBody = request.body as Record<string, unknown>;
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, 'Chybí ID nabídky', 400);
|
||||
// From-quotation flow via JSON (no attachment)
|
||||
if (rawBody.quotationId) {
|
||||
const fromQuotParsed = parseBody(
|
||||
CreateOrderFromQuotationSchema,
|
||||
rawBody,
|
||||
);
|
||||
if ("error" in fromQuotParsed)
|
||||
return error(reply, fromQuotParsed.error, 400);
|
||||
const quotationId = fromQuotParsed.data.quotationId;
|
||||
const customerOrderNumber = fromQuotParsed.data.customerOrderNumber;
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, "Chybí ID nabídky", 400);
|
||||
}
|
||||
|
||||
const result = await createOrderFromQuotation({
|
||||
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}`,
|
||||
});
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber, attachmentBuffer, attachmentName });
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
// Manual order creation
|
||||
const manualParsed = parseBody(CreateOrderSchema, rawBody);
|
||||
if ("error" in manualParsed) return error(reply, manualParsed.error, 400);
|
||||
const body = manualParsed.data;
|
||||
|
||||
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}` });
|
||||
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');
|
||||
}
|
||||
const result = await createOrder(body as any);
|
||||
|
||||
// === JSON body — either from-quotation (no attachment) or manual order ===
|
||||
const rawBody = request.body as Record<string, unknown>;
|
||||
await logAudit({
|
||||
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",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// From-quotation flow via JSON (no attachment)
|
||||
if (rawBody.quotationId) {
|
||||
const fromQuotParsed = parseBody(CreateOrderFromQuotationSchema, rawBody);
|
||||
if ('error' in fromQuotParsed) return error(reply, fromQuotParsed.error, 400);
|
||||
const quotationId = fromQuotParsed.data.quotationId;
|
||||
const customerOrderNumber = fromQuotParsed.data.customerOrderNumber;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateOrderSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, 'Chybí ID nabídky', 400);
|
||||
}
|
||||
const result = await updateOrder(id, parsed.data as any);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber });
|
||||
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}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Objednávka byla uložena");
|
||||
},
|
||||
);
|
||||
|
||||
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}` });
|
||||
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');
|
||||
}
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("orders.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
// Manual order creation
|
||||
const manualParsed = parseBody(CreateOrderSchema, rawBody);
|
||||
if ('error' in manualParsed) return error(reply, manualParsed.error, 400);
|
||||
const body = manualParsed.data;
|
||||
const result = await deleteOrder(id);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
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}` });
|
||||
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) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateOrderSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const result = await updateOrder(id, parsed.data as any);
|
||||
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}` });
|
||||
return success(reply, { id }, 200, 'Objednávka byla uložena');
|
||||
});
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const result = await deleteOrder(id);
|
||||
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}` });
|
||||
return success(reply, null, 200, 'Objednávka smazána');
|
||||
});
|
||||
await logAudit({
|
||||
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");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
import { success, error } from '../../utils/response';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { config } from '../../config/env';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { UpdateProfileSchema } from '../../schemas/profile.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth } from "../../middleware/auth";
|
||||
import { success, error } from "../../utils/response";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { config } from "../../config/env";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { UpdateProfileSchema } from "../../schemas/profile.schema";
|
||||
|
||||
export default async function profileRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
export default async function profileRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
select: {
|
||||
id: true, username: true, email: true, first_name: true, last_name: true,
|
||||
totp_enabled: true, last_login: true, password_changed_at: true,
|
||||
id: 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 } },
|
||||
},
|
||||
});
|
||||
if (!user) return error(reply, 'Uživatel nenalezen', 404);
|
||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||
return success(reply, user);
|
||||
});
|
||||
|
||||
fastify.put('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.put("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
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 userId = request.authData!.userId;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.email) {
|
||||
const newEmail = String(body.email).trim();
|
||||
const existing = await prisma.users.findFirst({ where: { email: newEmail, id: { not: userId } } });
|
||||
if (existing) return error(reply, 'E-mail již existuje', 409);
|
||||
const existing = await prisma.users.findFirst({
|
||||
where: { email: newEmail, id: { not: userId } },
|
||||
});
|
||||
if (existing) return error(reply, "E-mail již existuje", 409);
|
||||
data.email = newEmail;
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
if (!valid) return error(reply, 'Nesprávné aktuální heslo', 400);
|
||||
const valid = await bcrypt.compare(
|
||||
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();
|
||||
|
||||
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 });
|
||||
return success(reply, null, 200, 'Profil aktualizován');
|
||||
return success(reply, null, 200, "Profil aktualizován");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
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';
|
||||
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 } });
|
||||
export default async function projectFilesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
await fastify.register(multipart, {
|
||||
limits: { fileSize: config.nas.maxUploadSize },
|
||||
});
|
||||
|
||||
const fm = new NasFileManager();
|
||||
|
||||
@@ -22,194 +26,230 @@ export default async function projectFilesRoutes(fastify: FastifyInstance): Prom
|
||||
}
|
||||
|
||||
// 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);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const subPath = query.path || '';
|
||||
const subPath = query.path || "";
|
||||
|
||||
if (query.action === 'download') {
|
||||
if (!subPath) return error(reply, 'Cesta k souboru je povinná');
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
if (query.action === "download") {
|
||||
if (!subPath) return error(reply, "Cesta k souboru je povinná");
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
const result = fm.downloadFile(project.project_number, subPath);
|
||||
if (!result) return error(reply, 'Soubor nebyl nalezen', 404);
|
||||
const result = fm.downloadFile(project.project_number, subPath);
|
||||
if (!result) return error(reply, "Soubor nebyl nalezen", 404);
|
||||
|
||||
const stream = fs.createReadStream(result.filePath);
|
||||
return reply
|
||||
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(result.fileName)}"`)
|
||||
.header('Content-Type', result.mime)
|
||||
.header('X-Content-Type-Options', 'nosniff')
|
||||
.send(stream);
|
||||
}
|
||||
const stream = fs.createReadStream(result.filePath);
|
||||
return reply
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
|
||||
)
|
||||
.header("Content-Type", result.mime)
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.send(stream);
|
||||
}
|
||||
|
||||
// List files
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
// List files
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
const result = fm.listFiles(project.project_number, subPath);
|
||||
if (result === null) {
|
||||
return error(reply, 'Složka nebyla nalezena', 404);
|
||||
}
|
||||
const result = fm.listFiles(project.project_number, subPath);
|
||||
if (result === null) {
|
||||
return error(reply, "Složka nebyla nalezena", 404);
|
||||
}
|
||||
|
||||
return success(reply, {
|
||||
...result,
|
||||
project_number: project.project_number,
|
||||
folder_exists: true,
|
||||
});
|
||||
});
|
||||
return success(reply, {
|
||||
...result,
|
||||
project_number: project.project_number,
|
||||
folder_exists: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST / — create folder (JSON body)
|
||||
fastify.post('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const folderName = String(body.folder_name || '').trim();
|
||||
const path = String(body.path || '');
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const folderName = String(body.folder_name || "").trim();
|
||||
const path = String(body.path || "");
|
||||
|
||||
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) 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ů)");
|
||||
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || '');
|
||||
}
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || "");
|
||||
}
|
||||
|
||||
const err = fm.createFolder(project.project_number, path, folderName);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = fm.createFolder(project.project_number, path, folderName);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'create',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`,
|
||||
newValues: { folder: folderName, path },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`,
|
||||
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)
|
||||
fastify.post('/upload', {
|
||||
preHandler: requirePermission('projects.files'),
|
||||
bodyLimit: config.nas.maxUploadSize,
|
||||
}, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.post(
|
||||
"/upload",
|
||||
{
|
||||
preHandler: requirePermission("projects.files"),
|
||||
bodyLimit: config.nas.maxUploadSize,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || '');
|
||||
}
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || "");
|
||||
}
|
||||
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, 'Nebyl nahrán žádný soubor');
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
|
||||
|
||||
const subPath = query.path || '';
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileName = file.filename;
|
||||
const subPath = query.path || "";
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileName = file.filename;
|
||||
|
||||
const err = await fm.uploadFile(project.project_number, subPath, fileBuffer, fileName);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = await fm.uploadFile(
|
||||
project.project_number,
|
||||
subPath,
|
||||
fileBuffer,
|
||||
fileName,
|
||||
);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'create',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Nahrán soubor do projektu '${project.project_number}'`,
|
||||
newValues: { file: fileName, path: subPath },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Nahrán soubor do projektu '${project.project_number}'`,
|
||||
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
|
||||
fastify.put('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.put(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const fromPath = String(body.from_path || '');
|
||||
const toPath = String(body.to_path || '');
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const fromPath = String(body.from_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);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = fm.moveItem(project.project_number, fromPath, toPath);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'update',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Přesun/přejmenování v projektu '${project.project_number}'`,
|
||||
oldValues: { path: fromPath },
|
||||
newValues: { path: toPath },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Přesun/přejmenování v projektu '${project.project_number}'`,
|
||||
oldValues: { path: fromPath },
|
||||
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
|
||||
fastify.delete('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.delete(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
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á');
|
||||
const filePath = query.path || "";
|
||||
if (!filePath) return error(reply, "Cesta k souboru je povinná");
|
||||
|
||||
const err = await fm.deleteItem(project.project_number, filePath);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = await fm.deleteItem(project.project_number, filePath);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'delete',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Smazán soubor/složka v projektu '${project.project_number}'`,
|
||||
oldValues: { path: filePath },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Smazán soubor/složka v projektu '${project.project_number}'`,
|
||||
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
Reference in New Issue
Block a user