diff --git a/.claude/hooks/block-env.js b/.claude/hooks/block-env.js new file mode 100644 index 0000000..0601b8f --- /dev/null +++ b/.claude/hooks/block-env.js @@ -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 {} +}); diff --git a/.claude/hooks/format-on-save.js b/.claude/hooks/format-on-save.js new file mode 100644 index 0000000..3720413 --- /dev/null +++ b/.claude/hooks/format-on-save.js @@ -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 {} +}); diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a9a2b6a --- /dev/null +++ b/.claude/settings.json @@ -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..." + } + ] + } + ] + } +} diff --git a/.claude/skills/compare-php/SKILL.md b/.claude/skills/compare-php/SKILL.md new file mode 100644 index 0000000..8543307 --- /dev/null +++ b/.claude/skills/compare-php/SKILL.md @@ -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\` diff --git a/.env.test.example b/.env.test.example deleted file mode 100644 index 007e487..0000000 --- a/.env.test.example +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 21be2e1..bb8221f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ .env .env.test +.env.production *.log dist-client/ *.css.map diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 0000000..041ab80 --- /dev/null +++ b/docs/deployment-guide.md @@ -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= TOTP_ENCRYPTION_KEY= DATABASE_URL= 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= +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= + +# 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 --dry-run + +# If all [OK], run for real +npx tsx scripts/rotate-totp-key.ts +``` + +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) | diff --git a/docs/superpowers/plans/2026-03-23-project-files.md b/docs/superpowers/plans/2026-03-23-project-files.md new file mode 100644 index 0000000..512ee95 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-project-files.md @@ -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 { + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; +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 + + + +``` + +- [ ] **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 + +``` + +- [ ] **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" +``` diff --git a/docs/superpowers/specs/2026-03-23-project-files-design.md b/docs/superpowers/specs/2026-03-23-project-files-design.md new file mode 100644 index 0000000..3a85a71 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-project-files-design.md @@ -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 +projectFolderExists(projectNumber): boolean +renameProjectFolder(projectNumber, newName): boolean +listFiles(projectNumber, subPath): ListResult | null +uploadFile(projectNumber, subPath, fileBuffer, fileName): Promise +downloadFile(projectNumber, filePath): DownloadResult | null +deleteItem(projectNumber, filePath): Promise +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 `` 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 diff --git a/src/App.tsx b/src/App.tsx index f239460..175d20d 100644 --- a/src/App.tsx +++ b/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 ( -
+
- ) + ); } export default function App() { @@ -23,5 +25,5 @@ export default function App() { } /> - ) + ); } diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 10f6346..dab78d9 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -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>; -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); }); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index a9904d0..2988f6f 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -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; diff --git a/src/__tests__/numbering.test.ts b/src/__tests__/numbering.test.ts index e948f72..2b76d16 100644 --- a/src/__tests__/numbering.test.ts +++ b/src/__tests__/numbering.test.ts @@ -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,}$`)); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index c226d8f..1d2989f 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,2 +1,2 @@ -import dotenv from 'dotenv'; -dotenv.config({ path: '.env.test' }); +import dotenv from "dotenv"; +dotenv.config({ path: ".env.test" }); diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index dc40df8..5913425 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -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() { -
}> + +
+
+ } + > } /> }> } /> } /> } /> - } /> + } + /> } /> - } /> + } + /> } /> } /> - } /> - } /> + } + /> + } + /> } /> } /> } /> @@ -91,5 +109,5 @@ export default function AdminApp() {
- ) + ); } diff --git a/src/admin/components/AdminDatePicker.tsx b/src/admin/components/AdminDatePicker.tsx index 5066b45..40c6123 100644 --- a/src/admin/components/AdminDatePicker.tsx +++ b/src/admin/components/AdminDatePicker.tsx @@ -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) => void - placeholder?: string - required?: boolean - readOnly?: boolean - disabled?: boolean + value?: string; + onClick?: () => void; + onChange?: (e: React.ChangeEvent) => void; + placeholder?: string; + required?: boolean; + readOnly?: boolean; + disabled?: boolean; } const CustomInput = forwardRef( - ({ value, onClick, onChange, placeholder, required, readOnly, disabled }, ref) => ( + ( + { value, onClick, onChange, placeholder, required, readOnly, disabled }, + ref, + ) => ( ( 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 = { month: 'month', time: 'time' } +const modeToInputType: Record = { + 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 ( 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: , + locale: "cs", + customInput: ( + + ), 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 ( - ) + ); } - if (mode === 'month') { + if (mode === "month") { return ( - - ) + + ); } - return ( - - ) + return ; } diff --git a/src/admin/components/AdminLayout.tsx b/src/admin/components/AdminLayout.tsx index 5ec334e..11009a6 100644 --- a/src/admin/components/AdminLayout.tsx +++ b/src/admin/components/AdminLayout.tsx @@ -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 (
-
+
- ) + ); } if (!isAuthenticated) { - return + return ; } // 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 + const needs2FASetup = user?.require2FA && !user?.totpEnabled; + if (needs2FASetup && location.pathname !== "/") { + return ; } return ( - setSidebarOpen(false)} onLogout={handleLogout} /> + setSidebarOpen(false)} + onLogout={handleLogout} + />
@@ -67,7 +72,14 @@ export default function AdminLayout() { className="admin-menu-btn" aria-label="Otevřít menu" > - + @@ -79,22 +91,39 @@ export default function AdminLayout() { - + + - - + + -
@@ -103,5 +132,5 @@ export default function AdminLayout() {
- ) + ); } diff --git a/src/admin/components/AlertContainer.tsx b/src/admin/components/AlertContainer.tsx index fc5d14e..fc1ea11 100644 --- a/src/admin/components/AlertContainer.tsx +++ b/src/admin/components/AlertContainer.tsx @@ -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 = { success: ( - + ), error: ( - + ), warning: ( - + ), info: ( - + ), -} +}; export default function AlertContainer() { - const { alerts, removeAlert } = useAlertState() + const { alerts, removeAlert } = useAlertState(); return (
- {alerts.map(alert => ( + {alerts.map((alert) => ( removeAlert(alert.id)} aria-label="Zavřít" > - + @@ -63,5 +98,5 @@ export default function AlertContainer() { ))}
- ) + ); } diff --git a/src/admin/components/AttendanceShiftTable.tsx b/src/admin/components/AttendanceShiftTable.tsx index afdbad1..98cff0b 100644 --- a/src/admin/components/AttendanceShiftTable.tsx +++ b/src/admin/components/AttendanceShiftTable.tsx @@ -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 ( -
+
{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 ( - - {log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' \u25B8' : ''}) + + {log.project_name || `#${log.project_id}`} ({h}: + {String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""}) - ) + ); })}
- ) + ); } if (record.project_name) { - return {record.project_name} + return ( + + {record.project_name} + + ); } - 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 (

Za tento měsíc nejsou žádné záznamy.

- ) + ); } return ( @@ -110,40 +140,65 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte {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 ( {formatDate(record.shift_date)} {record.user_name} - + {getLeaveTypeName(leaveType)} - {isLeave ? '\u2014' : formatDatetime(record.arrival_time)} - {isLeave ? '\u2014' : formatBreak(record)} + {isLeave ? "\u2014" : formatDatetime(record.arrival_time)} - {isLeave ? '\u2014' : formatDatetime(record.departure_time)} - {workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '\u2014'} - - {renderProjectCell(record)} + + {isLeave ? "\u2014" : formatBreak(record)} + + {isLeave ? "\u2014" : formatDatetime(record.departure_time)} + + + {workMinutes > 0 + ? `${formatMinutes(workMinutes)} h` + : "\u2014"} + + {renderProjectCell(record)} {hasLocation ? ( - - {'\uD83D\uDCCD'} + + {"\uD83D\uDCCD"} - ) : '\u2014'} + ) : ( + "\u2014" + )} - - {record.notes || ''} + + {record.notes || ""}
@@ -153,7 +208,14 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte title="Upravit" aria-label="Upravit" > - + @@ -164,7 +226,14 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte title="Smazat" aria-label="Smazat" > - + @@ -172,10 +241,10 @@ export default function AttendanceShiftTable({ records, onEdit, onDelete }: Atte
- ) + ); })}
- ) + ); } diff --git a/src/admin/components/BulkAttendanceModal.tsx b/src/admin/components/BulkAttendanceModal.tsx index 8eb0a05..5af113a 100644 --- a/src/admin/components/BulkAttendanceModal.tsx +++ b/src/admin/components/BulkAttendanceModal.tsx @@ -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 ( @@ -51,7 +51,10 @@ export default function BulkAttendanceModal({ exit={{ opacity: 0 }} transition={{ duration: 0.2 }} > -
!submitting && onClose()} /> +
!submitting && onClose()} + />

Vyplnit docházku za měsíc

-

- Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Existující záznamy se přeskočí. +

+ Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky + označí. Existující záznamy se přeskočí.

@@ -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"}
{users.map((user) => ( @@ -132,7 +144,9 @@ export default function BulkAttendanceModal({ setForm({ ...form, arrival_time: val })} + onChange={(val) => + setForm({ ...form, arrival_time: val }) + } />
@@ -140,7 +154,9 @@ export default function BulkAttendanceModal({ setForm({ ...form, departure_time: val })} + onChange={(val) => + setForm({ ...form, departure_time: val }) + } />
@@ -151,7 +167,9 @@ export default function BulkAttendanceModal({ setForm({ ...form, break_start_time: val })} + onChange={(val) => + setForm({ ...form, break_start_time: val }) + } />
@@ -159,7 +177,9 @@ export default function BulkAttendanceModal({ setForm({ ...form, break_end_time: val })} + onChange={(val) => + setForm({ ...form, break_end_time: val }) + } />
@@ -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"}
)} - ) + ); } diff --git a/src/admin/components/ConfirmModal.tsx b/src/admin/components/ConfirmModal.tsx index 781d12f..fcf510c 100644 --- a/src/admin/components/ConfirmModal.tsx +++ b/src/admin/components/ConfirmModal.tsx @@ -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 ( {isOpen && ( - +
- + @@ -39,14 +63,26 @@ export default function ConfirmModal({ isOpen, onClose, onConfirm, title, messag

{message}

- - +
)} - ) + ); } diff --git a/src/admin/components/ErrorBoundary.tsx b/src/admin/components/ErrorBoundary.tsx index e91e24a..3b70168 100644 --- a/src/admin/components/ErrorBoundary.tsx +++ b/src/admin/components/ErrorBoundary.tsx @@ -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 { - 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 ( -
+

Něco se pokazilo

{this.state.error?.message}

- +
- ) + ); } - return this.props.children + return this.props.children; } } diff --git a/src/admin/components/Forbidden.tsx b/src/admin/components/Forbidden.tsx index 0d52a74..ee160a0 100644 --- a/src/admin/components/Forbidden.tsx +++ b/src/admin/components/Forbidden.tsx @@ -1,11 +1,16 @@ -import { Link } from 'react-router-dom' +import { Link } from "react-router-dom"; export default function Forbidden() { return ( -
+

403

Nemáte oprávnění pro přístup k této stránce.

- Zpět na Dashboard + + Zpět na Dashboard +
- ) + ); } diff --git a/src/admin/components/FormField.tsx b/src/admin/components/FormField.tsx index bbc9304..7cd6e53 100644 --- a/src/admin/components/FormField.tsx +++ b/src/admin/components/FormField.tsx @@ -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 (
- ) + ); } diff --git a/src/admin/components/Pagination.tsx b/src/admin/components/Pagination.tsx index 5daf8e1..b4a11a8 100644 --- a/src/admin/components/Pagination.tsx +++ b/src/admin/components/Pagination.tsx @@ -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 (
-
- {total} záznamů -
+
{total} záznamů
{getPages().map((p, i) => - typeof p === 'string' ? ( - ... + typeof p === "string" ? ( + + ... + ) : ( - ) + ), )}
{onPerPageChange && ( )}
- ) + ); } diff --git a/src/admin/components/ProjectFileManager.tsx b/src/admin/components/ProjectFileManager.tsx index 98560aa..6c7f251 100644 --- a/src/admin/components/ProjectFileManager.tsx +++ b/src/admin/components/ProjectFileManager.tsx @@ -1,83 +1,170 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { useAlert } from '../context/AlertContext' -import ConfirmModal from './ConfirmModal' -import apiFetch from '../utils/api' +import { useState, useEffect, useRef, useCallback } from "react"; +import { useAlert } from "../context/AlertContext"; +import ConfirmModal from "./ConfirmModal"; +import apiFetch from "../utils/api"; -const API_BASE = '/api/admin' +const API_BASE = "/api/admin"; interface ProjectFileManagerProps { - projectId: number - projectNumber: string | null - hasPermission: (perm: string) => boolean - hasNasFolder: boolean + projectId: number; + projectNumber: string | null; + hasPermission: (perm: string) => boolean; + hasNasFolder: boolean; } interface FileItem { - name: string - type: 'file' | 'folder' - size?: number - size_formatted?: string - modified?: string - extension?: string - item_count?: number - is_symlink?: boolean - link_target?: string + name: string; + type: "file" | "folder"; + size?: number; + size_formatted?: string; + modified?: string; + extension?: string; + item_count?: number; + is_symlink?: boolean; + link_target?: string; } function getFileIcon(type: string, extension?: string) { - if (type === 'folder') { + if (type === "folder") { return ( - - + + - ) + ); } - const ext = (extension || '').toLowerCase() + const ext = (extension || "").toLowerCase(); const iconMap: Record = { - pdf: { color: '#e74c3c', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - doc: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - docx: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - xls: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - xlsx: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - ppt: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - pptx: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - jpg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, - jpeg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, - png: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, - gif: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, - zip: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }, - rar: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }, - '7z': { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }, - dwg: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - dxf: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - step: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - stp: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, - } + pdf: { + color: "#e74c3c", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + doc: { + color: "#3498db", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + docx: { + color: "#3498db", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + xls: { + color: "#27ae60", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + xlsx: { + color: "#27ae60", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + ppt: { + color: "#e67e22", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + pptx: { + color: "#e67e22", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + jpg: { + color: "#3498db", + path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", + }, + jpeg: { + color: "#3498db", + path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", + }, + png: { + color: "#3498db", + path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", + }, + gif: { + color: "#3498db", + path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", + }, + zip: { + color: "#e67e22", + path: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z", + }, + rar: { + color: "#e67e22", + path: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z", + }, + "7z": { + color: "#e67e22", + path: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z", + }, + dwg: { + color: "#8e44ad", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + dxf: { + color: "#8e44ad", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + step: { + color: "#8e44ad", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + stp: { + color: "#8e44ad", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + }; - const cfg = iconMap[ext] || { color: 'var(--text-muted)', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } + const cfg = iconMap[ext] || { + color: "var(--text-muted)", + path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }; return ( - + - ) + ); } function SymlinkBadge({ target }: { target?: string }) { return ( - - + + - ) + ); } -function FileNameCell({ item, onFolderClick }: { item: FileItem; onFolderClick: (name: string) => void }) { - if (item.type === 'folder') { +function FileNameCell({ + item, + onFolderClick, +}: { + item: FileItem; + onFolderClick: (name: string) => void; +}) { + if (item.type === "folder") { return (
{fullPath && ( - {fullPath} + + {fullPath} + )} {canManage && ( @@ -453,11 +582,18 @@ export default function ProjectFileManager({ projectId, projectNumber, hasPermis type="button" className="admin-btn admin-btn-secondary admin-btn-sm" onClick={() => { - setNewFolderMode(!newFolderMode) - setNewFolderName('') + setNewFolderMode(!newFolderMode); + setNewFolderName(""); }} > - + @@ -477,7 +613,14 @@ export default function ProjectFileManager({ projectId, projectNumber, hasPermis ) : ( <> - + @@ -490,7 +633,7 @@ export default function ProjectFileManager({ projectId, projectNumber, hasPermis ref={fileInputRef} type="file" multiple - style={{ display: 'none' }} + style={{ display: "none" }} onChange={handleFileInputChange} />
@@ -508,13 +651,13 @@ export default function ProjectFileManager({ projectId, projectNumber, hasPermis placeholder="Název složky..." autoFocus onKeyDown={(e) => { - if (e.key === 'Enter') handleCreateFolder() - if (e.key === 'Escape') { - setNewFolderMode(false) - setNewFolderName('') + if (e.key === "Enter") handleCreateFolder(); + if (e.key === "Escape") { + setNewFolderMode(false); + setNewFolderName(""); } }} - style={{ fontSize: '12px', padding: '6px 10px' }} + style={{ fontSize: "12px", padding: "6px 10px" }} />