diff --git a/.gitignore b/.gitignore index 1f2ffbc..51bdbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ # Dependencies node_modules/ vendor/ +example_design/ +sql/ # Build dist/ diff --git a/CLAUDE.md b/CLAUDE.md index a777263..284cea7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,42 +3,120 @@ ## Projektová paměť Před začátkem práce si načti relevantní soubory z `memory/`: -- `MEMORY.md` — tech stack, architektura, klíčové poznámky -- `structure.md` — adresářová struktura, routes, API endpointy -- `patterns.md` — coding konvence (frontend i backend), CSS architektura -- `modules.md` — detaily modulů (attendance, offers, orders, invoices, projects, atd.) -- `design-system.md` — CSS třídy, komponenty, theme tokens -- `security.md` — bezpečnostní audit, auth, rate limiting +- `MEMORY.md` - tech stack, architektura, klíčové poznámky +- `structure.md` - adresářová struktura, routes, API endpointy +- `patterns.md` - coding konvence (frontend i backend), CSS architektura +- `modules.md` - detaily modulů (attendance, offers, orders, invoices, projects, atd.) +- `design-system.md` - CSS třídy, komponenty, theme tokens +- `security.md` - bezpečnostní audit, auth, rate limiting -Čti podle typu úkolu — nemusíš vše, ale vždy alespoň `MEMORY.md`. +Čti podle typu úkolu - nemusíš vše, ale vždy alespoň `MEMORY.md`. + +--- ## Prostředí -- **Dev server:** Apache WAMP → `C:\Apache24\htdocs` -- **PHP/MySQL:** v systémovém PATH (`php -v`, `mysql -u root`) -- **npm:** při instalaci balíčků vždy `--legacy-peer-deps` (eslint peer conflict) -- **Deploy (dev):** po každém `npm run build` smaž obsah `C:\Apache24\htdocs` a zkopíruj tam `dist/` -- **DB migrace:** SQL soubory ukládej do `sql/`, aplikuj přes `mysql` příkaz +- **Projekt:** `D:\Claude\BOHA Website\app` +- **Frontend:** Vite dev server (`npm run dev`) - port 3000 +- **Backend:** PHP built-in server (`php -S localhost:8000`) - port 8000 +- **Databáze:** MySQL 8.4 na localhost, DB název: `app` +- **Vite proxy:** `/api` -> `http://localhost:8000` +- **npm:** při instalaci balíčků vždy `--legacy-peer-deps` +- **Git remote:** https://git.boha-automation.cz/boha_admin/app.git + +--- + +## Spuštění dev serveru + +```bash +# Terminal 1 - Frontend +npm run dev + +# Terminal 2 - PHP API +php -S localhost:8000 +``` + +Aplikace běží na http://localhost:3000 + +--- + +## Autonomní režim + +Když běžíš s `--auto`, dodržuj tento postup: + +### Před jakoukoli prací: +1. Načti `memory/MEMORY.md` a relevantní memory soubory +2. Spusť diagnostiku: + - `npx eslint src/` - zkontroluj chyby + - `vendor/bin/phpcs` - zkontroluj PSR-12 + - `npm run build` - ověř že build projde + +### Během práce: +- Po každé větší změně spusť `npm run build` - ověř že nic nerozbíjíš +- Opravuj průběžně ESLint a PHPCS chyby +- Pokud si nejsi jistý architektonickým rozhodnutím, zapiš do reportu a pokračuj dál + +### Po dokončení práce: +1. Spusť finální kontroly: + - `npx eslint src/` - 0 errors + - `vendor/bin/phpcs` - 0 errors + - `npm run build` - musí projít +2. Commitni a pushni: + - `git add .` + - `git commit -m "typ: stručný popis změn"` + - `git push` +3. Napiš report do `reports/YYYY-MM-DD.md` + +### Commit message konvence: +- `feat:` - nová funkce +- `fix:` - oprava bugu +- `refactor:` - refaktoring bez změny funkčnosti +- `style:` - formátování, lint opravy +- `docs:` - dokumentace +- `perf:` - optimalizace výkonu + +### Report formát: + +``` +# Report YYYY-MM-DD + +## Zadání +- Co bylo požadováno + +## Provedené změny +- Seznam změn s krátkým popisem + +## Nalezené problémy +- Problémy co vyžadují rozhodnutí uživatele (pokud nějaké) + +## Stav +- ESLint: PASS/FAIL +- PHPCS: PASS/FAIL +- Build: PASS/FAIL +- Git: commitnuto a pushnuto / jen commitnuto / nepushnuto +``` + +--- ## Coding Standards ### PHP - PSR-12, `strict_types=1` vždy - Typované parametry a návratové typy povinné -- Repository pattern pro DB — žádné raw SQL v controllerech +- Repository pattern pro DB - žádné raw SQL v controllerech - Prepared statements s `?` placeholdery, žádná string interpolace v SQL - PHPStan level 6 musí projít - `vendor/bin/phpcs` (PSR-12) musí projít bez chyb - Docblock PŘED `declare(strict_types=1)`, ne za ním ### React -- Funkcionální komponenty, žádné class components (výjimka: `ErrorBoundary.jsx` - React nemá hook pro error boundaries) +- Funkcionální komponenty, žádné class components (výjimka: `ErrorBoundary.jsx`) - Props typované přes interface, ne inline type - Žádné `any` typy -- Custom hooks pro business logiku — ne inline useEffect spaghetti -- Žádné vnořené ternáry — použít early return, if/else, helper funkci nebo lookup objekt +- Custom hooks pro business logiku - ne inline useEffect spaghetti +- Žádné vnořené ternáry - použít early return, if/else, helper funkci nebo lookup objekt - Striktní porovnání (`===`/`!==`), nikdy `==`/`!=` -- API volání vždy přes `apiFetch()` z `../utils/api` — nikdy raw `fetch` (výjimka: `AuthContext.jsx` - auth vrstva používá raw `fetch`, protože `apiFetch` na ní závisí) +- API volání vždy přes `apiFetch()` z `../utils/api` (výjimka: `AuthContext.jsx`) ### Data konvence - Všechny response klíče **snake_case** (DB sloupce, PHP proměnné, JSON klíče) @@ -51,38 +129,47 @@ Před začátkem práce si načti relevantní soubory z `memory/`: - API error: `{ "success": false, "error": "message" }` - PHP: `jsonResponse()`, `errorResponse()`, `successResponse()` helpery - Frontend: `alert.success()` / `alert.error()` přes `useAlert()` -- Formulářová validace vždy frontend inline — viz sekce níže +- Formulářová validace vždy frontend inline ### Inline validace formulářů - `has-error` class na `.admin-form-group` + `.admin-form-error` pod inputem -- Error message vždy **za inputem** (ne před ním — posouvá layout) -- `const [errors, setErrors] = useState({})` — validace v handleSubmit/handleSave, clear na onChange +- Error message vždy **za inputem** (ne před ním) +- `const [errors, setErrors] = useState({})` - validace v handleSubmit/handleSave, clear na onChange - Povinná pole: `required` class na labelu (červená hvězdička) - Detaily viz `design-system.md` +--- + ## Komentáře v kódu - Piš jako programátor, stručně a k věci -- Nekomentuj zřejmé věci (`setUser(null)`, `getAccessToken()`) -- Nepoužívej em dash (—), používej normální pomlčku (-) +- Nekomentuj zřejmé věci +- Nepoužívej em dash, používej normální pomlčku (-) - Čeština i angličtina OK, preferuj češtinu u nových komentářů -- Komentář jen když přidává info, které nejde vyčíst z kódu (důvod, workaround, gotcha) +- Komentář jen když přidává info, které nejde vyčíst z kódu - Docblocky jen u složitějších funkcí/API endpointů - Žádné komentáře typu `// Set headers`, `// Get input` +--- + ## Kontroly před buildem Vždy spusť před `npm run build`: -1. `npx eslint src/` — 0 errors, 0 warnings -2. `vendor/bin/phpcs` — 0 errors (warnings tolerovány, ale nepotlačuj je v nastavení) +1. `npx eslint src/` - 0 errors, 0 warnings +2. `vendor/bin/phpcs` - 0 errors 3. Build musí projít bez chyb -## Pravidla +--- -- Nikdy nečti `.env` soubory -- Chrome používej pouze na výslovnou žádost uživatele +## Zakázané operace + +- NIKDY nečti `.env` soubory +- NIKDY nepoužívej SSH, SCP, rsync na jakýkoli vzdálený server +- NIKDY neměň databázi na vzdáleném serveru +- NIKDY nespouštěj Chrome bez výslovné žádosti uživatele - Žádné TODO/FIXME v kódu -- Žádné `console.log` v kódu — ani po debugování (console.error jen s `import.meta.env.DEV` guardem) -- Funkce max 50 řádků — u React komponent se počítá logika, ne JSX template -- Permissions: frontend `hasPermission()` + `` guard, backend `requirePermission()` +- Žádné `console.log` v kódu (console.error jen s `import.meta.env.DEV` guardem) +- Funkce max 50 řádků - u React komponent se počítá logika, ne JSX template +- Žádné `any` typy v TypeScript/React +- Žádná string interpolace v SQL dotazech diff --git a/api/admin/attendance.php b/api/admin/attendance.php index 45e5818..e4ce058 100644 --- a/api/admin/attendance.php +++ b/api/admin/attendance.php @@ -35,6 +35,7 @@ require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/CzechHolidays.php'; require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php'; require_once dirname(__DIR__) . '/includes/AttendanceAdmin.php'; +require_once __DIR__ . '/handlers/attendance-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -149,589 +150,3 @@ try { // ============================================================================ // User-facing handlers // ============================================================================ - -function handleGetCurrent(PDO $pdo, int $userId): void -{ - $today = date('Y-m-d'); - - $stmt = $pdo->prepare(" - SELECT * FROM attendance - WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') - ORDER BY created_at DESC LIMIT 1 - "); - $stmt->execute([$userId]); - $ongoingShift = $stmt->fetch(); - - $projectLogs = []; - $activeProjectId = null; - if ($ongoingShift) { - $stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'); - $stmt->execute([$ongoingShift['id']]); - $projectLogs = $stmt->fetchAll(); - foreach ($projectLogs as $log) { - if ($log['ended_at'] === null) { - $activeProjectId = (int)$log['project_id']; - break; - } - } - } - - $stmt = $pdo->prepare(" - SELECT * FROM attendance - WHERE user_id = ? AND shift_date = ? - AND departure_time IS NOT NULL - AND (leave_type IS NULL OR leave_type = 'work') - ORDER BY arrival_time DESC - "); - $stmt->execute([$userId, $today]); - $todayShifts = $stmt->fetchAll(); - - $completedShiftIds = array_column($todayShifts, 'id'); - $completedProjectLogs = []; - if (!empty($completedShiftIds)) { - $placeholders = implode(',', array_fill(0, count($completedShiftIds), '?')); - $stmt = $pdo->prepare( - "SELECT * FROM attendance_project_logs - WHERE attendance_id IN ($placeholders) - ORDER BY started_at ASC" - ); - $stmt->execute($completedShiftIds); - $allLogs = $stmt->fetchAll(); - foreach ($allLogs as $log) { - $completedProjectLogs[$log['attendance_id']][] = $log; - } - } - - $leaveBalance = getLeaveBalance($pdo, $userId); - - $currentYear = (int)date('Y'); - $currentMonth = (int)date('m'); - $fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth); - $businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth); - - $startDate = date('Y-m-01'); - $endDate = date('Y-m-t'); - - $stmt = $pdo->prepare(' - SELECT * FROM attendance - WHERE user_id = ? AND shift_date BETWEEN ? AND ? - '); - $stmt->execute([$userId, $startDate, $endDate]); - $monthRecords = $stmt->fetchAll(); - - $workedMinutes = 0; - $leaveHoursMonth = 0; - $vacationHours = 0; - $sickHours = 0; - $holidayHours = 0; - $unpaidHours = 0; - foreach ($monthRecords as $rec) { - $lt = $rec['leave_type'] ?? 'work'; - $lh = (float)($rec['leave_hours'] ?? 0); - if ($lt === 'work') { - if ($rec['departure_time']) { - $workedMinutes += calculateWorkMinutes($rec); - } - } elseif ($lt === 'vacation') { - $vacationHours += $lh; - $leaveHoursMonth += $lh; - } elseif ($lt === 'sick') { - $sickHours += $lh; - $leaveHoursMonth += $lh; - } elseif ($lt === 'holiday') { - $holidayHours += $lh; - } elseif ($lt === 'unpaid') { - $unpaidHours += $lh; - } - } - - $workedHours = round($workedMinutes / 60, 1); - $covered = $workedHours + $leaveHoursMonth; - $remaining = max(0, $fund - $covered); - $overtime = max(0, round($covered - $fund, 1)); - - $monthlyFund = [ - 'fund' => $fund, - 'business_days' => $businessDays, - 'worked' => $workedHours, - 'leave_hours' => $leaveHoursMonth, - 'vacation_hours' => $vacationHours, - 'sick_hours' => $sickHours, - 'holiday_hours' => $holidayHours, - 'unpaid_hours' => $unpaidHours, - 'covered' => $covered, - 'remaining' => $remaining, - 'overtime' => $overtime, - 'month_name' => getCzechMonthName($currentMonth) . ' ' . $currentYear, - ]; - - // Enrich project logs with names - $allLogProjectIds = []; - foreach ($projectLogs as $l) { - $allLogProjectIds[$l['project_id']] = $l['project_id']; - } - foreach ($completedProjectLogs as $logs) { - foreach ($logs as $l) { - $allLogProjectIds[$l['project_id']] = $l['project_id']; - } - } - $projNameMap = fetchProjectNames($allLogProjectIds); - - foreach ($projectLogs as &$l) { - $l['project_name'] = $projNameMap[$l['project_id']] ?? null; - } - unset($l); - foreach ($completedProjectLogs as &$logs) { - foreach ($logs as &$l) { - $l['project_name'] = $projNameMap[$l['project_id']] ?? null; - } - unset($l); - } - unset($logs); - - foreach ($todayShifts as &$shift) { - $shift['project_logs'] = $completedProjectLogs[$shift['id']] ?? []; - } - unset($shift); - - successResponse([ - 'ongoing_shift' => $ongoingShift, - 'today_shifts' => $todayShifts, - 'date' => $today, - 'leave_balance' => $leaveBalance, - 'monthly_fund' => $monthlyFund, - 'project_logs' => $projectLogs, - 'active_project_id' => $activeProjectId, - ]); -} - -function handleGetHistory(PDO $pdo, int $userId): void -{ - $month = validateMonth(); - $year = (int)substr($month, 0, 4); - $monthNum = (int)substr($month, 5, 2); - - $startDate = "{$month}-01"; - $endDate = date('Y-m-t', strtotime($startDate)); - - $stmt = $pdo->prepare(' - SELECT * FROM attendance - WHERE user_id = ? AND shift_date BETWEEN ? AND ? - ORDER BY shift_date DESC - '); - $stmt->execute([$userId, $startDate, $endDate]); - $records = $stmt->fetchAll(); - - enrichRecordsWithProjectLogs($pdo, $records); - - $totalMinutes = 0; - $vacationHours = 0; - $sickHours = 0; - $holidayHours = 0; - $unpaidHours = 0; - - foreach ($records as $record) { - $leaveType = $record['leave_type'] ?? 'work'; - $leaveHours = (float)($record['leave_hours'] ?? 0); - - if ($leaveType === 'vacation') { - $vacationHours += $leaveHours; - } elseif ($leaveType === 'sick') { - $sickHours += $leaveHours; - } elseif ($leaveType === 'holiday') { - $holidayHours += $leaveHours; - } elseif ($leaveType === 'unpaid') { - $unpaidHours += $leaveHours; - } else { - $totalMinutes += calculateWorkMinutes($record); - } - } - - $fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum); - $businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum); - $workedHours = round($totalMinutes / 60, 1); - $leaveHoursCovered = $vacationHours + $sickHours; - $covered = $workedHours + $leaveHoursCovered; - $remaining = max(0, round($fund - $covered, 1)); - $overtime = max(0, round($covered - $fund, 1)); - - $leaveBalance = getLeaveBalance($pdo, $userId, $year); - - successResponse([ - 'records' => $records, - 'month' => $month, - 'year' => $year, - 'month_name' => getCzechMonthName($monthNum) . ' ' . $year, - 'total_minutes' => $totalMinutes, - 'vacation_hours' => $vacationHours, - 'sick_hours' => $sickHours, - 'holiday_hours' => $holidayHours, - 'unpaid_hours' => $unpaidHours, - 'leave_balance' => $leaveBalance, - 'monthly_fund' => [ - 'fund' => $fund, - 'business_days' => $businessDays, - 'worked' => $workedHours, - 'leave_hours' => $leaveHoursCovered, - 'covered' => $covered, - 'remaining' => $remaining, - 'overtime' => $overtime, - ], - ]); -} - -function handlePunch(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - $action = $input['punch_action'] ?? ''; - $today = date('Y-m-d'); - $rawNow = date('Y-m-d H:i:s'); - - $lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null; - $lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null; - $accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null; - $address = !empty($input['address']) ? $input['address'] : null; - - $stmt = $pdo->prepare(" - SELECT * FROM attendance - WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') - ORDER BY created_at DESC LIMIT 1 - "); - $stmt->execute([$userId]); - $ongoingShift = $stmt->fetch(); - - if ($action === 'arrival' && !$ongoingShift) { - $now = roundUpTo15Minutes($rawNow); - $stmt = $pdo->prepare(' - INSERT INTO attendance - (user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address) - VALUES (?, ?, ?, ?, ?, ?, ?) - '); - $stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]); - - AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [ - 'arrival_time' => $now, - 'location' => $address, - ], 'Příchod zaznamenán'); - - successResponse(null, 'Příchod zaznamenán'); - } elseif ($ongoingShift) { - switch ($action) { - case 'break_start': - if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) { - $breakStart = roundToNearest10Minutes($rawNow); - $breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60)); - - $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); - $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); - - successResponse(null, 'Pauza zaznamenána'); - } else { - errorResponse('Nelze zadat pauzu'); - } - break; - - case 'departure': - if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) { - $now = roundDownTo15Minutes($rawNow); - - // Auto-add break if shift is longer than 6h and no break - if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) { - $arrivalTime = strtotime($ongoingShift['arrival_time']); - $departureTime = strtotime($now); - $hoursWorked = ($departureTime - $arrivalTime) / 3600; - - if ($hoursWorked > 12) { - $midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2); - $breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60))); - $breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60))); - - $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); - $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); - } elseif ($hoursWorked > 6) { - $midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2); - $breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60))); - $breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60))); - - $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); - $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); - } - } - - $stmt = $pdo->prepare(' - UPDATE attendance - SET departure_time = ?, departure_lat = ?, departure_lng = ?, - departure_accuracy = ?, departure_address = ? - WHERE id = ? - '); - $stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]); - - // Close any open project log - $stmt = $pdo->prepare(' - UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL - '); - $stmt->execute([$now, $ongoingShift['id']]); - - AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [ - 'departure_time' => $now, - 'location' => $address, - ], 'Odchod zaznamenán'); - - successResponse(null, 'Odchod zaznamenán'); - } else { - errorResponse('Nelze zadat odchod'); - } - break; - - default: - errorResponse('Neplatná akce'); - } - } else { - errorResponse('Neplatná akce - nemáte aktivní směnu'); - } -} - -function handleUpdateAddress(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - $address = trim($input['address'] ?? ''); - $punchAction = $input['punch_action'] ?? ''; - - if (!$address) { - successResponse(null); - return; - } - - if ($punchAction === 'arrival') { - $stmt = $pdo->prepare(" - UPDATE attendance SET arrival_address = ? - WHERE id = ( - SELECT id FROM ( - SELECT id FROM attendance - WHERE user_id = ? AND (arrival_address IS NULL OR arrival_address = '') - ORDER BY created_at DESC LIMIT 1 - ) t - ) - "); - } else { - $stmt = $pdo->prepare(" - UPDATE attendance SET departure_address = ? - WHERE id = ( - SELECT id FROM ( - SELECT id FROM attendance - WHERE user_id = ? AND (departure_address IS NULL OR departure_address = '') - AND departure_time IS NOT NULL - ORDER BY created_at DESC LIMIT 1 - ) t - ) - "); - } - $stmt->execute([$address, $userId]); - - successResponse(null); -} - -function handleAddLeave(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - - $leaveType = $input['leave_type'] ?? ''; - $leaveDate = $input['leave_date'] ?? ''; - $leaveHours = (float)($input['leave_hours'] ?? 8); - $notes = trim($input['notes'] ?? ''); - - if (!$leaveType || !$leaveDate || $leaveHours <= 0) { - errorResponse('Vyplňte všechna povinná pole'); - } - - if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) { - errorResponse('Neplatný typ nepřítomnosti'); - } - - if ($leaveType === 'vacation') { - $year = (int)date('Y', strtotime($leaveDate)); - $balance = getLeaveBalance($pdo, $userId, $year); - - if ($balance['vacation_remaining'] < $leaveHours) { - errorResponse( - "Nemáte dostatek hodin dovolené. Zbývá vám " - . "{$balance['vacation_remaining']} hodin, požadujete {$leaveHours} hodin." - ); - } - } - - $stmt = $pdo->prepare(' - INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes) - VALUES (?, ?, ?, ?, ?) - '); - $stmt->execute([$userId, $leaveDate, $leaveType, $leaveHours, $notes ?: null]); - - updateLeaveBalance($pdo, $userId, $leaveDate, $leaveType, $leaveHours); - - AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [ - 'leave_type' => $leaveType, - 'leave_hours' => $leaveHours, - ], "Zaznamenána nepřítomnost: $leaveType"); - - successResponse(null, 'Nepřítomnost byla zaznamenána'); -} - -function handleSaveNotes(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - $notes = trim($input['notes'] ?? ''); - - $stmt = $pdo->prepare(' - SELECT id FROM attendance - WHERE user_id = ? AND departure_time IS NULL - ORDER BY created_at DESC LIMIT 1 - '); - $stmt->execute([$userId]); - $currentShift = $stmt->fetch(); - - if (!$currentShift) { - errorResponse('Nemáte aktivní směnu'); - } - - $stmt = $pdo->prepare('UPDATE attendance SET notes = ? WHERE id = ?'); - $stmt->execute([$notes, $currentShift['id']]); - - successResponse(null, 'Poznámka byla uložena'); -} - -function handleGetProjects(): void -{ - try { - $pdo = db(); - $stmt = $pdo->query( - "SELECT id, project_number, name FROM projects - WHERE status = 'aktivni' ORDER BY project_number ASC" - ); - $projects = $stmt->fetchAll(); - successResponse(['projects' => $projects]); - } catch (\Exception $e) { - error_log('Failed to fetch projects: ' . $e->getMessage()); - successResponse(['projects' => []]); - } -} - -function handleSwitchProject(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - /** @var mixed $rawProjectId */ - $rawProjectId = $input['project_id'] ?? null; - $projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null - ? (int)$rawProjectId - : null; - - $stmt = $pdo->prepare(" - SELECT id FROM attendance - WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') - ORDER BY created_at DESC LIMIT 1 - "); - $stmt->execute([$userId]); - $currentShift = $stmt->fetch(); - - if (!$currentShift) { - errorResponse('Nemáte aktivní směnu'); - } - - $attendanceId = $currentShift['id']; - $now = date('Y-m-d H:i:s'); - - $stmt = $pdo->prepare( - 'UPDATE attendance_project_logs SET ended_at = ? - WHERE attendance_id = ? AND ended_at IS NULL' - ); - $stmt->execute([$now, $attendanceId]); - - if ($projectId) { - $stmt = $pdo->prepare( - 'INSERT INTO attendance_project_logs - (attendance_id, project_id, started_at) VALUES (?, ?, ?)' - ); - $stmt->execute([$attendanceId, $projectId, $now]); - } - - $stmt = $pdo->prepare('UPDATE attendance SET project_id = ? WHERE id = ?'); - $stmt->execute([$projectId, $attendanceId]); - - successResponse(null, $projectId ? 'Projekt přepnut' : 'Projekt zastaven'); -} - -/** @param array $authData */ -function handleGetProjectLogs(PDO $pdo, int $currentUserId, array $authData): void -{ - $attendanceId = (int)($_GET['attendance_id'] ?? 0); - if (!$attendanceId) { - errorResponse('attendance_id je povinné'); - } - - // Ověření vlastnictví záznamu nebo admin oprávnění - if (!hasPermission($authData, 'attendance.admin')) { - $ownerStmt = $pdo->prepare('SELECT user_id FROM attendance WHERE id = ?'); - $ownerStmt->execute([$attendanceId]); - $owner = $ownerStmt->fetch(); - if (!$owner || (int)$owner['user_id'] !== $currentUserId) { - errorResponse('Nemáte oprávnění zobrazit tyto záznamy', 403); - } - } - - $stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'); - $stmt->execute([$attendanceId]); - $logs = $stmt->fetchAll(); - - $projectIds = []; - foreach ($logs as $l) { - $projectIds[$l['project_id']] = $l['project_id']; - } - $projNameMap = fetchProjectNames($projectIds); - foreach ($logs as &$l) { - $l['project_name'] = $projNameMap[$l['project_id']] ?? null; - } - unset($l); - - successResponse(['logs' => $logs]); -} - -function handleSaveProjectLogs(PDO $pdo): void -{ - $input = getJsonInput(); - $attendanceId = (int)($input['attendance_id'] ?? 0); - $logs = $input['project_logs'] ?? []; - - if (!$attendanceId) { - errorResponse('attendance_id je povinné'); - } - - $stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?'); - $stmt->execute([$attendanceId]); - $record = $stmt->fetch(); - if (!$record) { - errorResponse('Záznam nebyl nalezen', 404); - } - - $stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?'); - $stmt->execute([$attendanceId]); - - if (!empty($logs)) { - $stmt = $pdo->prepare( - 'INSERT INTO attendance_project_logs - (attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)' - ); - foreach ($logs as $log) { - $projectId = (int)($log['project_id'] ?? 0); - if (!$projectId) { - continue; - } - $h = (int)($log['hours'] ?? 0); - $m = (int)($log['minutes'] ?? 0); - if ($h === 0 && $m === 0) { - continue; - } - $stmt->execute([$attendanceId, $projectId, $h, $m]); - } - } - - successResponse(null, 'Projektové záznamy byly uloženy'); -} diff --git a/api/admin/bank-accounts.php b/api/admin/bank-accounts.php index 9401765..f8a9a5b 100644 --- a/api/admin/bank-accounts.php +++ b/api/admin/bank-accounts.php @@ -14,6 +14,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/bank-accounts-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -67,166 +68,3 @@ try { errorResponse('Chyba databáze', 500); } } - -function handleGetBankAccountList(PDO $pdo): void -{ - $stmt = $pdo->query('SELECT * FROM bank_accounts ORDER BY position, id'); - successResponse($stmt->fetchAll()); -} - -function handleCreateBankAccount(PDO $pdo): void -{ - $input = getJsonInput(); - - $accountName = trim($input['account_name'] ?? ''); - $bankName = trim($input['bank_name'] ?? ''); - $accountNumber = trim($input['account_number'] ?? ''); - $iban = trim($input['iban'] ?? ''); - $bic = trim($input['bic'] ?? ''); - $currency = trim($input['currency'] ?? 'CZK'); - $isDefault = !empty($input['is_default']) ? 1 : 0; - - if (!$accountName) { - errorResponse('Název účtu je povinný'); - } - if (mb_strlen($accountName) > 100) { - errorResponse('Název účtu je příliš dlouhý (max 100 znaků)'); - } - if (mb_strlen($bankName) > 255) { - errorResponse('Název banky je příliš dlouhý (max 255 znaků)'); - } - if (mb_strlen($accountNumber) > 50) { - errorResponse('Číslo účtu je příliš dlouhé (max 50 znaků)'); - } - if (mb_strlen($iban) > 50) { - errorResponse('IBAN je příliš dlouhý (max 50 znaků)'); - } - if (mb_strlen($bic) > 20) { - errorResponse('BIC/SWIFT je příliš dlouhý (max 20 znaků)'); - } - if (!in_array($currency, ['CZK', 'EUR', 'USD', 'GBP'])) { - errorResponse('Neplatná měna'); - } - - // Zjistit dalsi pozici - $maxPos = (int) $pdo->query('SELECT COALESCE(MAX(position), 0) FROM bank_accounts')->fetchColumn(); - - $pdo->beginTransaction(); - try { - // Pokud je default, zrusit ostatnim - if ($isDefault) { - $pdo->exec('UPDATE bank_accounts SET is_default = 0'); - } - - $stmt = $pdo->prepare(' - INSERT INTO bank_accounts - (account_name, bank_name, account_number, iban, bic, currency, is_default, position) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - '); - $stmt->execute([$accountName, $bankName, $accountNumber, $iban, $bic, $currency, $isDefault, $maxPos + 1]); - $newId = (int) $pdo->lastInsertId(); - - $pdo->commit(); - - AuditLog::logCreate( - 'bank_account', - $newId, - ['account_name' => $accountName], - "Vytvořen bankovní účet '$accountName'" - ); - - successResponse(['id' => $newId], 'Bankovní účet byl vytvořen'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleUpdateBankAccount(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?'); - $stmt->execute([$id]); - $account = $stmt->fetch(); - - if (!$account) { - errorResponse('Bankovní účet nebyl nalezen', 404); - } - - $input = getJsonInput(); - - // Delkove limity a validace - $maxLengths = ['account_name' => 100, 'bank_name' => 255, 'account_number' => 50, 'iban' => 50, 'bic' => 20]; - foreach ($maxLengths as $f => $max) { - if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) { - errorResponse("Pole $f je příliš dlouhé (max $max znaků)"); - } - } - if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { - errorResponse('Neplatná měna'); - } - - $fields = ['account_name', 'bank_name', 'account_number', 'iban', 'bic', 'currency']; - $updates = []; - $params = []; - - foreach ($fields as $field) { - if (array_key_exists($field, $input)) { - $updates[] = "$field = ?"; - $params[] = trim((string) $input[$field]); - } - } - - $pdo->beginTransaction(); - try { - if (array_key_exists('is_default', $input)) { - $isDefault = !empty($input['is_default']) ? 1 : 0; - if ($isDefault) { - $pdo->exec('UPDATE bank_accounts SET is_default = 0'); - } - $updates[] = 'is_default = ?'; - $params[] = $isDefault; - } - - if (empty($updates)) { - errorResponse('Žádná data k aktualizaci'); - } - - $updates[] = 'modified_at = NOW()'; - $params[] = $id; - - $sql = 'UPDATE bank_accounts SET ' . implode(', ', $updates) . ' WHERE id = ?'; - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - - $pdo->commit(); - - AuditLog::logUpdate('bank_account', $id, [], $input, "Aktualizován bankovní účet #{$id}"); - - successResponse(null, 'Bankovní účet byl aktualizován'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleDeleteBankAccount(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?'); - $stmt->execute([$id]); - $account = $stmt->fetch(); - - if (!$account) { - errorResponse('Bankovní účet nebyl nalezen', 404); - } - - $pdo->prepare('DELETE FROM bank_accounts WHERE id = ?')->execute([$id]); - - AuditLog::logDelete( - 'bank_account', - $id, - ['account_name' => $account['account_name']], - "Smazán bankovní účet '{$account['account_name']}'" - ); - - successResponse(null, 'Bankovní účet byl smazán'); -} diff --git a/api/admin/company-settings.php b/api/admin/company-settings.php index 53f8904..b14c724 100644 --- a/api/admin/company-settings.php +++ b/api/admin/company-settings.php @@ -14,6 +14,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/company-settings-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -68,247 +69,3 @@ try { errorResponse('Chyba databáze', 500); } } - -/** - * @param bool $includeLogo false = bez logo_data BLOBu - * @return array - */ -function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array -{ - if ($includeLogo) { - $stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1'); - } else { - $stmt = $pdo->query(' - SELECT id, company_name, company_id, vat_id, street, city, postal_code, country, - quotation_prefix, default_currency, default_vat_rate, - custom_fields, uuid, modified_at, sync_version, - order_type_code, invoice_type_code, is_deleted, - CASE WHEN logo_data IS NOT NULL AND LENGTH(logo_data) > 0 THEN 1 ELSE 0 END as has_logo - FROM company_settings LIMIT 1 - '); - } - $settings = $stmt->fetch(); - - if (!$settings) { - $uuid = sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0x0fff) | 0x4000, - random_int(0, 0x3fff) | 0x8000, - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff) - ); - $pdo->prepare( - "INSERT INTO company_settings - (id, company_name, quotation_prefix, default_currency, - default_vat_rate, uuid, modified_at, sync_version) - VALUES (1, '', 'N', 'EUR', 21.0, ?, NOW(), 1)" - )->execute([$uuid]); - return getOrCreateSettings($pdo, $includeLogo); - } - - return $settings; -} - -function handleGetOffersSettings(PDO $pdo): void -{ - $settings = getOrCreateSettings($pdo, false); - /** @var array|null $cfRaw */ - $cfRaw = !empty($settings['custom_fields']) - ? json_decode($settings['custom_fields'], true) - : null; - if (is_array($cfRaw) && !isset($cfRaw['fields'])) { - $settings['custom_fields'] = $cfRaw; - $settings['supplier_field_order'] = null; - } elseif (is_array($cfRaw) && isset($cfRaw['fields'])) { - $settings['custom_fields'] = $cfRaw['fields']; - $settings['supplier_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null; - } else { - $settings['custom_fields'] = []; - $settings['supplier_field_order'] = null; - } - - $settings['has_logo'] = (bool)($settings['has_logo'] ?? false); - - successResponse($settings); -} - -function handleUpdateOffersSettings(PDO $pdo): void -{ - $input = getJsonInput(); - $settings = getOrCreateSettings($pdo); - - // Delkove limity - $maxLengths = [ - 'company_name' => 255, 'street' => 255, 'city' => 255, - 'postal_code' => 20, 'country' => 100, - 'company_id' => 50, 'vat_id' => 50, - 'default_currency' => 5, - ]; - foreach ($maxLengths as $f => $max) { - if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) { - errorResponse("Pole $f je příliš dlouhé (max $max znaků)"); - } - } - // Validace meny - if (isset($input['default_currency']) && !in_array($input['default_currency'], ['EUR', 'USD', 'CZK', 'GBP'])) { - errorResponse('Neplatná měna'); - } - - $fields = [ - 'company_name', 'street', 'city', 'postal_code', 'country', - 'company_id', 'vat_id', - 'quotation_prefix', 'default_currency', - 'order_type_code', 'invoice_type_code', - ]; - - $setClauses = []; - $params = []; - - foreach ($fields as $field) { - if (array_key_exists($field, $input)) { - $setClauses[] = "$field = ?"; - $params[] = $input[$field]; - } - } - - // custom_fields + SupplierFieldOrder - ulozeny dohromady jako JSON - if (array_key_exists('custom_fields', $input) || array_key_exists('supplier_field_order', $input)) { - /** @var array|null $currentRaw */ - $currentRaw = !empty($settings['custom_fields']) - ? json_decode($settings['custom_fields'], true) - : null; - if (is_array($currentRaw) && !isset($currentRaw['fields'])) { - /** @var array $stored */ - $stored = ['fields' => $currentRaw, 'field_order' => null]; - } elseif (is_array($currentRaw) && isset($currentRaw['fields'])) { - /** @var array $stored */ - $stored = $currentRaw; - } else { - $stored = ['fields' => [], 'field_order' => null]; - } - - if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) { - $stored['fields'] = $input['custom_fields']; - } - if (array_key_exists('supplier_field_order', $input)) { - $stored['field_order'] = is_array($input['supplier_field_order']) ? $input['supplier_field_order'] : null; - } - - // Odstranit stary klic - unset($stored['fieldOrder']); - - $setClauses[] = 'custom_fields = ?'; - $params[] = json_encode($stored, JSON_UNESCAPED_UNICODE); - } - - // Validace prefixu - if (isset($input['quotation_prefix']) && !preg_match('/^[A-Za-z0-9]{0,10}$/', $input['quotation_prefix'])) { - errorResponse('Prefix nabídky může obsahovat pouze alfanumerické znaky (max 10)'); - } - if (isset($input['order_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['order_type_code'])) { - errorResponse('Typový kód objednávek může obsahovat pouze čísla (max 10)'); - } - if (isset($input['invoice_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['invoice_type_code'])) { - errorResponse('Typový kód faktur může obsahovat pouze čísla (max 10)'); - } - - $numericFields = ['default_vat_rate']; - foreach ($numericFields as $field) { - if (array_key_exists($field, $input)) { - $val = is_numeric($input[$field]) ? floatval($input[$field]) : 0; - if ($val < 0 || $val > 100) { - errorResponse('Sazba DPH musí být mezi 0 a 100'); - } - $setClauses[] = "$field = ?"; - $params[] = $val; - } - } - - if (empty($setClauses)) { - errorResponse('Žádná data k aktualizaci'); - } - - $setClauses[] = 'modified_at = NOW()'; - $setClauses[] = 'sync_version = sync_version + 1'; - - $sql = 'UPDATE company_settings SET ' . implode(', ', $setClauses) . ' WHERE id = ?'; - $params[] = $settings['id']; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - - - AuditLog::logUpdate('company_settings', (int)$settings['id'], [], $input, 'Aktualizováno nastavení firmy'); - - successResponse(null, 'Nastavení bylo uloženo'); -} - -function handleUploadLogo(PDO $pdo): void -{ - if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) { - errorResponse('Nebyl nahrán žádný soubor'); - } - - $file = $_FILES['logo']; - $allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mimeType = finfo_file($finfo, $file['tmp_name']); - finfo_close($finfo); - - if (!in_array($mimeType, $allowedTypes)) { - errorResponse('Nepodporovaný formát obrázku. Povolené: PNG, JPEG, GIF, WebP'); - } - - if ($file['size'] > 5 * 1024 * 1024) { - errorResponse('Soubor je příliš velký (max 5 MB)'); - } - - $logoData = file_get_contents($file['tmp_name']); - - $settings = getOrCreateSettings($pdo); - $stmt = $pdo->prepare( - 'UPDATE company_settings SET logo_data = ?, modified_at = NOW(), sync_version = sync_version + 1 WHERE id = ?' - ); - $stmt->execute([$logoData, $settings['id']]); - - - AuditLog::logUpdate( - 'company_settings', - (int)$settings['id'], - [], - ['logo' => 'uploaded'], - 'Aktualizováno logo firmy' - ); - - successResponse(null, 'Logo bylo nahráno'); -} - -function handleGetLogo(PDO $pdo): void -{ - $stmt = $pdo->query('SELECT logo_data FROM company_settings LIMIT 1'); - $row = $stmt->fetch(); - - if (!$row || empty($row['logo_data'])) { - http_response_code(404); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['success' => false, 'error' => 'Logo nenalezeno']); - exit(); - } - - $logoData = $row['logo_data']; - - // Detect image type from binary data - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mimeType = finfo_buffer($finfo, $logoData); - finfo_close($finfo); - - header('Content-Type: ' . $mimeType); - header('Content-Length: ' . strlen($logoData)); - header('Cache-Control: public, max-age=3600'); - echo $logoData; - exit(); -} diff --git a/api/admin/customers.php b/api/admin/customers.php index 41589e1..01c9deb 100644 --- a/api/admin/customers.php +++ b/api/admin/customers.php @@ -16,6 +16,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/customers-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -76,275 +77,3 @@ try { errorResponse('Chyba databáze', 500); } } - -/** @param array $customer */ -function parseCustomerCustomFields(array &$customer): void -{ - /** @var array|null $cfRaw */ - $cfRaw = !empty($customer['custom_fields']) - ? json_decode($customer['custom_fields'], true) - : null; - if (is_array($cfRaw) && !isset($cfRaw['fields'])) { - $customer['custom_fields'] = $cfRaw; - $customer['customer_field_order'] = null; - } elseif (is_array($cfRaw) && isset($cfRaw['fields'])) { - $customer['custom_fields'] = $cfRaw['fields']; - $customer['customer_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null; - } else { - $customer['custom_fields'] = []; - $customer['customer_field_order'] = null; - } -} - -/** @param array $input */ -function encodeCustomerCustomFields(array $input, ?string $existingJson): ?string -{ - if (!array_key_exists('custom_fields', $input) && !array_key_exists('customer_field_order', $input)) { - return $existingJson; - } - /** @var array|null $currentRaw */ - $currentRaw = !empty($existingJson) ? json_decode($existingJson, true) : null; - if (is_array($currentRaw) && !isset($currentRaw['fields'])) { - /** @var array $stored */ - $stored = ['fields' => $currentRaw, 'field_order' => null]; - } elseif (is_array($currentRaw) && isset($currentRaw['fields'])) { - /** @var array $stored */ - $stored = $currentRaw; - } else { - $stored = ['fields' => [], 'field_order' => null]; - } - - if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) { - $stored['fields'] = $input['custom_fields']; - } - if (array_key_exists('customer_field_order', $input)) { - $stored['field_order'] = is_array($input['customer_field_order']) ? $input['customer_field_order'] : null; - } - - unset($stored['fieldOrder']); - - return json_encode($stored, JSON_UNESCAPED_UNICODE); -} - -function handleGetAll(PDO $pdo): void -{ - $stmt = $pdo->query(' - SELECT c.*, COUNT(q.id) as quotation_count - FROM customers c - LEFT JOIN quotations q ON q.customer_id = c.id - GROUP BY c.id - ORDER BY c.name ASC - '); - $customers = $stmt->fetchAll(); - - foreach ($customers as &$c) { - parseCustomerCustomFields($c); - } - unset($c); - - successResponse(['customers' => $customers]); -} - -function handleGetOne(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); - $stmt->execute([$id]); - $customer = $stmt->fetch(); - - if (!$customer) { - errorResponse('Zákazník nebyl nalezen', 404); - } - - parseCustomerCustomFields($customer); - successResponse($customer); -} - -function handleSearch(PDO $pdo): void -{ - $q = trim($_GET['q'] ?? ''); - if (strlen($q) < 1 || mb_strlen($q) > 100) { - successResponse(['customers' => []]); - return; - } - - $stmt = $pdo->prepare(' - SELECT * FROM customers - WHERE name LIKE ? OR company_id LIKE ? OR city LIKE ? - ORDER BY name ASC - LIMIT 20 - '); - $search = "%{$q}%"; - $stmt->execute([$search, $search, $search]); - - $results = $stmt->fetchAll(); - foreach ($results as &$c) { - parseCustomerCustomFields($c); - } - unset($c); - successResponse(['customers' => $results]); -} - -function handleCreateCustomer(PDO $pdo): void -{ - $input = getJsonInput(); - - if (empty($input['name'])) { - errorResponse('Název zákazníka je povinný'); - } - if (mb_strlen($input['name']) > 255) { - errorResponse('Název zákazníka je příliš dlouhý (max 255 znaků)'); - } - foreach (['street', 'city', 'country'] as $f) { - if (isset($input[$f]) && mb_strlen($input[$f]) > 255) { - errorResponse("Pole $f je příliš dlouhé (max 255 znaků)"); - } - } - if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) { - errorResponse('PSČ je příliš dlouhé (max 20 znaků)'); - } - if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) { - errorResponse('IČO je příliš dlouhé (max 50 znaků)'); - } - if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) { - errorResponse('DIČ je příliš dlouhé (max 50 znaků)'); - } - - $uuid = sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0x0fff) | 0x4000, - random_int(0, 0x3fff) | 0x8000, - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff) - ); - - $customFieldsJson = encodeCustomerCustomFields($input, null); - - $stmt = $pdo->prepare(' - INSERT INTO customers (name, street, city, postal_code, country, - company_id, vat_id, custom_fields, created_at, uuid, modified_at, sync_version) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), 1) - '); - $stmt->execute([ - $input['name'], - $input['street'] ?? '', - $input['city'] ?? '', - $input['postal_code'] ?? '', - $input['country'] ?? '', - $input['company_id'] ?? '', - $input['vat_id'] ?? '', - $customFieldsJson, - $uuid, - ]); - - $newId = (int)$pdo->lastInsertId(); - - - AuditLog::logCreate('customer', (int)$newId, [ - 'name' => $input['name'], - ], "Vytvořen zákazník '{$input['name']}'"); - - successResponse(['id' => $newId], 'Zákazník byl vytvořen'); -} - -function handleUpdateCustomer(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); - $stmt->execute([$id]); - $existing = $stmt->fetch(); - - if (!$existing) { - errorResponse('Zákazník nebyl nalezen', 404); - } - - $input = getJsonInput(); - - // Delkove limity - if (isset($input['name']) && mb_strlen($input['name']) > 255) { - errorResponse('Název je příliš dlouhý (max 255 znaků)'); - } - foreach (['street', 'city', 'country'] as $f) { - if (isset($input[$f]) && mb_strlen($input[$f]) > 255) { - errorResponse("Pole $f je příliš dlouhé (max 255 znaků)"); - } - } - if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) { - errorResponse('PSČ je příliš dlouhé (max 20 znaků)'); - } - if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) { - errorResponse('IČO je příliš dlouhé (max 50 znaků)'); - } - if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) { - errorResponse('DIČ je příliš dlouhé (max 50 znaků)'); - } - - $customFieldsJson = encodeCustomerCustomFields($input, $existing['custom_fields'] ?? null); - - $stmt = $pdo->prepare(' - UPDATE customers SET - name = ?, - street = ?, - city = ?, - postal_code = ?, - country = ?, - company_id = ?, - vat_id = ?, - custom_fields = ?, - modified_at = NOW(), - sync_version = sync_version + 1 - WHERE id = ? - '); - $stmt->execute([ - $input['name'] ?? $existing['name'], - $input['street'] ?? $existing['street'], - $input['city'] ?? $existing['city'], - $input['postal_code'] ?? $existing['postal_code'], - $input['country'] ?? $existing['country'], - $input['company_id'] ?? $existing['company_id'], - $input['vat_id'] ?? $existing['vat_id'], - $customFieldsJson, - $id, - ]); - - - AuditLog::logUpdate( - 'customer', - $id, - ['name' => $existing['name']], - ['name' => $input['name'] ?? $existing['name']], - "Upraven zákazník #$id" - ); - - successResponse(null, 'Zákazník byl aktualizován'); -} - -function handleDeleteCustomer(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); - $stmt->execute([$id]); - $customer = $stmt->fetch(); - - if (!$customer) { - errorResponse('Zákazník nebyl nalezen', 404); - } - - // Check if customer has quotations - $stmt = $pdo->prepare('SELECT COUNT(*) FROM quotations WHERE customer_id = ?'); - $stmt->execute([$id]); - $count = (int)$stmt->fetchColumn(); - - if ($count > 0) { - errorResponse("Zákazníka nelze smazat, má $count nabídek"); - } - - $stmt = $pdo->prepare('DELETE FROM customers WHERE id = ?'); - $stmt->execute([$id]); - - - AuditLog::logDelete('customer', $id, ['name' => $customer['name']], "Smazán zákazník '{$customer['name']}'"); - - successResponse(null, 'Zákazník byl smazán'); -} diff --git a/api/admin/handlers/attendance-handlers.php b/api/admin/handlers/attendance-handlers.php new file mode 100644 index 0000000..f86f2d6 --- /dev/null +++ b/api/admin/handlers/attendance-handlers.php @@ -0,0 +1,589 @@ +prepare(" + SELECT * FROM attendance + WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') + ORDER BY created_at DESC LIMIT 1 + "); + $stmt->execute([$userId]); + $ongoingShift = $stmt->fetch(); + + $projectLogs = []; + $activeProjectId = null; + if ($ongoingShift) { + $stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'); + $stmt->execute([$ongoingShift['id']]); + $projectLogs = $stmt->fetchAll(); + foreach ($projectLogs as $log) { + if ($log['ended_at'] === null) { + $activeProjectId = (int)$log['project_id']; + break; + } + } + } + + $stmt = $pdo->prepare(" + SELECT * FROM attendance + WHERE user_id = ? AND shift_date = ? + AND departure_time IS NOT NULL + AND (leave_type IS NULL OR leave_type = 'work') + ORDER BY arrival_time DESC + "); + $stmt->execute([$userId, $today]); + $todayShifts = $stmt->fetchAll(); + + $completedShiftIds = array_column($todayShifts, 'id'); + $completedProjectLogs = []; + if (!empty($completedShiftIds)) { + $placeholders = implode(',', array_fill(0, count($completedShiftIds), '?')); + $stmt = $pdo->prepare( + "SELECT * FROM attendance_project_logs + WHERE attendance_id IN ($placeholders) + ORDER BY started_at ASC" + ); + $stmt->execute($completedShiftIds); + $allLogs = $stmt->fetchAll(); + foreach ($allLogs as $log) { + $completedProjectLogs[$log['attendance_id']][] = $log; + } + } + + $leaveBalance = getLeaveBalance($pdo, $userId); + + $currentYear = (int)date('Y'); + $currentMonth = (int)date('m'); + $fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth); + $businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth); + + $startDate = date('Y-m-01'); + $endDate = date('Y-m-t'); + + $stmt = $pdo->prepare(' + SELECT * FROM attendance + WHERE user_id = ? AND shift_date BETWEEN ? AND ? + '); + $stmt->execute([$userId, $startDate, $endDate]); + $monthRecords = $stmt->fetchAll(); + + $workedMinutes = 0; + $leaveHoursMonth = 0; + $vacationHours = 0; + $sickHours = 0; + $holidayHours = 0; + $unpaidHours = 0; + foreach ($monthRecords as $rec) { + $lt = $rec['leave_type'] ?? 'work'; + $lh = (float)($rec['leave_hours'] ?? 0); + if ($lt === 'work') { + if ($rec['departure_time']) { + $workedMinutes += calculateWorkMinutes($rec); + } + } elseif ($lt === 'vacation') { + $vacationHours += $lh; + $leaveHoursMonth += $lh; + } elseif ($lt === 'sick') { + $sickHours += $lh; + $leaveHoursMonth += $lh; + } elseif ($lt === 'holiday') { + $holidayHours += $lh; + } elseif ($lt === 'unpaid') { + $unpaidHours += $lh; + } + } + + $workedHours = round($workedMinutes / 60, 1); + $covered = $workedHours + $leaveHoursMonth; + $remaining = max(0, $fund - $covered); + $overtime = max(0, round($covered - $fund, 1)); + + $monthlyFund = [ + 'fund' => $fund, + 'business_days' => $businessDays, + 'worked' => $workedHours, + 'leave_hours' => $leaveHoursMonth, + 'vacation_hours' => $vacationHours, + 'sick_hours' => $sickHours, + 'holiday_hours' => $holidayHours, + 'unpaid_hours' => $unpaidHours, + 'covered' => $covered, + 'remaining' => $remaining, + 'overtime' => $overtime, + 'month_name' => getCzechMonthName($currentMonth) . ' ' . $currentYear, + ]; + + // Enrich project logs with names + $allLogProjectIds = []; + foreach ($projectLogs as $l) { + $allLogProjectIds[$l['project_id']] = $l['project_id']; + } + foreach ($completedProjectLogs as $logs) { + foreach ($logs as $l) { + $allLogProjectIds[$l['project_id']] = $l['project_id']; + } + } + $projNameMap = fetchProjectNames($allLogProjectIds); + + foreach ($projectLogs as &$l) { + $l['project_name'] = $projNameMap[$l['project_id']] ?? null; + } + unset($l); + foreach ($completedProjectLogs as &$logs) { + foreach ($logs as &$l) { + $l['project_name'] = $projNameMap[$l['project_id']] ?? null; + } + unset($l); + } + unset($logs); + + foreach ($todayShifts as &$shift) { + $shift['project_logs'] = $completedProjectLogs[$shift['id']] ?? []; + } + unset($shift); + + successResponse([ + 'ongoing_shift' => $ongoingShift, + 'today_shifts' => $todayShifts, + 'date' => $today, + 'leave_balance' => $leaveBalance, + 'monthly_fund' => $monthlyFund, + 'project_logs' => $projectLogs, + 'active_project_id' => $activeProjectId, + ]); +} + +function handleGetHistory(PDO $pdo, int $userId): void +{ + $month = validateMonth(); + $year = (int)substr($month, 0, 4); + $monthNum = (int)substr($month, 5, 2); + + $startDate = "{$month}-01"; + $endDate = date('Y-m-t', strtotime($startDate)); + + $stmt = $pdo->prepare(' + SELECT * FROM attendance + WHERE user_id = ? AND shift_date BETWEEN ? AND ? + ORDER BY shift_date DESC + '); + $stmt->execute([$userId, $startDate, $endDate]); + $records = $stmt->fetchAll(); + + enrichRecordsWithProjectLogs($pdo, $records); + + $totalMinutes = 0; + $vacationHours = 0; + $sickHours = 0; + $holidayHours = 0; + $unpaidHours = 0; + + foreach ($records as $record) { + $leaveType = $record['leave_type'] ?? 'work'; + $leaveHours = (float)($record['leave_hours'] ?? 0); + + if ($leaveType === 'vacation') { + $vacationHours += $leaveHours; + } elseif ($leaveType === 'sick') { + $sickHours += $leaveHours; + } elseif ($leaveType === 'holiday') { + $holidayHours += $leaveHours; + } elseif ($leaveType === 'unpaid') { + $unpaidHours += $leaveHours; + } else { + $totalMinutes += calculateWorkMinutes($record); + } + } + + $fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum); + $businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum); + $workedHours = round($totalMinutes / 60, 1); + $leaveHoursCovered = $vacationHours + $sickHours; + $covered = $workedHours + $leaveHoursCovered; + $remaining = max(0, round($fund - $covered, 1)); + $overtime = max(0, round($covered - $fund, 1)); + + $leaveBalance = getLeaveBalance($pdo, $userId, $year); + + successResponse([ + 'records' => $records, + 'month' => $month, + 'year' => $year, + 'month_name' => getCzechMonthName($monthNum) . ' ' . $year, + 'total_minutes' => $totalMinutes, + 'vacation_hours' => $vacationHours, + 'sick_hours' => $sickHours, + 'holiday_hours' => $holidayHours, + 'unpaid_hours' => $unpaidHours, + 'leave_balance' => $leaveBalance, + 'monthly_fund' => [ + 'fund' => $fund, + 'business_days' => $businessDays, + 'worked' => $workedHours, + 'leave_hours' => $leaveHoursCovered, + 'covered' => $covered, + 'remaining' => $remaining, + 'overtime' => $overtime, + ], + ]); +} + +function handlePunch(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + $action = $input['punch_action'] ?? ''; + $today = date('Y-m-d'); + $rawNow = date('Y-m-d H:i:s'); + + $lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null; + $lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null; + $accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null; + $address = !empty($input['address']) ? $input['address'] : null; + + $stmt = $pdo->prepare(" + SELECT * FROM attendance + WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') + ORDER BY created_at DESC LIMIT 1 + "); + $stmt->execute([$userId]); + $ongoingShift = $stmt->fetch(); + + if ($action === 'arrival' && !$ongoingShift) { + $now = roundUpTo15Minutes($rawNow); + $stmt = $pdo->prepare(' + INSERT INTO attendance + (user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address) + VALUES (?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]); + + AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [ + 'arrival_time' => $now, + 'location' => $address, + ], 'Příchod zaznamenán'); + + successResponse(null, 'Příchod zaznamenán'); + } elseif ($ongoingShift) { + switch ($action) { + case 'break_start': + if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) { + $breakStart = roundToNearest10Minutes($rawNow); + $breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60)); + + $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); + $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); + + successResponse(null, 'Pauza zaznamenána'); + } else { + errorResponse('Nelze zadat pauzu'); + } + break; + + case 'departure': + if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) { + $now = roundDownTo15Minutes($rawNow); + + // Auto-add break if shift is longer than 6h and no break + if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) { + $arrivalTime = strtotime($ongoingShift['arrival_time']); + $departureTime = strtotime($now); + $hoursWorked = ($departureTime - $arrivalTime) / 3600; + + if ($hoursWorked > 12) { + $midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2); + $breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60))); + $breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60))); + + $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); + $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); + } elseif ($hoursWorked > 6) { + $midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2); + $breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60))); + $breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60))); + + $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); + $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); + } + } + + $stmt = $pdo->prepare(' + UPDATE attendance + SET departure_time = ?, departure_lat = ?, departure_lng = ?, + departure_accuracy = ?, departure_address = ? + WHERE id = ? + '); + $stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]); + + // Close any open project log + $stmt = $pdo->prepare(' + UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL + '); + $stmt->execute([$now, $ongoingShift['id']]); + + AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [ + 'departure_time' => $now, + 'location' => $address, + ], 'Odchod zaznamenán'); + + successResponse(null, 'Odchod zaznamenán'); + } else { + errorResponse('Nelze zadat odchod'); + } + break; + + default: + errorResponse('Neplatná akce'); + } + } else { + errorResponse('Neplatná akce - nemáte aktivní směnu'); + } +} + +function handleUpdateAddress(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + $address = trim($input['address'] ?? ''); + $punchAction = $input['punch_action'] ?? ''; + + if (!$address) { + successResponse(null); + return; + } + + if ($punchAction === 'arrival') { + $stmt = $pdo->prepare(" + UPDATE attendance SET arrival_address = ? + WHERE id = ( + SELECT id FROM ( + SELECT id FROM attendance + WHERE user_id = ? AND (arrival_address IS NULL OR arrival_address = '') + ORDER BY created_at DESC LIMIT 1 + ) t + ) + "); + } else { + $stmt = $pdo->prepare(" + UPDATE attendance SET departure_address = ? + WHERE id = ( + SELECT id FROM ( + SELECT id FROM attendance + WHERE user_id = ? AND (departure_address IS NULL OR departure_address = '') + AND departure_time IS NOT NULL + ORDER BY created_at DESC LIMIT 1 + ) t + ) + "); + } + $stmt->execute([$address, $userId]); + + successResponse(null); +} + +function handleAddLeave(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + + $leaveType = $input['leave_type'] ?? ''; + $leaveDate = $input['leave_date'] ?? ''; + $leaveHours = (float)($input['leave_hours'] ?? 8); + $notes = trim($input['notes'] ?? ''); + + if (!$leaveType || !$leaveDate || $leaveHours <= 0) { + errorResponse('Vyplňte všechna povinná pole'); + } + + if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) { + errorResponse('Neplatný typ nepřítomnosti'); + } + + if ($leaveType === 'vacation') { + $year = (int)date('Y', strtotime($leaveDate)); + $balance = getLeaveBalance($pdo, $userId, $year); + + if ($balance['vacation_remaining'] < $leaveHours) { + errorResponse( + "Nemáte dostatek hodin dovolené. Zbývá vám " + . "{$balance['vacation_remaining']} hodin, požadujete {$leaveHours} hodin." + ); + } + } + + $stmt = $pdo->prepare(' + INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes) + VALUES (?, ?, ?, ?, ?) + '); + $stmt->execute([$userId, $leaveDate, $leaveType, $leaveHours, $notes ?: null]); + + updateLeaveBalance($pdo, $userId, $leaveDate, $leaveType, $leaveHours); + + AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [ + 'leave_type' => $leaveType, + 'leave_hours' => $leaveHours, + ], "Zaznamenána nepřítomnost: $leaveType"); + + successResponse(null, 'Nepřítomnost byla zaznamenána'); +} + +function handleSaveNotes(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + $notes = trim($input['notes'] ?? ''); + + $stmt = $pdo->prepare(' + SELECT id FROM attendance + WHERE user_id = ? AND departure_time IS NULL + ORDER BY created_at DESC LIMIT 1 + '); + $stmt->execute([$userId]); + $currentShift = $stmt->fetch(); + + if (!$currentShift) { + errorResponse('Nemáte aktivní směnu'); + } + + $stmt = $pdo->prepare('UPDATE attendance SET notes = ? WHERE id = ?'); + $stmt->execute([$notes, $currentShift['id']]); + + successResponse(null, 'Poznámka byla uložena'); +} + +function handleGetProjects(): void +{ + try { + $pdo = db(); + $stmt = $pdo->query( + "SELECT id, project_number, name FROM projects + WHERE status = 'aktivni' ORDER BY project_number ASC" + ); + $projects = $stmt->fetchAll(); + successResponse(['projects' => $projects]); + } catch (\Exception $e) { + error_log('Failed to fetch projects: ' . $e->getMessage()); + successResponse(['projects' => []]); + } +} + +function handleSwitchProject(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + /** @var mixed $rawProjectId */ + $rawProjectId = $input['project_id'] ?? null; + $projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null + ? (int)$rawProjectId + : null; + + $stmt = $pdo->prepare(" + SELECT id FROM attendance + WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') + ORDER BY created_at DESC LIMIT 1 + "); + $stmt->execute([$userId]); + $currentShift = $stmt->fetch(); + + if (!$currentShift) { + errorResponse('Nemáte aktivní směnu'); + } + + $attendanceId = $currentShift['id']; + $now = date('Y-m-d H:i:s'); + + $stmt = $pdo->prepare( + 'UPDATE attendance_project_logs SET ended_at = ? + WHERE attendance_id = ? AND ended_at IS NULL' + ); + $stmt->execute([$now, $attendanceId]); + + if ($projectId) { + $stmt = $pdo->prepare( + 'INSERT INTO attendance_project_logs + (attendance_id, project_id, started_at) VALUES (?, ?, ?)' + ); + $stmt->execute([$attendanceId, $projectId, $now]); + } + + $stmt = $pdo->prepare('UPDATE attendance SET project_id = ? WHERE id = ?'); + $stmt->execute([$projectId, $attendanceId]); + + successResponse(null, $projectId ? 'Projekt přepnut' : 'Projekt zastaven'); +} + +/** @param array $authData */ +function handleGetProjectLogs(PDO $pdo, int $currentUserId, array $authData): void +{ + $attendanceId = (int)($_GET['attendance_id'] ?? 0); + if (!$attendanceId) { + errorResponse('attendance_id je povinné'); + } + + // Ověření vlastnictví záznamu nebo admin oprávnění + if (!hasPermission($authData, 'attendance.admin')) { + $ownerStmt = $pdo->prepare('SELECT user_id FROM attendance WHERE id = ?'); + $ownerStmt->execute([$attendanceId]); + $owner = $ownerStmt->fetch(); + if (!$owner || (int)$owner['user_id'] !== $currentUserId) { + errorResponse('Nemáte oprávnění zobrazit tyto záznamy', 403); + } + } + + $stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'); + $stmt->execute([$attendanceId]); + $logs = $stmt->fetchAll(); + + $projectIds = []; + foreach ($logs as $l) { + $projectIds[$l['project_id']] = $l['project_id']; + } + $projNameMap = fetchProjectNames($projectIds); + foreach ($logs as &$l) { + $l['project_name'] = $projNameMap[$l['project_id']] ?? null; + } + unset($l); + + successResponse(['logs' => $logs]); +} + +function handleSaveProjectLogs(PDO $pdo): void +{ + $input = getJsonInput(); + $attendanceId = (int)($input['attendance_id'] ?? 0); + $logs = $input['project_logs'] ?? []; + + if (!$attendanceId) { + errorResponse('attendance_id je povinné'); + } + + $stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?'); + $stmt->execute([$attendanceId]); + $record = $stmt->fetch(); + if (!$record) { + errorResponse('Záznam nebyl nalezen', 404); + } + + $stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?'); + $stmt->execute([$attendanceId]); + + if (!empty($logs)) { + $stmt = $pdo->prepare( + 'INSERT INTO attendance_project_logs + (attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)' + ); + foreach ($logs as $log) { + $projectId = (int)($log['project_id'] ?? 0); + if (!$projectId) { + continue; + } + $h = (int)($log['hours'] ?? 0); + $m = (int)($log['minutes'] ?? 0); + if ($h === 0 && $m === 0) { + continue; + } + $stmt->execute([$attendanceId, $projectId, $h, $m]); + } + } + + successResponse(null, 'Projektové záznamy byly uloženy'); +} diff --git a/api/admin/handlers/bank-accounts-handlers.php b/api/admin/handlers/bank-accounts-handlers.php new file mode 100644 index 0000000..c0aa04c --- /dev/null +++ b/api/admin/handlers/bank-accounts-handlers.php @@ -0,0 +1,166 @@ +query('SELECT * FROM bank_accounts ORDER BY position, id'); + successResponse($stmt->fetchAll()); +} + +function handleCreateBankAccount(PDO $pdo): void +{ + $input = getJsonInput(); + + $accountName = trim($input['account_name'] ?? ''); + $bankName = trim($input['bank_name'] ?? ''); + $accountNumber = trim($input['account_number'] ?? ''); + $iban = trim($input['iban'] ?? ''); + $bic = trim($input['bic'] ?? ''); + $currency = trim($input['currency'] ?? 'CZK'); + $isDefault = !empty($input['is_default']) ? 1 : 0; + + if (!$accountName) { + errorResponse('Název účtu je povinný'); + } + if (mb_strlen($accountName) > 100) { + errorResponse('Název účtu je příliš dlouhý (max 100 znaků)'); + } + if (mb_strlen($bankName) > 255) { + errorResponse('Název banky je příliš dlouhý (max 255 znaků)'); + } + if (mb_strlen($accountNumber) > 50) { + errorResponse('Číslo účtu je příliš dlouhé (max 50 znaků)'); + } + if (mb_strlen($iban) > 50) { + errorResponse('IBAN je příliš dlouhý (max 50 znaků)'); + } + if (mb_strlen($bic) > 20) { + errorResponse('BIC/SWIFT je příliš dlouhý (max 20 znaků)'); + } + if (!in_array($currency, ['CZK', 'EUR', 'USD', 'GBP'])) { + errorResponse('Neplatná měna'); + } + + // Zjistit dalsi pozici + $maxPos = (int) $pdo->query('SELECT COALESCE(MAX(position), 0) FROM bank_accounts')->fetchColumn(); + + $pdo->beginTransaction(); + try { + // Pokud je default, zrusit ostatnim + if ($isDefault) { + $pdo->exec('UPDATE bank_accounts SET is_default = 0'); + } + + $stmt = $pdo->prepare(' + INSERT INTO bank_accounts + (account_name, bank_name, account_number, iban, bic, currency, is_default, position) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([$accountName, $bankName, $accountNumber, $iban, $bic, $currency, $isDefault, $maxPos + 1]); + $newId = (int) $pdo->lastInsertId(); + + $pdo->commit(); + + AuditLog::logCreate( + 'bank_account', + $newId, + ['account_name' => $accountName], + "Vytvořen bankovní účet '$accountName'" + ); + + successResponse(['id' => $newId], 'Bankovní účet byl vytvořen'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleUpdateBankAccount(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?'); + $stmt->execute([$id]); + $account = $stmt->fetch(); + + if (!$account) { + errorResponse('Bankovní účet nebyl nalezen', 404); + } + + $input = getJsonInput(); + + // Delkove limity a validace + $maxLengths = ['account_name' => 100, 'bank_name' => 255, 'account_number' => 50, 'iban' => 50, 'bic' => 20]; + foreach ($maxLengths as $f => $max) { + if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) { + errorResponse("Pole $f je příliš dlouhé (max $max znaků)"); + } + } + if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { + errorResponse('Neplatná měna'); + } + + $fields = ['account_name', 'bank_name', 'account_number', 'iban', 'bic', 'currency']; + $updates = []; + $params = []; + + foreach ($fields as $field) { + if (array_key_exists($field, $input)) { + $updates[] = "$field = ?"; + $params[] = trim((string) $input[$field]); + } + } + + $pdo->beginTransaction(); + try { + if (array_key_exists('is_default', $input)) { + $isDefault = !empty($input['is_default']) ? 1 : 0; + if ($isDefault) { + $pdo->exec('UPDATE bank_accounts SET is_default = 0'); + } + $updates[] = 'is_default = ?'; + $params[] = $isDefault; + } + + if (empty($updates)) { + errorResponse('Žádná data k aktualizaci'); + } + + $updates[] = 'modified_at = NOW()'; + $params[] = $id; + + $sql = 'UPDATE bank_accounts SET ' . implode(', ', $updates) . ' WHERE id = ?'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + $pdo->commit(); + + AuditLog::logUpdate('bank_account', $id, [], $input, "Aktualizován bankovní účet #{$id}"); + + successResponse(null, 'Bankovní účet byl aktualizován'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleDeleteBankAccount(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?'); + $stmt->execute([$id]); + $account = $stmt->fetch(); + + if (!$account) { + errorResponse('Bankovní účet nebyl nalezen', 404); + } + + $pdo->prepare('DELETE FROM bank_accounts WHERE id = ?')->execute([$id]); + + AuditLog::logDelete( + 'bank_account', + $id, + ['account_name' => $account['account_name']], + "Smazán bankovní účet '{$account['account_name']}'" + ); + + successResponse(null, 'Bankovní účet byl smazán'); +} diff --git a/api/admin/handlers/company-settings-handlers.php b/api/admin/handlers/company-settings-handlers.php new file mode 100644 index 0000000..d465e11 --- /dev/null +++ b/api/admin/handlers/company-settings-handlers.php @@ -0,0 +1,247 @@ + + */ +function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array +{ + if ($includeLogo) { + $stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1'); + } else { + $stmt = $pdo->query(' + SELECT id, company_name, company_id, vat_id, street, city, postal_code, country, + quotation_prefix, default_currency, default_vat_rate, + custom_fields, uuid, modified_at, sync_version, + order_type_code, invoice_type_code, is_deleted, + CASE WHEN logo_data IS NOT NULL AND LENGTH(logo_data) > 0 THEN 1 ELSE 0 END as has_logo + FROM company_settings LIMIT 1 + '); + } + $settings = $stmt->fetch(); + + if (!$settings) { + $uuid = sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0x0fff) | 0x4000, + random_int(0, 0x3fff) | 0x8000, + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff) + ); + $pdo->prepare( + "INSERT INTO company_settings + (id, company_name, quotation_prefix, default_currency, + default_vat_rate, uuid, modified_at, sync_version) + VALUES (1, '', 'N', 'EUR', 21.0, ?, NOW(), 1)" + )->execute([$uuid]); + return getOrCreateSettings($pdo, $includeLogo); + } + + return $settings; +} + +function handleGetOffersSettings(PDO $pdo): void +{ + $settings = getOrCreateSettings($pdo, false); + /** @var array|null $cfRaw */ + $cfRaw = !empty($settings['custom_fields']) + ? json_decode($settings['custom_fields'], true) + : null; + if (is_array($cfRaw) && !isset($cfRaw['fields'])) { + $settings['custom_fields'] = $cfRaw; + $settings['supplier_field_order'] = null; + } elseif (is_array($cfRaw) && isset($cfRaw['fields'])) { + $settings['custom_fields'] = $cfRaw['fields']; + $settings['supplier_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null; + } else { + $settings['custom_fields'] = []; + $settings['supplier_field_order'] = null; + } + + $settings['has_logo'] = (bool)($settings['has_logo'] ?? false); + + successResponse($settings); +} + +function handleUpdateOffersSettings(PDO $pdo): void +{ + $input = getJsonInput(); + $settings = getOrCreateSettings($pdo); + + // Delkove limity + $maxLengths = [ + 'company_name' => 255, 'street' => 255, 'city' => 255, + 'postal_code' => 20, 'country' => 100, + 'company_id' => 50, 'vat_id' => 50, + 'default_currency' => 5, + ]; + foreach ($maxLengths as $f => $max) { + if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) { + errorResponse("Pole $f je příliš dlouhé (max $max znaků)"); + } + } + // Validace meny + if (isset($input['default_currency']) && !in_array($input['default_currency'], ['EUR', 'USD', 'CZK', 'GBP'])) { + errorResponse('Neplatná měna'); + } + + $fields = [ + 'company_name', 'street', 'city', 'postal_code', 'country', + 'company_id', 'vat_id', + 'quotation_prefix', 'default_currency', + 'order_type_code', 'invoice_type_code', + ]; + + $setClauses = []; + $params = []; + + foreach ($fields as $field) { + if (array_key_exists($field, $input)) { + $setClauses[] = "$field = ?"; + $params[] = $input[$field]; + } + } + + // custom_fields + SupplierFieldOrder - ulozeny dohromady jako JSON + if (array_key_exists('custom_fields', $input) || array_key_exists('supplier_field_order', $input)) { + /** @var array|null $currentRaw */ + $currentRaw = !empty($settings['custom_fields']) + ? json_decode($settings['custom_fields'], true) + : null; + if (is_array($currentRaw) && !isset($currentRaw['fields'])) { + /** @var array $stored */ + $stored = ['fields' => $currentRaw, 'field_order' => null]; + } elseif (is_array($currentRaw) && isset($currentRaw['fields'])) { + /** @var array $stored */ + $stored = $currentRaw; + } else { + $stored = ['fields' => [], 'field_order' => null]; + } + + if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) { + $stored['fields'] = $input['custom_fields']; + } + if (array_key_exists('supplier_field_order', $input)) { + $stored['field_order'] = is_array($input['supplier_field_order']) ? $input['supplier_field_order'] : null; + } + + // Odstranit stary klic + unset($stored['fieldOrder']); + + $setClauses[] = 'custom_fields = ?'; + $params[] = json_encode($stored, JSON_UNESCAPED_UNICODE); + } + + // Validace prefixu + if (isset($input['quotation_prefix']) && !preg_match('/^[A-Za-z0-9]{0,10}$/', $input['quotation_prefix'])) { + errorResponse('Prefix nabídky může obsahovat pouze alfanumerické znaky (max 10)'); + } + if (isset($input['order_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['order_type_code'])) { + errorResponse('Typový kód objednávek může obsahovat pouze čísla (max 10)'); + } + if (isset($input['invoice_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['invoice_type_code'])) { + errorResponse('Typový kód faktur může obsahovat pouze čísla (max 10)'); + } + + $numericFields = ['default_vat_rate']; + foreach ($numericFields as $field) { + if (array_key_exists($field, $input)) { + $val = is_numeric($input[$field]) ? floatval($input[$field]) : 0; + if ($val < 0 || $val > 100) { + errorResponse('Sazba DPH musí být mezi 0 a 100'); + } + $setClauses[] = "$field = ?"; + $params[] = $val; + } + } + + if (empty($setClauses)) { + errorResponse('Žádná data k aktualizaci'); + } + + $setClauses[] = 'modified_at = NOW()'; + $setClauses[] = 'sync_version = sync_version + 1'; + + $sql = 'UPDATE company_settings SET ' . implode(', ', $setClauses) . ' WHERE id = ?'; + $params[] = $settings['id']; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + + AuditLog::logUpdate('company_settings', (int)$settings['id'], [], $input, 'Aktualizováno nastavení firmy'); + + successResponse(null, 'Nastavení bylo uloženo'); +} + +function handleUploadLogo(PDO $pdo): void +{ + if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) { + errorResponse('Nebyl nahrán žádný soubor'); + } + + $file = $_FILES['logo']; + $allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!in_array($mimeType, $allowedTypes)) { + errorResponse('Nepodporovaný formát obrázku. Povolené: PNG, JPEG, GIF, WebP'); + } + + if ($file['size'] > 5 * 1024 * 1024) { + errorResponse('Soubor je příliš velký (max 5 MB)'); + } + + $logoData = file_get_contents($file['tmp_name']); + + $settings = getOrCreateSettings($pdo); + $stmt = $pdo->prepare( + 'UPDATE company_settings SET logo_data = ?, modified_at = NOW(), sync_version = sync_version + 1 WHERE id = ?' + ); + $stmt->execute([$logoData, $settings['id']]); + + + AuditLog::logUpdate( + 'company_settings', + (int)$settings['id'], + [], + ['logo' => 'uploaded'], + 'Aktualizováno logo firmy' + ); + + successResponse(null, 'Logo bylo nahráno'); +} + +function handleGetLogo(PDO $pdo): void +{ + $stmt = $pdo->query('SELECT logo_data FROM company_settings LIMIT 1'); + $row = $stmt->fetch(); + + if (!$row || empty($row['logo_data'])) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'error' => 'Logo nenalezeno']); + exit(); + } + + $logoData = $row['logo_data']; + + // Detect image type from binary data + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_buffer($finfo, $logoData); + finfo_close($finfo); + + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . strlen($logoData)); + header('Cache-Control: public, max-age=3600'); + echo $logoData; + exit(); +} diff --git a/api/admin/handlers/customers-handlers.php b/api/admin/handlers/customers-handlers.php new file mode 100644 index 0000000..ed42e37 --- /dev/null +++ b/api/admin/handlers/customers-handlers.php @@ -0,0 +1,275 @@ + $customer */ +function parseCustomerCustomFields(array &$customer): void +{ + /** @var array|null $cfRaw */ + $cfRaw = !empty($customer['custom_fields']) + ? json_decode($customer['custom_fields'], true) + : null; + if (is_array($cfRaw) && !isset($cfRaw['fields'])) { + $customer['custom_fields'] = $cfRaw; + $customer['customer_field_order'] = null; + } elseif (is_array($cfRaw) && isset($cfRaw['fields'])) { + $customer['custom_fields'] = $cfRaw['fields']; + $customer['customer_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null; + } else { + $customer['custom_fields'] = []; + $customer['customer_field_order'] = null; + } +} + +/** @param array $input */ +function encodeCustomerCustomFields(array $input, ?string $existingJson): ?string +{ + if (!array_key_exists('custom_fields', $input) && !array_key_exists('customer_field_order', $input)) { + return $existingJson; + } + /** @var array|null $currentRaw */ + $currentRaw = !empty($existingJson) ? json_decode($existingJson, true) : null; + if (is_array($currentRaw) && !isset($currentRaw['fields'])) { + /** @var array $stored */ + $stored = ['fields' => $currentRaw, 'field_order' => null]; + } elseif (is_array($currentRaw) && isset($currentRaw['fields'])) { + /** @var array $stored */ + $stored = $currentRaw; + } else { + $stored = ['fields' => [], 'field_order' => null]; + } + + if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) { + $stored['fields'] = $input['custom_fields']; + } + if (array_key_exists('customer_field_order', $input)) { + $stored['field_order'] = is_array($input['customer_field_order']) ? $input['customer_field_order'] : null; + } + + unset($stored['fieldOrder']); + + return json_encode($stored, JSON_UNESCAPED_UNICODE); +} + +function handleGetAll(PDO $pdo): void +{ + $stmt = $pdo->query(' + SELECT c.*, COUNT(q.id) as quotation_count + FROM customers c + LEFT JOIN quotations q ON q.customer_id = c.id + GROUP BY c.id + ORDER BY c.name ASC + '); + $customers = $stmt->fetchAll(); + + foreach ($customers as &$c) { + parseCustomerCustomFields($c); + } + unset($c); + + successResponse(['customers' => $customers]); +} + +function handleGetOne(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); + $stmt->execute([$id]); + $customer = $stmt->fetch(); + + if (!$customer) { + errorResponse('Zákazník nebyl nalezen', 404); + } + + parseCustomerCustomFields($customer); + successResponse($customer); +} + +function handleSearch(PDO $pdo): void +{ + $q = trim($_GET['q'] ?? ''); + if (strlen($q) < 1 || mb_strlen($q) > 100) { + successResponse(['customers' => []]); + return; + } + + $stmt = $pdo->prepare(' + SELECT * FROM customers + WHERE name LIKE ? OR company_id LIKE ? OR city LIKE ? + ORDER BY name ASC + LIMIT 20 + '); + $search = "%{$q}%"; + $stmt->execute([$search, $search, $search]); + + $results = $stmt->fetchAll(); + foreach ($results as &$c) { + parseCustomerCustomFields($c); + } + unset($c); + successResponse(['customers' => $results]); +} + +function handleCreateCustomer(PDO $pdo): void +{ + $input = getJsonInput(); + + if (empty($input['name'])) { + errorResponse('Název zákazníka je povinný'); + } + if (mb_strlen($input['name']) > 255) { + errorResponse('Název zákazníka je příliš dlouhý (max 255 znaků)'); + } + foreach (['street', 'city', 'country'] as $f) { + if (isset($input[$f]) && mb_strlen($input[$f]) > 255) { + errorResponse("Pole $f je příliš dlouhé (max 255 znaků)"); + } + } + if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) { + errorResponse('PSČ je příliš dlouhé (max 20 znaků)'); + } + if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) { + errorResponse('IČO je příliš dlouhé (max 50 znaků)'); + } + if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) { + errorResponse('DIČ je příliš dlouhé (max 50 znaků)'); + } + + $uuid = sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0x0fff) | 0x4000, + random_int(0, 0x3fff) | 0x8000, + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff) + ); + + $customFieldsJson = encodeCustomerCustomFields($input, null); + + $stmt = $pdo->prepare(' + INSERT INTO customers (name, street, city, postal_code, country, + company_id, vat_id, custom_fields, created_at, uuid, modified_at, sync_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), 1) + '); + $stmt->execute([ + $input['name'], + $input['street'] ?? '', + $input['city'] ?? '', + $input['postal_code'] ?? '', + $input['country'] ?? '', + $input['company_id'] ?? '', + $input['vat_id'] ?? '', + $customFieldsJson, + $uuid, + ]); + + $newId = (int)$pdo->lastInsertId(); + + + AuditLog::logCreate('customer', (int)$newId, [ + 'name' => $input['name'], + ], "Vytvořen zákazník '{$input['name']}'"); + + successResponse(['id' => $newId], 'Zákazník byl vytvořen'); +} + +function handleUpdateCustomer(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); + $stmt->execute([$id]); + $existing = $stmt->fetch(); + + if (!$existing) { + errorResponse('Zákazník nebyl nalezen', 404); + } + + $input = getJsonInput(); + + // Delkove limity + if (isset($input['name']) && mb_strlen($input['name']) > 255) { + errorResponse('Název je příliš dlouhý (max 255 znaků)'); + } + foreach (['street', 'city', 'country'] as $f) { + if (isset($input[$f]) && mb_strlen($input[$f]) > 255) { + errorResponse("Pole $f je příliš dlouhé (max 255 znaků)"); + } + } + if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) { + errorResponse('PSČ je příliš dlouhé (max 20 znaků)'); + } + if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) { + errorResponse('IČO je příliš dlouhé (max 50 znaků)'); + } + if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) { + errorResponse('DIČ je příliš dlouhé (max 50 znaků)'); + } + + $customFieldsJson = encodeCustomerCustomFields($input, $existing['custom_fields'] ?? null); + + $stmt = $pdo->prepare(' + UPDATE customers SET + name = ?, + street = ?, + city = ?, + postal_code = ?, + country = ?, + company_id = ?, + vat_id = ?, + custom_fields = ?, + modified_at = NOW(), + sync_version = sync_version + 1 + WHERE id = ? + '); + $stmt->execute([ + $input['name'] ?? $existing['name'], + $input['street'] ?? $existing['street'], + $input['city'] ?? $existing['city'], + $input['postal_code'] ?? $existing['postal_code'], + $input['country'] ?? $existing['country'], + $input['company_id'] ?? $existing['company_id'], + $input['vat_id'] ?? $existing['vat_id'], + $customFieldsJson, + $id, + ]); + + + AuditLog::logUpdate( + 'customer', + $id, + ['name' => $existing['name']], + ['name' => $input['name'] ?? $existing['name']], + "Upraven zákazník #$id" + ); + + successResponse(null, 'Zákazník byl aktualizován'); +} + +function handleDeleteCustomer(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); + $stmt->execute([$id]); + $customer = $stmt->fetch(); + + if (!$customer) { + errorResponse('Zákazník nebyl nalezen', 404); + } + + // Check if customer has quotations + $stmt = $pdo->prepare('SELECT COUNT(*) FROM quotations WHERE customer_id = ?'); + $stmt->execute([$id]); + $count = (int)$stmt->fetchColumn(); + + if ($count > 0) { + errorResponse("Zákazníka nelze smazat, má $count nabídek"); + } + + $stmt = $pdo->prepare('DELETE FROM customers WHERE id = ?'); + $stmt->execute([$id]); + + + AuditLog::logDelete('customer', $id, ['name' => $customer['name']], "Smazán zákazník '{$customer['name']}'"); + + successResponse(null, 'Zákazník byl smazán'); +} diff --git a/api/admin/handlers/invoices-handlers.php b/api/admin/handlers/invoices-handlers.php new file mode 100644 index 0000000..95acce5 --- /dev/null +++ b/api/admin/handlers/invoices-handlers.php @@ -0,0 +1,705 @@ + */ +function getValidTransitions(string $status): array +{ + return match ($status) { + 'issued' => ['paid'], + 'overdue' => ['paid'], + default => [] + }; +} + +// --- Invoice number generation --- + +function generateInvoiceNumber(PDO $pdo): string +{ + $yy = date('y'); + + $settings = $pdo->query('SELECT invoice_type_code FROM company_settings LIMIT 1')->fetch(); + $typeCode = ($settings && !empty($settings['invoice_type_code'])) ? $settings['invoice_type_code'] : '81'; + + $prefix = $yy . $typeCode; + $prefixLen = strlen($prefix); + $likePattern = $prefix . '%'; + + $stmt = $pdo->prepare(' + SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ? + 1) AS UNSIGNED)), 0) + FROM invoices WHERE invoice_number LIKE ? + '); + $stmt->execute([$prefixLen, $likePattern]); + $max = (int) $stmt->fetchColumn(); + + return sprintf('%s%04d', $prefix, $max + 1); +} + +// --- Stats --- + +/** + * Spocita celkovou castku faktur seskupenou podle meny + CZK prepocet dle kurzu k datu faktury. + * + * @param array $params + * @return array{amounts: array, count: int, total_czk: float} + */ +function sumInvoicesByCurrency(PDO $pdo, string $where, array $params): array +{ + // Per-faktura pro presny prepocet kurzem k datu + $perInvoiceSql = " + SELECT i.id, i.currency, i.issue_date, + COALESCE(SUM(ii.quantity * ii.unit_price), 0) + + COALESCE(SUM(CASE WHEN i.apply_vat + THEN ii.quantity * ii.unit_price * ii.vat_rate / 100 + ELSE 0 END), 0) AS total + FROM invoices i + JOIN invoice_items ii ON ii.invoice_id = i.id + $where + GROUP BY i.id, i.currency, i.issue_date + "; + $stmt = $pdo->prepare($perInvoiceSql); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + // Seskupit podle meny pro zobrazeni + $byCurrency = []; + $czkItems = []; + foreach ($rows as $r) { + $cur = $r['currency']; + $amt = round((float) $r['total'], 2); + $byCurrency[$cur] = ($byCurrency[$cur] ?? 0) + $amt; + $czkItems[] = [ + 'amount' => $amt, + 'currency' => $cur, + 'date' => $r['issue_date'], + ]; + } + + $amounts = []; + foreach ($byCurrency as $cur => $total) { + $amounts[] = ['amount' => round($total, 2), 'currency' => $cur]; + } + + $cnb = CnbRates::getInstance(); + $totalCzk = $cnb->sumToCzk($czkItems); + + $countSql = "SELECT COUNT(*) FROM invoices i $where"; + $countStmt = $pdo->prepare($countSql); + $countStmt->execute($params); + + return [ + 'amounts' => $amounts, + 'count' => (int) $countStmt->fetchColumn(), + 'total_czk' => $totalCzk, + ]; +} + +function handleGetStats(PDO $pdo): void +{ + $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); + $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); + + // Lazy overdue detekce + $pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()"); + + $monthStart = sprintf('%04d-%02d-01', $year, $month); + $monthEnd = date('Y-m-t', strtotime($monthStart)); + + // a) Uhrazeno v danem mesici (dle data vystaveni, ne uhrazeni) + $paidWhere = "WHERE i.status = 'paid' AND i.issue_date BETWEEN ? AND ?"; + $paid = sumInvoicesByCurrency($pdo, $paidWhere, [$monthStart, $monthEnd]); + + // b) Ceka uhrada (aktualni stav) + $awaiting = sumInvoicesByCurrency($pdo, "WHERE i.status = 'issued'", []); + + // c) Po splatnosti (aktualni stav) + $overdue = sumInvoicesByCurrency($pdo, "WHERE i.status = 'overdue'", []); + + // d) DPH v danem mesici - per faktura pro kurz k datu + $vatSql = " + SELECT i.id, i.currency, i.issue_date, + COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0) AS vat_total + FROM invoices i + JOIN invoice_items ii ON ii.invoice_id = i.id + WHERE i.apply_vat = 1 AND i.issue_date BETWEEN ? AND ? + GROUP BY i.id, i.currency, i.issue_date + "; + $vatStmt = $pdo->prepare($vatSql); + $vatStmt->execute([$monthStart, $monthEnd]); + $vatRows = $vatStmt->fetchAll(); + + $vatByCurrency = []; + $vatCzkItems = []; + foreach ($vatRows as $r) { + $cur = $r['currency']; + $amt = round((float) $r['vat_total'], 2); + $vatByCurrency[$cur] = ($vatByCurrency[$cur] ?? 0) + $amt; + $vatCzkItems[] = [ + 'amount' => $amt, + 'currency' => $cur, + 'date' => $r['issue_date'], + ]; + } + + $vatAmounts = []; + foreach ($vatByCurrency as $cur => $total) { + $vatAmounts[] = ['amount' => round($total, 2), 'currency' => $cur]; + } + + $cnb = CnbRates::getInstance(); + + successResponse([ + 'paid_month' => $paid['amounts'], + 'paid_month_czk' => $paid['total_czk'], + 'paid_month_count' => $paid['count'], + 'awaiting' => $awaiting['amounts'], + 'awaiting_czk' => $awaiting['total_czk'], + 'awaiting_count' => $awaiting['count'], + 'overdue' => $overdue['amounts'], + 'overdue_czk' => $overdue['total_czk'], + 'overdue_count' => $overdue['count'], + 'vat_month' => $vatAmounts, + 'vat_month_czk' => $cnb->sumToCzk($vatCzkItems), + 'month' => $month, + 'year' => $year, + ]); +} + +// --- Handlers --- + +function handleGetList(PDO $pdo): void +{ + $search = trim($_GET['search'] ?? ''); + $statusFilter = trim($_GET['status'] ?? ''); + $sort = $_GET['sort'] ?? 'created_at'; + $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + $page = max(1, (int) ($_GET['page'] ?? 1)); + $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); + + $sortMap = [ + 'InvoiceNumber' => 'i.invoice_number', + 'invoice_number' => 'i.invoice_number', + 'CreatedAt' => 'i.created_at', + 'created_at' => 'i.created_at', + 'Status' => 'i.status', + 'status' => 'i.status', + 'DueDate' => 'i.due_date', + 'due_date' => 'i.due_date', + 'IssueDate' => 'i.issue_date', + 'issue_date' => 'i.issue_date', + ]; + if (!isset($sortMap[$sort])) { + errorResponse('Neplatný parametr řazení', 400); + } + $sortCol = $sortMap[$sort]; + + // Lazy overdue detekce + $pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()"); + + $where = 'WHERE 1=1'; + $params = []; + + if ($search) { + $search = mb_substr($search, 0, 100); + $where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)'; + $searchParam = "%{$search}%"; + $params = array_merge($params, [$searchParam, $searchParam, $searchParam]); + } + + if ($statusFilter) { + $statuses = array_filter(explode(',', $statusFilter)); + if ($statuses) { + $placeholders = implode(',', array_fill(0, count($statuses), '?')); + $where .= " AND i.status IN ($placeholders)"; + $params = array_merge($params, $statuses); + } + } + + $countSql = " + SELECT COUNT(*) + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + $where + "; + $stmt = $pdo->prepare($countSql); + $stmt->execute($params); + $total = (int) $stmt->fetchColumn(); + + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency, + i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat, + c.name as customer_name, + (SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0) + FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal, + o.order_number + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + LEFT JOIN orders o ON i.order_id = o.id + $where + ORDER BY $sortCol $order + LIMIT $perPage OFFSET $offset + "; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $invoices = $stmt->fetchAll(); + + // Dopocitat celkovou castku s DPH + foreach ($invoices as &$inv) { + $subtotal = (float) $inv['subtotal']; + if ($inv['apply_vat']) { + $vatStmt = $pdo->prepare(' + SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0) + FROM invoice_items WHERE invoice_id = ? + '); + $vatStmt->execute([$inv['id']]); + $vatAmount = (float) $vatStmt->fetchColumn(); + $inv['total'] = $subtotal + $vatAmount; + } else { + $inv['total'] = $subtotal; + } + } + unset($inv); + + successResponse([ + 'invoices' => $invoices, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]); +} + +function handleGetDetail(PDO $pdo, int $id): void +{ + // Lazy overdue + $pdo->prepare( + "UPDATE invoices SET status = 'overdue' WHERE id = ? AND status = 'issued' AND due_date < CURDATE()" + )->execute([$id]); + + $stmt = $pdo->prepare(' + SELECT i.*, c.name as customer_name, o.order_number + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + LEFT JOIN orders o ON i.order_id = o.id + WHERE i.id = ? + '); + $stmt->execute([$id]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + errorResponse('Faktura nebyla nalezena', 404); + } + + // Polozky + $stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position'); + $stmt->execute([$id]); + $invoice['items'] = $stmt->fetchAll(); + + // Zakaznik + if ($invoice['customer_id']) { + $stmt = $pdo->prepare( + 'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields + FROM customers WHERE id = ?' + ); + $stmt->execute([$invoice['customer_id']]); + $invoice['customer'] = $stmt->fetch(); + } + + $invoice['valid_transitions'] = getValidTransitions($invoice['status']); + + successResponse($invoice); +} + +function handleGetNextNumber(PDO $pdo): void +{ + $number = generateInvoiceNumber($pdo); + successResponse(['number' => $number]); +} + +function handleGetOrderData(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare(' + SELECT o.id, o.order_number, o.customer_id, o.status, o.currency, + o.language, o.vat_rate, o.apply_vat, o.exchange_rate, + o.created_at, o.modified_at, + c.name as customer_name + FROM orders o + LEFT JOIN customers c ON o.customer_id = c.id + WHERE o.id = ? + '); + $stmt->execute([$id]); + $order = $stmt->fetch(); + + if (!$order) { + errorResponse('Objednávka nebyla nalezena', 404); + } + + // Polozky objednavky + $stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position'); + $stmt->execute([$id]); + $order['items'] = $stmt->fetchAll(); + + successResponse($order); +} + +/** @param array $authData */ +function handleCreateInvoice(PDO $pdo, array $authData): void +{ + $input = getJsonInput(); + + $customerId = isset($input['customer_id']) ? (int) $input['customer_id'] : null; + $orderId = !empty($input['order_id']) ? (int) $input['order_id'] : null; + $issueDate = trim($input['issue_date'] ?? ''); + $dueDate = trim($input['due_date'] ?? ''); + $taxDate = trim($input['tax_date'] ?? ''); + $currency = trim($input['currency'] ?? 'CZK'); + $applyVat = isset($input['apply_vat']) ? (int) $input['apply_vat'] : 1; + $paymentMethod = trim($input['payment_method'] ?? 'Příkazem'); + $constantSymbol = trim($input['constant_symbol'] ?? '0308'); + $issuedBy = trim($input['issued_by'] ?? ''); + $notes = trim($input['notes'] ?? ''); + $items = $input['items'] ?? []; + + // Bankovni udaje + $bankName = trim($input['bank_name'] ?? ''); + $bankSwift = trim($input['bank_swift'] ?? ''); + $bankIban = trim($input['bank_iban'] ?? ''); + $bankAccount = trim($input['bank_account'] ?? ''); + + if (!$customerId) { + errorResponse('Zákazník je povinný'); + } + if (!$issueDate || !$dueDate || !$taxDate) { + errorResponse('Všechna data (vystavení, splatnost, DÚZP) jsou povinná'); + } + + // Validace formatu dat + foreach (['issue_date' => $issueDate, 'due_date' => $dueDate, 'tax_date' => $taxDate] as $label => $date) { + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { + errorResponse("Neplatný formát data: $label"); + } + } + + // Validace meny + $validCurrencies = ['CZK', 'EUR', 'USD', 'GBP']; + if (!in_array($currency, $validCurrencies)) { + errorResponse('Neplatná měna'); + } + + // Delkove limity + if (mb_strlen($paymentMethod) > 50) { + errorResponse('Forma úhrady je příliš dlouhá (max 50 znaků)'); + } + if (mb_strlen($issuedBy) > 255) { + errorResponse('Vystavil je příliš dlouhé (max 255 znaků)'); + } + if (mb_strlen($notes) > 5000) { + errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); + } + if (mb_strlen($bankName) > 255) { + errorResponse('Název banky je příliš dlouhý'); + } + if (mb_strlen($bankIban) > 50) { + errorResponse('IBAN je příliš dlouhý'); + } + if (mb_strlen($bankSwift) > 20) { + errorResponse('BIC/SWIFT je příliš dlouhý'); + } + if (mb_strlen($bankAccount) > 50) { + errorResponse('Číslo účtu je příliš dlouhé'); + } + if (!$bankAccount && !$bankIban) { + errorResponse('Bankovní účet je povinný'); + } + + if (empty($items)) { + errorResponse('Faktura musí mít alespoň jednu položku'); + } + + // Validace polozek + foreach ($items as $i => $item) { + $qty = $item['quantity'] ?? 1; + $price = $item['unit_price'] ?? 0; + $vatRate = $item['vat_rate'] ?? 21; + if (!is_numeric($qty) || $qty < 0) { + errorResponse('Položka #' . ($i + 1) . ': neplatné množství'); + } + if (!is_numeric($price)) { + errorResponse('Položka #' . ($i + 1) . ': neplatná cena'); + } + if (!is_numeric($vatRate) || $vatRate < 0 || $vatRate > 100) { + errorResponse('Položka #' . ($i + 1) . ': neplatná sazba DPH'); + } + if (mb_strlen($item['description'] ?? '') > 500) { + errorResponse('Položka #' . ($i + 1) . ': popis je příliš dlouhý (max 500 znaků)'); + } + } + + // Overit zakaznika + $stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); + $stmt->execute([$customerId]); + if (!$stmt->fetch()) { + errorResponse('Zákazník nebyl nalezen', 404); + } + + // Lock pro cislovani + $locked = $pdo->query("SELECT GET_LOCK('boha_invoice_number', 5)")->fetchColumn(); + if (!$locked) { + errorResponse('Nepodařilo se získat zámek pro číslo faktury, zkuste to znovu', 503); + } + + $pdo->beginTransaction(); + try { + $invoiceNumber = !empty($input['invoice_number']) + ? trim($input['invoice_number']) + : generateInvoiceNumber($pdo); + + $stmt = $pdo->prepare(" + INSERT INTO invoices ( + invoice_number, order_id, customer_id, status, currency, + vat_rate, apply_vat, payment_method, constant_symbol, + bank_name, bank_swift, bank_iban, bank_account, + issue_date, due_date, tax_date, issued_by, notes, + created_at, modified_at + ) VALUES (?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + "); + $stmt->execute([ + $invoiceNumber, + $orderId, + $customerId, + $currency, + $input['vat_rate'] ?? 21, + $applyVat, + $paymentMethod, + $constantSymbol, + $bankName, + $bankSwift, + $bankIban, + $bankAccount, + $issueDate, + $dueDate, + $taxDate, + $issuedBy, + $notes, + ]); + $invoiceId = (int) $pdo->lastInsertId(); + + // Vlozit polozky + $itemStmt = $pdo->prepare(' + INSERT INTO invoice_items ( + invoice_id, description, quantity, unit, unit_price, vat_rate, position + ) VALUES (?, ?, ?, ?, ?, ?, ?) + '); + foreach ($items as $i => $item) { + $itemStmt->execute([ + $invoiceId, + trim($item['description'] ?? ''), + $item['quantity'] ?? 1, + trim($item['unit'] ?? ''), + $item['unit_price'] ?? 0, + $item['vat_rate'] ?? 21, + $item['position'] ?? $i, + ]); + } + + $pdo->commit(); + $pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')"); + + AuditLog::logCreate('invoices_invoice', $invoiceId, [ + 'invoice_number' => $invoiceNumber, + 'customer_id' => $customerId, + 'order_id' => $orderId, + ], "Vytvořena faktura '$invoiceNumber'"); + + successResponse([ + 'invoice_id' => $invoiceId, + 'invoice_number' => $invoiceNumber, + ], 'Faktura byla vystavena'); + } catch (PDOException $e) { + $pdo->rollBack(); + $pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')"); + throw $e; + } +} + +function handleUpdateInvoice(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); + $stmt->execute([$id]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + errorResponse('Faktura nebyla nalezena', 404); + } + + $input = getJsonInput(); + $newStatus = $input['status'] ?? null; + $isDraft = $invoice['status'] === 'issued'; + + // Zmena stavu + if ($newStatus && $newStatus !== $invoice['status']) { + $valid = getValidTransitions($invoice['status']); + if (!in_array($newStatus, $valid)) { + errorResponse("Neplatný přechod stavu z '{$invoice['status']}' na '$newStatus'"); + } + } + + $pdo->beginTransaction(); + try { + $updates = []; + $params = []; + + if ($newStatus !== null && $newStatus !== $invoice['status']) { + $updates[] = 'status = ?'; + $params[] = $newStatus; + + if ($newStatus === 'paid') { + $updates[] = 'paid_date = CURDATE()'; + } + } + + // V issued stavu lze editovat vsechna pole + if ($isDraft) { + // Validace dat + foreach (['issue_date', 'due_date', 'tax_date'] as $dateField) { + if ( + isset($input[$dateField]) + && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $input[$dateField]) || !strtotime($input[$dateField])) + ) { + errorResponse("Neplatný formát data: $dateField"); + } + } + // Validace meny + if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { + errorResponse('Neplatná měna'); + } + // Validace DPH + if ( + isset($input['vat_rate']) + && (!is_numeric($input['vat_rate']) || $input['vat_rate'] < 0 || $input['vat_rate'] > 100) + ) { + errorResponse('Neplatná sazba DPH'); + } + // Validace zakaznika + if (isset($input['customer_id'])) { + $custStmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); + $custStmt->execute([(int)$input['customer_id']]); + if (!$custStmt->fetch()) { + errorResponse('Zákazník nebyl nalezen', 404); + } + } + + $stringFields = [ + 'issue_date' => 20, 'due_date' => 20, 'tax_date' => 20, + 'payment_method' => 50, 'constant_symbol' => 10, + 'bank_name' => 255, 'bank_swift' => 20, 'bank_iban' => 50, 'bank_account' => 50, + 'issued_by' => 255, + ]; + foreach ($stringFields as $field => $maxLen) { + if (array_key_exists($field, $input)) { + $val = trim((string)$input[$field]); + if (mb_strlen($val) > $maxLen) { + errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)"); + } + $updates[] = "$field = ?"; + $params[] = $val; + } + } + $numericFields = ['currency', 'vat_rate', 'apply_vat', 'customer_id']; + foreach ($numericFields as $field) { + if (array_key_exists($field, $input)) { + $updates[] = "$field = ?"; + $params[] = $input[$field]; + } + } + + // Aktualizace polozek + if (isset($input['items']) && is_array($input['items'])) { + $pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]); + + $itemStmt = $pdo->prepare(' + INSERT INTO invoice_items ( + invoice_id, description, quantity, unit, unit_price, vat_rate, position + ) VALUES (?, ?, ?, ?, ?, ?, ?) + '); + foreach ($input['items'] as $i => $item) { + $itemStmt->execute([ + $id, + trim($item['description'] ?? ''), + $item['quantity'] ?? 1, + trim($item['unit'] ?? ''), + $item['unit_price'] ?? 0, + $item['vat_rate'] ?? 21, + $item['position'] ?? $i, + ]); + } + } + } + + // Poznamky lze editovat jen v issued/overdue stavu + if ($isDraft || $invoice['status'] === 'overdue') { + if (array_key_exists('notes', $input)) { + $updates[] = 'notes = ?'; + $params[] = $input['notes']; + } + if (array_key_exists('internal_notes', $input)) { + $updates[] = 'internal_notes = ?'; + $params[] = $input['internal_notes']; + } + } + + if (!empty($updates)) { + $updates[] = 'modified_at = NOW()'; + $params[] = $id; + $sql = 'UPDATE invoices SET ' . implode(', ', $updates) . ' WHERE id = ?'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + } + + $pdo->commit(); + + AuditLog::logUpdate( + 'invoices_invoice', + $id, + ['status' => $invoice['status']], + ['status' => $newStatus ?? $invoice['status']], + "Aktualizována faktura '{$invoice['invoice_number']}'" + ); + + successResponse(null, 'Faktura byla aktualizována'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleDeleteInvoice(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); + $stmt->execute([$id]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + errorResponse('Faktura nebyla nalezena', 404); + } + + $pdo->beginTransaction(); + try { + $pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM invoices WHERE id = ?')->execute([$id]); + + $pdo->commit(); + + AuditLog::logDelete('invoices_invoice', $id, [ + 'invoice_number' => $invoice['invoice_number'], + 'customer_id' => $invoice['customer_id'], + ], "Smazána faktura '{$invoice['invoice_number']}'"); + + successResponse(null, 'Faktura byla smazána'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} diff --git a/api/admin/handlers/leave-requests-handlers.php b/api/admin/handlers/leave-requests-handlers.php new file mode 100644 index 0000000..d11d582 --- /dev/null +++ b/api/admin/handlers/leave-requests-handlers.php @@ -0,0 +1,462 @@ +modify('+1 day'); // include the end date + + $days = 0; + $period = new DatePeriod($start, new DateInterval('P1D'), $end); + foreach ($period as $date) { + $dayOfWeek = (int)$date->format('N'); // 1=Mon, 7=Sun + if ($dayOfWeek <= 5) { + $days++; + } + } + return $days; +} + +/** + * Get leave balance for user (reuse logic from attendance.php) + * + * @return array + */ +function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): array +{ + $year = $year ?: (int)date('Y'); + + $stmt = $pdo->prepare('SELECT * FROM leave_balances WHERE user_id = ? AND year = ?'); + $stmt->execute([$userId, $year]); + $balance = $stmt->fetch(); + + if (!$balance) { + return [ + 'vacation_total' => 160, + 'vacation_used' => 0, + 'vacation_remaining' => 160, + 'sick_used' => 0, + ]; + } + + return [ + 'vacation_total' => (float)$balance['vacation_total'], + 'vacation_used' => (float)$balance['vacation_used'], + 'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'], + 'sick_used' => (float)$balance['sick_used'], + ]; +} + +/** + * Get hours already locked in pending requests for vacation + */ +function getPendingVacationHours(PDO $pdo, int $userId, int $year): float +{ + $stmt = $pdo->prepare(" + SELECT COALESCE(SUM(total_hours), 0) as pending_hours + FROM leave_requests + WHERE user_id = ? AND leave_type = 'vacation' AND status = 'pending' + AND YEAR(date_from) = ? + "); + $stmt->execute([$userId, $year]); + return (float)$stmt->fetchColumn(); +} + +// ============================================================================ +// GET Handlers +// ============================================================================ + +/** + * GET - Own leave requests + */ +function handleGetMyRequests(PDO $pdo, int $userId): void +{ + $stmt = $pdo->prepare(" + SELECT lr.*, + CONCAT(u.first_name, ' ', u.last_name) as reviewer_name + FROM leave_requests lr + LEFT JOIN users u ON lr.reviewer_id = u.id + WHERE lr.user_id = ? + ORDER BY lr.created_at DESC + "); + $stmt->execute([$userId]); + $requests = $stmt->fetchAll(); + + successResponse($requests); +} + +/** + * GET - All pending requests (for approver) + */ +function handleGetPending(PDO $pdo): void +{ + $stmt = $pdo->prepare(" + SELECT lr.*, + CONCAT(u.first_name, ' ', u.last_name) as employee_name, + CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name + FROM leave_requests lr + JOIN users u ON lr.user_id = u.id + LEFT JOIN users rv ON lr.reviewer_id = rv.id + WHERE lr.status = 'pending' + ORDER BY lr.created_at ASC + "); + $stmt->execute(); + $requests = $stmt->fetchAll(); + + successResponse([ + 'requests' => $requests, + 'count' => count($requests), + ]); +} + +/** + * GET - All requests with filters (for approver) + */ +function handleGetAll(PDO $pdo): void +{ + $status = $_GET['status'] ?? ''; + $userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null; + + $where = []; + $params = []; + + if ($status && in_array($status, ['pending', 'approved', 'rejected', 'cancelled'])) { + $where[] = 'lr.status = ?'; + $params[] = $status; + } + + if ($userId) { + $where[] = 'lr.user_id = ?'; + $params[] = $userId; + } + + $whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + $stmt = $pdo->prepare(" + SELECT lr.*, + CONCAT(u.first_name, ' ', u.last_name) as employee_name, + CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name + FROM leave_requests lr + JOIN users u ON lr.user_id = u.id + LEFT JOIN users rv ON lr.reviewer_id = rv.id + $whereClause + ORDER BY lr.created_at DESC + LIMIT 200 + "); + $stmt->execute($params); + $requests = $stmt->fetchAll(); + + successResponse($requests); +} + +// ============================================================================ +// POST Handlers +// ============================================================================ + +/** + * POST - Submit new leave request + */ +function handleSubmitRequest(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + + $leaveType = $input['leave_type'] ?? ''; + $dateFrom = $input['date_from'] ?? ''; + $dateTo = $input['date_to'] ?? ''; + $notes = trim($input['notes'] ?? ''); + + if (!$leaveType || !$dateFrom || !$dateTo) { + errorResponse('Vyplňte všechna povinná pole'); + } + + if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) { + errorResponse('Neplatný typ nepřítomnosti'); + } + + // Validate dates + $from = new DateTime($dateFrom); + $to = new DateTime($dateTo); + if ($to < $from) { + errorResponse('Datum "do" nesmí být před datem "od"'); + } + + // Calculate business days + $businessDays = calculateBusinessDays($dateFrom, $dateTo); + if ($businessDays === 0) { + errorResponse('Zvolené období neobsahuje žádné pracovní dny'); + } + + $totalHours = $businessDays * 8; + + // Check vacation balance + if ($leaveType === 'vacation') { + $year = (int)$from->format('Y'); + $balance = getLeaveBalanceForRequest($pdo, $userId, $year); + $pendingHours = getPendingVacationHours($pdo, $userId, $year); + $availableHours = $balance['vacation_remaining'] - $pendingHours; + + if ($availableHours < $totalHours) { + errorResponse( + "Nemáte dostatek hodin dovolené. Dostupné: {$availableHours}h" + . " (zbývá {$balance['vacation_remaining']}h, v čekajících žádostech: {$pendingHours}h)," + . " požadujete: {$totalHours}h." + ); + } + } + + // Check overlapping requests + $stmt = $pdo->prepare(" + SELECT id FROM leave_requests + WHERE user_id = ? AND status IN ('pending', 'approved') + AND date_from <= ? AND date_to >= ? + "); + $stmt->execute([$userId, $dateTo, $dateFrom]); + if ($stmt->fetch()) { + errorResponse('Pro toto období již existuje žádost o nepřítomnost'); + } + + // Insert request + $stmt = $pdo->prepare(" + INSERT INTO leave_requests (user_id, leave_type, date_from, date_to, total_hours, total_days, notes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending') + "); + $stmt->execute([$userId, $leaveType, $dateFrom, $dateTo, $totalHours, $businessDays, $notes ?: null]); + + $requestId = (int)$pdo->lastInsertId(); + + AuditLog::logCreate('leave_request', $requestId, [ + 'leave_type' => $leaveType, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'total_days' => $businessDays, + 'total_hours' => $totalHours, + ], "Podána žádost o nepřítomnost: $leaveType ($dateFrom - $dateTo)"); + + // Send email notification + try { + $stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?"); + $stmt->execute([$userId]); + $employeeName = $stmt->fetchColumn() ?: 'Neznámý'; + + LeaveNotification::notifyNewRequest([ + 'leave_type' => $leaveType, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'total_days' => $businessDays, + 'total_hours' => $totalHours, + 'notes' => $notes, + ], $employeeName); + } catch (\Exception $e) { + error_log('Leave notification error: ' . $e->getMessage()); + } + + successResponse(['id' => $requestId], 'Žádost byla odeslána ke schválení'); +} + +/** + * POST - Cancel own pending request + */ +function handleCancelRequest(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + $requestId = (int)($input['request_id'] ?? 0); + + if (!$requestId) { + errorResponse('ID žádosti je povinné'); + } + + $stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ? AND user_id = ?'); + $stmt->execute([$requestId, $userId]); + $request = $stmt->fetch(); + + if (!$request) { + errorResponse('Žádost nebyla nalezena'); + } + + if ($request['status'] !== 'pending') { + errorResponse('Lze zrušit pouze čekající žádosti'); + } + + $stmt = $pdo->prepare("UPDATE leave_requests SET status = 'cancelled' WHERE id = ?"); + $stmt->execute([$requestId]); + + AuditLog::logUpdate( + 'leave_request', + $requestId, + ['status' => 'pending'], + ['status' => 'cancelled'], + 'Žádost o nepřítomnost zrušena zaměstnancem' + ); + + successResponse(null, 'Žádost byla zrušena'); +} + +/** + * POST - Approve a leave request + * + * @param array $authData + */ +function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void +{ + $input = getJsonInput(); + $requestId = (int)($input['request_id'] ?? 0); + + if (!$requestId) { + errorResponse('ID žádosti je povinné'); + } + + $stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?'); + $stmt->execute([$requestId]); + $request = $stmt->fetch(); + + if (!$request) { + errorResponse('Žádost nebyla nalezena'); + } + + if ($request['status'] !== 'pending') { + errorResponse('Lze schválit pouze čekající žádosti'); + } + + if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) { + errorResponse('Nemůžete schválit vlastní žádost', 403); + } + + // Re-check vacation balance + if ($request['leave_type'] === 'vacation') { + $year = (int)date('Y', strtotime($request['date_from'])); + $balance = getLeaveBalanceForRequest($pdo, (int)$request['user_id'], $year); + + if ($balance['vacation_remaining'] < (float)$request['total_hours']) { + errorResponse( + "Zaměstnanec nemá dostatek hodin dovolené." + . " Zbývá: {$balance['vacation_remaining']}h, požadováno: {$request['total_hours']}h." + ); + } + } + + // Begin transaction + $pdo->beginTransaction(); + + try { + // Create attendance records for each business day + $start = new DateTime($request['date_from']); + $end = new DateTime($request['date_to']); + $end->modify('+1 day'); + + $period = new DatePeriod($start, new DateInterval('P1D'), $end); + $insertStmt = $pdo->prepare(' + INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes) + VALUES (?, ?, ?, 8, ?) + '); + + $leaveNote = "Schválená žádost #$requestId"; + $totalBusinessDays = 0; + + foreach ($period as $date) { + $dayOfWeek = (int)$date->format('N'); + if ($dayOfWeek <= 5) { + $shiftDate = $date->format('Y-m-d'); + $insertStmt->execute([ + $request['user_id'], + $shiftDate, + $request['leave_type'], + $leaveNote, + ]); + $totalBusinessDays++; + } + } + + // Update leave balance ONCE with total hours (was N queries, one per day) + if ($totalBusinessDays > 0) { + updateLeaveBalance( + $pdo, + (int)$request['user_id'], + $request['date_from'], + $request['leave_type'], + (float)($totalBusinessDays * 8) + ); + } + + // Update request status + $stmt = $pdo->prepare(" + UPDATE leave_requests + SET status = 'approved', reviewer_id = ?, reviewed_at = NOW() + WHERE id = ? + "); + $stmt->execute([$reviewerId, $requestId]); + + $pdo->commit(); + + AuditLog::logUpdate( + 'leave_request', + $requestId, + ['status' => 'pending'], + ['status' => 'approved', 'reviewer_id' => $reviewerId], + 'Žádost o nepřítomnost schválena' + ); + + successResponse(null, 'Žádost byla schválena'); + } catch (\Exception $e) { + $pdo->rollBack(); + error_log('Approve request error: ' . $e->getMessage()); + errorResponse('Chyba při schvalování žádosti', 500); + } +} + +/** + * POST - Reject a leave request + * + * @param array $authData + */ +function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void +{ + $input = getJsonInput(); + $requestId = (int)($input['request_id'] ?? 0); + $note = trim($input['note'] ?? ''); + + if (!$requestId) { + errorResponse('ID žádosti je povinné'); + } + + if (!$note) { + errorResponse('Důvod zamítnutí je povinný'); + } + + $stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?'); + $stmt->execute([$requestId]); + $request = $stmt->fetch(); + + if (!$request) { + errorResponse('Žádost nebyla nalezena'); + } + + if ($request['status'] !== 'pending') { + errorResponse('Lze zamítnout pouze čekající žádosti'); + } + + if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) { + errorResponse('Nemůžete zamítnout vlastní žádost', 403); + } + + $stmt = $pdo->prepare(" + UPDATE leave_requests + SET status = 'rejected', reviewer_id = ?, reviewer_note = ?, reviewed_at = NOW() + WHERE id = ? + "); + $stmt->execute([$reviewerId, $note, $requestId]); + + AuditLog::logUpdate( + 'leave_request', + $requestId, + ['status' => 'pending'], + ['status' => 'rejected', 'reviewer_id' => $reviewerId, 'reviewer_note' => $note], + "Žádost o nepřítomnost zamítnuta: $note" + ); + + successResponse(null, 'Žádost byla zamítnuta'); +} diff --git a/api/admin/handlers/offers-handlers.php b/api/admin/handlers/offers-handlers.php new file mode 100644 index 0000000..703f012 --- /dev/null +++ b/api/admin/handlers/offers-handlers.php @@ -0,0 +1,588 @@ + 'q.created_at', + 'CreatedAt' => 'q.created_at', + 'created_at' => 'q.created_at', + 'QuotationNumber' => 'q.quotation_number', + 'quotation_number' => 'q.quotation_number', + 'ProjectCode' => 'q.project_code', + 'project_code' => 'q.project_code', + 'ValidUntil' => 'q.valid_until', + 'valid_until' => 'q.valid_until', + 'Currency' => 'q.currency', + 'currency' => 'q.currency', + ]; + if (!isset($sortMap[$sort])) { + errorResponse('Neplatný parametr řazení', 400); + } + $sortCol = $sortMap[$sort]; + + $where = 'WHERE 1=1'; + $params = []; + + if ($search) { + $search = mb_substr($search, 0, 100); + $where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; + $searchParam = "%{$search}%"; + $params = [$searchParam, $searchParam, $searchParam]; + } + + // Celkovy pocet pro pagination + $countSql = " + SELECT COUNT(*) + FROM quotations q + LEFT JOIN customers c ON q.customer_id = c.id + $where + "; + $stmt = $pdo->prepare($countSql); + $stmt->execute($params); + $total = (int) $stmt->fetchColumn(); + + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until, + q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate, + q.customer_id, q.order_id, q.status, + c.name as customer_name, + (SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0) + FROM quotation_items qi WHERE qi.quotation_id = q.id) as total + FROM quotations q + LEFT JOIN customers c ON q.customer_id = c.id + $where + ORDER BY $sortCol $order + LIMIT $perPage OFFSET $offset + "; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $quotations = $stmt->fetchAll(); + + successResponse([ + 'quotations' => $quotations, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]); +} + +function handleGetDetail(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare(' + SELECT q.*, c.name as customer_name + FROM quotations q + LEFT JOIN customers c ON q.customer_id = c.id + WHERE q.id = ? + '); + $stmt->execute([$id]); + $quotation = $stmt->fetch(); + + if (!$quotation) { + errorResponse('Nabídka nebyla nalezena', 404); + } + + // Get items + $stmt = $pdo->prepare(' + SELECT * FROM quotation_items + WHERE quotation_id = ? + ORDER BY position + '); + $stmt->execute([$id]); + $quotation['items'] = $stmt->fetchAll(); + + // Get scope sections + $stmt = $pdo->prepare(' + SELECT * FROM scope_sections + WHERE quotation_id = ? + ORDER BY position + '); + $stmt->execute([$id]); + $quotation['sections'] = $stmt->fetchAll(); + + // Get customer + if ($quotation['customer_id']) { + $stmt = $pdo->prepare( + 'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields + FROM customers WHERE id = ?' + ); + $stmt->execute([$quotation['customer_id']]); + $quotation['customer'] = $stmt->fetch(); + } + + // Get linked order info + if ($quotation['order_id']) { + $stmt = $pdo->prepare('SELECT id, order_number, status FROM orders WHERE id = ?'); + $stmt->execute([$quotation['order_id']]); + $quotation['order'] = $stmt->fetch() ?: null; + } else { + $quotation['order'] = null; + } + + successResponse($quotation); +} + +function handleGetNextNumber(PDO $pdo): void +{ + $settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch(); + if (!$settings) { + errorResponse('Nastavení firmy nenalezeno'); + } + + $year = date('Y'); + $prefix = $settings['quotation_prefix'] ?: 'N'; + $number = getMaxQuotationNumber($pdo, $year, $prefix) + 1; + + $formatted = sprintf('%s/%s/%03d', $year, $prefix, $number); + + successResponse([ + 'number' => $formatted, + 'raw_number' => $number, + 'prefix' => $prefix, + 'year' => $year, + ]); +} + +function getMaxQuotationNumber(PDO $pdo, string $year, string $prefix): int +{ + $likePattern = "{$year}/{$prefix}/%"; + $stmt = $pdo->prepare(" + SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) + FROM quotations + WHERE quotation_number LIKE ? + "); + $stmt->execute([$likePattern]); + return (int) $stmt->fetchColumn(); +} + +function generateNextNumber(PDO $pdo): string +{ + $settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch(); + + $year = date('Y'); + $prefix = $settings['quotation_prefix'] ?: 'N'; + $number = getMaxQuotationNumber($pdo, $year, $prefix) + 1; + + return sprintf('%s/%s/%03d', $year, $prefix, $number); +} + +/** @param array $q */ +function validateQuotationInput(array $q): void +{ + if (empty($q['customer_id'])) { + errorResponse('Vyberte zákazníka'); + } + if (empty($q['created_at'])) { + errorResponse('Zadejte datum vytvoření'); + } + if (empty($q['valid_until'])) { + errorResponse('Zadejte datum platnosti'); + } + if (!empty($q['created_at']) && !empty($q['valid_until']) && $q['valid_until'] < $q['created_at']) { + errorResponse('Datum platnosti nesmí být před datem vytvoření'); + } + if (empty($q['currency'])) { + errorResponse('Vyberte měnu'); + } + + // Validace formatu dat + foreach (['created_at', 'valid_until'] as $dateField) { + if (!empty($q[$dateField]) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $q[$dateField])) { + errorResponse("Neplatný formát data: $dateField"); + } + } + // Validace meny a jazyka + if (!in_array($q['currency'] ?? '', ['EUR', 'USD', 'CZK', 'GBP'])) { + errorResponse('Neplatná měna'); + } + if (!empty($q['language']) && !in_array($q['language'], ['EN', 'CZ'])) { + errorResponse('Neplatný jazyk'); + } + // Validace DPH + if (isset($q['vat_rate'])) { + $rate = floatval($q['vat_rate']); + if ($rate < 0 || $rate > 100) { + errorResponse('Sazba DPH musí být mezi 0 a 100'); + } + } + // Delkove limity + if (!empty($q['project_code']) && mb_strlen($q['project_code']) > 100) { + errorResponse('Kód projektu je příliš dlouhý (max 100 znaků)'); + } +} + +function handleCreateOffer(PDO $pdo): void +{ + $input = getJsonInput(); + $quotation = $input['quotation'] ?? $input; + $items = $input['items'] ?? []; + $sections = $input['sections'] ?? []; + + validateQuotationInput($quotation); + + // Serialize number generation across concurrent requests + $locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn(); + if (!$locked) { + errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503); + } + + $pdo->beginTransaction(); + try { + $quotationNumber = generateNextNumber($pdo); + + $stmt = $pdo->prepare(' + INSERT INTO quotations ( + quotation_number, project_code, customer_id, created_at, valid_until, + currency, language, vat_rate, apply_vat, exchange_rate, + scope_title, scope_description, modified_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + '); + + $stmt->execute([ + $quotationNumber, + $quotation['project_code'] ?? '', + $quotation['customer_id'] ? (int)$quotation['customer_id'] : null, + $quotation['created_at'] ?? date('Y-m-d H:i:s'), + $quotation['valid_until'] ?? date('Y-m-d H:i:s', strtotime('+30 days')), + $quotation['currency'] ?? 'EUR', + $quotation['language'] ?? 'EN', + $quotation['vat_rate'] ?? 21, + isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : 0, + $quotation['exchange_rate'] ?? null, + $quotation['scope_title'] ?? '', + $quotation['scope_description'] ?? '', + ]); + + $quotationId = (int)$pdo->lastInsertId(); + + saveItems($pdo, $quotationId, $items); + saveSections($pdo, $quotationId, $sections); + + + $pdo->commit(); + $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); + + AuditLog::logCreate('offers_quotation', $quotationId, [ + 'quotation_number' => $quotationNumber, + 'project_code' => $quotation['project_code'] ?? '', + ], "Vytvořena nabídka '$quotationNumber'"); + + successResponse([ + 'id' => $quotationId, + 'number' => $quotationNumber, + ], 'Nabídka byla vytvořena'); + } catch (PDOException $e) { + $pdo->rollBack(); + $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); + throw $e; + } +} + +function handleUpdateOffer(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); + $stmt->execute([$id]); + $existing = $stmt->fetch(); + + if (!$existing) { + errorResponse('Nabídka nebyla nalezena', 404); + } + + if ($existing['status'] === 'invalidated') { + errorResponse('Zneplatněnou nabídku nelze upravovat', 403); + } + + $input = getJsonInput(); + $quotation = $input['quotation'] ?? $input; + $items = $input['items'] ?? []; + $sections = $input['sections'] ?? []; + + validateQuotationInput($quotation); + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare(' + UPDATE quotations SET + project_code = ?, + customer_id = ?, + created_at = ?, + valid_until = ?, + currency = ?, + language = ?, + vat_rate = ?, + apply_vat = ?, + exchange_rate = ?, + scope_title = ?, + scope_description = ?, + modified_at = NOW() + WHERE id = ? + '); + + $stmt->execute([ + $quotation['project_code'] ?? $existing['project_code'], + isset($quotation['customer_id']) + ? ($quotation['customer_id'] ? (int)$quotation['customer_id'] : null) + : $existing['customer_id'], + $quotation['created_at'] ?? $existing['created_at'], + $quotation['valid_until'] ?? $existing['valid_until'], + $quotation['currency'] ?? $existing['currency'], + $quotation['language'] ?? $existing['language'], + $quotation['vat_rate'] ?? $existing['vat_rate'], + isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : $existing['apply_vat'], + array_key_exists('exchange_rate', $quotation) ? $quotation['exchange_rate'] : $existing['exchange_rate'], + $quotation['scope_title'] ?? $existing['scope_title'], + $quotation['scope_description'] ?? $existing['scope_description'], + $id, + ]); + + // Replace items + $stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?'); + $stmt->execute([$id]); + saveItems($pdo, $id, $items); + + // Replace sections + $stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?'); + $stmt->execute([$id]); + saveSections($pdo, $id, $sections); + + + $pdo->commit(); + + AuditLog::logUpdate( + 'offers_quotation', + $id, + ['quotation_number' => $existing['quotation_number']], + ['project_code' => $quotation['project_code'] ?? $existing['project_code']], + "Upravena nabídka '{$existing['quotation_number']}'" + ); + + successResponse(null, 'Nabídka byla aktualizována'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleDuplicate(PDO $pdo, int $sourceId): void +{ + $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); + $stmt->execute([$sourceId]); + $source = $stmt->fetch(); + + if (!$source) { + errorResponse('Zdrojová nabídka nebyla nalezena', 404); + } + + $stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); + $stmt->execute([$sourceId]); + $sourceItems = $stmt->fetchAll(); + + $stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); + $stmt->execute([$sourceId]); + $sourceSections = $stmt->fetchAll(); + + $locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn(); + if (!$locked) { + errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503); + } + + $pdo->beginTransaction(); + try { + $newNumber = generateNextNumber($pdo); + + $stmt = $pdo->prepare(' + INSERT INTO quotations ( + quotation_number, project_code, customer_id, created_at, valid_until, + currency, language, vat_rate, apply_vat, exchange_rate, + scope_title, scope_description, modified_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + '); + + $stmt->execute([ + $newNumber, + $source['project_code'], + $source['customer_id'], + date('Y-m-d H:i:s'), + date('Y-m-d H:i:s', strtotime('+30 days')), + $source['currency'], + $source['language'], + $source['vat_rate'], + $source['apply_vat'], + $source['exchange_rate'], + $source['scope_title'], + $source['scope_description'], + ]); + + $newId = (int)$pdo->lastInsertId(); + + $items = array_map(function ($item) { + return [ + 'description' => $item['description'], + 'item_description' => $item['item_description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'is_included_in_total' => $item['is_included_in_total'], + 'position' => $item['position'], + ]; + }, $sourceItems); + saveItems($pdo, $newId, $items); + + $sections = array_map(function ($section) { + return [ + 'title' => $section['title'], + 'title_cz' => $section['title_cz'], + 'content' => $section['content'], + 'position' => $section['position'], + ]; + }, $sourceSections); + saveSections($pdo, $newId, $sections); + + $pdo->commit(); + $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); + + AuditLog::logCreate('offers_quotation', $newId, [ + 'quotation_number' => $newNumber, + 'duplicated_from' => $source['quotation_number'], + ], "Duplikována nabídka '{$source['quotation_number']}' jako '$newNumber'"); + + successResponse([ + 'id' => $newId, + 'number' => $newNumber, + ], 'Nabídka byla duplikována'); + } catch (PDOException $e) { + $pdo->rollBack(); + $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); + throw $e; + } +} + +function handleInvalidateOffer(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT quotation_number, status, order_id FROM quotations WHERE id = ?'); + $stmt->execute([$id]); + $quotation = $stmt->fetch(); + + if (!$quotation) { + errorResponse('Nabídka nebyla nalezena', 404); + } + + if ($quotation['status'] === 'invalidated') { + errorResponse('Nabídka je již zneplatněna', 400); + } + + if ($quotation['order_id']) { + errorResponse('Nabídku s objednávkou nelze zneplatnit', 400); + } + + $stmt = $pdo->prepare('UPDATE quotations SET status = ?, modified_at = NOW() WHERE id = ?'); + $stmt->execute(['invalidated', $id]); + + AuditLog::logUpdate( + 'offers_quotation', + $id, + ['status' => 'active'], + ['status' => 'invalidated'], + "Zneplatněna nabídka '{$quotation['quotation_number']}'" + ); + + successResponse(null, 'Nabídka byla zneplatněna'); +} + +function handleDeleteQuotation(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT quotation_number FROM quotations WHERE id = ?'); + $stmt->execute([$id]); + $quotation = $stmt->fetch(); + + + if (!$quotation) { + errorResponse('Nabídka nebyla nalezena', 404); + } + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?'); + $stmt->execute([$id]); + + $stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?'); + $stmt->execute([$id]); + + $stmt = $pdo->prepare('DELETE FROM quotations WHERE id = ?'); + $stmt->execute([$id]); + + $pdo->commit(); + + AuditLog::logDelete('offers_quotation', $id, [ + 'quotation_number' => $quotation['quotation_number'], + ], "Smazána nabídka '{$quotation['quotation_number']}'"); + + successResponse(null, 'Nabídka byla smazána'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +// --- Helpers --- + +/** @param list> $items */ +function saveItems(PDO $pdo, int $quotationId, array $items): void +{ + if (empty($items)) { + return; + } + + $stmt = $pdo->prepare(' + INSERT INTO quotation_items ( + quotation_id, description, item_description, quantity, unit, + unit_price, is_included_in_total, position, modified_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + '); + + foreach ($items as $i => $item) { + $stmt->execute([ + $quotationId, + $item['description'] ?? '', + $item['item_description'] ?? '', + $item['quantity'] ?? 1, + $item['unit'] ?? '', + $item['unit_price'] ?? 0, + isset($item['is_included_in_total']) ? ($item['is_included_in_total'] ? 1 : 0) : 1, + $item['position'] ?? ($i + 1), + ]); + } +} + +/** @param list> $sections */ +function saveSections(PDO $pdo, int $quotationId, array $sections): void +{ + if (empty($sections)) { + return; + } + + $stmt = $pdo->prepare(' + INSERT INTO scope_sections ( + quotation_id, title, title_cz, content, position, modified_at + ) VALUES (?, ?, ?, ?, ?, NOW()) + '); + + foreach ($sections as $i => $section) { + $stmt->execute([ + $quotationId, + $section['title'] ?? '', + $section['title_cz'] ?? '', + $section['content'] ?? '', + $section['position'] ?? ($i + 1), + ]); + } +} diff --git a/api/admin/handlers/offers-templates-handlers.php b/api/admin/handlers/offers-templates-handlers.php new file mode 100644 index 0000000..d97fec4 --- /dev/null +++ b/api/admin/handlers/offers-templates-handlers.php @@ -0,0 +1,263 @@ +query('SELECT * FROM item_templates ORDER BY category, name'); + successResponse(['templates' => $stmt->fetchAll()]); +} + +function handleSaveItemTemplate(PDO $pdo): void +{ + $input = getJsonInput(); + + if (empty($input['name'])) { + errorResponse('Název šablony je povinný'); + } + + $id = isset($input['id']) ? (int)$input['id'] : null; + + if ($id) { + // Update + $stmt = $pdo->prepare(' + UPDATE item_templates SET + name = ?, description = ?, default_price = ?, category = ?, + modified_at = NOW(), sync_version = sync_version + 1 + WHERE id = ? + '); + $stmt->execute([ + $input['name'], + $input['description'] ?? '', + $input['default_price'] ?? 0, + $input['category'] ?? '', + $id, + ]); + successResponse(null, 'Šablona byla aktualizována'); + } else { + // Create + $uuid = sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0x0fff) | 0x4000, + random_int(0, 0x3fff) | 0x8000, + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff) + ); + + $stmt = $pdo->prepare(' + INSERT INTO item_templates (name, description, default_price, category, uuid, modified_at, sync_version) + VALUES (?, ?, ?, ?, ?, NOW(), 1) + '); + $stmt->execute([ + $input['name'], + $input['description'] ?? '', + $input['default_price'] ?? 0, + $input['category'] ?? '', + $uuid, + ]); + $newId = (int)$pdo->lastInsertId(); + + AuditLog::logCreate( + 'offers_item_template', + (int)$newId, + ['name' => $input['name']], + "Vytvořena šablona položky '{$input['name']}'" + ); + + successResponse(['id' => $newId], 'Šablona byla vytvořena'); + } +} + +function handleDeleteItemTemplate(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT name FROM item_templates WHERE id = ?'); + $stmt->execute([$id]); + $template = $stmt->fetch(); + + if (!$template) { + errorResponse('Šablona nebyla nalezena', 404); + } + + $stmt = $pdo->prepare('DELETE FROM item_templates WHERE id = ?'); + $stmt->execute([$id]); + + + AuditLog::logDelete( + 'offers_item_template', + $id, + ['name' => $template['name']], + "Smazána šablona položky '{$template['name']}'" + ); + + successResponse(null, 'Šablona byla smazána'); +} + +// --- Scope Templates --- + +function handleGetScopeTemplates(PDO $pdo): void +{ + $stmt = $pdo->query('SELECT * FROM scope_templates ORDER BY name'); + successResponse(['templates' => $stmt->fetchAll()]); +} + +function handleGetScopeDetail(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM scope_templates WHERE id = ?'); + $stmt->execute([$id]); + $template = $stmt->fetch(); + + if (!$template) { + errorResponse('Šablona nebyla nalezena', 404); + } + + $stmt = $pdo->prepare('SELECT * FROM scope_template_sections WHERE scope_template_id = ? ORDER BY position'); + $stmt->execute([$id]); + $template['sections'] = $stmt->fetchAll(); + + successResponse($template); +} + +function handleSaveScopeTemplate(PDO $pdo): void +{ + $input = getJsonInput(); + + if (empty($input['name'])) { + errorResponse('Název šablony je povinný'); + } + + $id = isset($input['id']) ? (int)$input['id'] : null; + $sections = $input['sections'] ?? []; + + $pdo->beginTransaction(); + try { + if ($id) { + // Update template + $stmt = $pdo->prepare(' + UPDATE scope_templates SET + name = ?, + title = ?, + description = ?, + modified_at = NOW(), + sync_version = sync_version + 1 + WHERE id = ? + '); + $stmt->execute([ + $input['name'], + $input['title'] ?? '', + $input['description'] ?? '', + $id, + ]); + } else { + // Create template + $uuid = sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0x0fff) | 0x4000, + random_int(0, 0x3fff) | 0x8000, + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff) + ); + + $stmt = $pdo->prepare(' + INSERT INTO scope_templates (name, title, description, uuid, modified_at, sync_version) + VALUES (?, ?, ?, ?, NOW(), 1) + '); + $stmt->execute([ + $input['name'], + $input['title'] ?? '', + $input['description'] ?? '', + $uuid, + ]); + $id = (int)$pdo->lastInsertId(); + } + + // Delete existing sections and re-insert + $stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?'); + $stmt->execute([$id]); + + $stmt = $pdo->prepare(' + INSERT INTO scope_template_sections + (scope_template_id, title, title_cz, content, position, uuid, modified_at, sync_version) + VALUES (?, ?, ?, ?, ?, ?, NOW(), 1) + '); + + foreach ($sections as $i => $section) { + $sectionUuid = sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0x0fff) | 0x4000, + random_int(0, 0x3fff) | 0x8000, + random_int(0, 0xffff), + random_int(0, 0xffff), + random_int(0, 0xffff) + ); + $stmt->execute([ + $id, + $section['title'] ?? '', + $section['title_cz'] ?? '', + $section['content'] ?? '', + $i + 1, + $sectionUuid, + ]); + } + + $pdo->commit(); + + AuditLog::logCreate( + 'offers_scope_template', + $id, + ['name' => $input['name']], + "Uložena šablona rozsahu '{$input['name']}'" + ); + + successResponse(['id' => $id], 'Šablona rozsahu byla uložena'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleDeleteScopeTemplate(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT name FROM scope_templates WHERE id = ?'); + $stmt->execute([$id]); + $template = $stmt->fetch(); + + if (!$template) { + errorResponse('Šablona nebyla nalezena', 404); + } + + $pdo->beginTransaction(); + try { + // Delete sections + $stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?'); + $stmt->execute([$id]); + + // Delete template + $stmt = $pdo->prepare('DELETE FROM scope_templates WHERE id = ?'); + $stmt->execute([$id]); + + $pdo->commit(); + + AuditLog::logDelete( + 'offers_scope_template', + $id, + ['name' => $template['name']], + "Smazána šablona rozsahu '{$template['name']}'" + ); + + successResponse(null, 'Šablona rozsahu byla smazána'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} diff --git a/api/admin/handlers/orders-handlers.php b/api/admin/handlers/orders-handlers.php new file mode 100644 index 0000000..ad4836e --- /dev/null +++ b/api/admin/handlers/orders-handlers.php @@ -0,0 +1,530 @@ + */ +function getValidTransitions(string $currentStatus): array +{ + $map = [ + 'prijata' => ['v_realizaci', 'stornovana'], + 'v_realizaci' => ['dokoncena', 'stornovana'], + 'dokoncena' => [], + 'stornovana' => [], + ]; + return $map[$currentStatus] ?? []; +} + +// --- Number generation --- + +function generateOrderNumber(PDO $pdo): string +{ + return generateSharedNumber($pdo); +} + +// --- Handlers --- + +function handleGetList(PDO $pdo): void +{ + $search = trim($_GET['search'] ?? ''); + $sort = $_GET['sort'] ?? 'created_at'; + $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + $page = max(1, (int) ($_GET['page'] ?? 1)); + $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); + + $sortMap = [ + 'OrderNumber' => 'o.order_number', + 'order_number' => 'o.order_number', + 'CreatedAt' => 'o.created_at', + 'created_at' => 'o.created_at', + 'Status' => 'o.status', + 'status' => 'o.status', + 'Currency' => 'o.currency', + 'currency' => 'o.currency', + ]; + if (!isset($sortMap[$sort])) { + errorResponse('Neplatný parametr řazení', 400); + } + $sortCol = $sortMap[$sort]; + + $where = 'WHERE 1=1'; + $params = []; + + if ($search) { + $search = mb_substr($search, 0, 100); + $where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; + $searchParam = "%{$search}%"; + $params = [$searchParam, $searchParam, $searchParam, $searchParam]; + } + + $countSql = " + SELECT COUNT(*) + FROM orders o + LEFT JOIN quotations q ON o.quotation_id = q.id + LEFT JOIN customers c ON o.customer_id = c.id + $where + "; + $stmt = $pdo->prepare($countSql); + $stmt->execute($params); + $total = (int) $stmt->fetchColumn(); + + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency, + o.created_at, o.apply_vat, o.vat_rate, + q.quotation_number, q.project_code, + c.name as customer_name, + (SELECT COALESCE(SUM(CASE WHEN oi.is_included_in_total THEN oi.quantity * oi.unit_price ELSE 0 END), 0) + FROM order_items oi WHERE oi.order_id = o.id) as total, + (SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id, + (SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number + FROM orders o + LEFT JOIN quotations q ON o.quotation_id = q.id + LEFT JOIN customers c ON o.customer_id = c.id + $where + ORDER BY $sortCol $order + LIMIT $perPage OFFSET $offset + "; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $orders = $stmt->fetchAll(); + + successResponse([ + 'orders' => $orders, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]); +} + +function handleGetDetail(PDO $pdo, int $id): void +{ + // BLOB vynechany - stahuje se pres action=attachment + $stmt = $pdo->prepare(' + SELECT o.id, o.order_number, o.customer_order_number, o.attachment_name, + o.quotation_id, o.customer_id, o.status, o.currency, o.language, + o.vat_rate, o.apply_vat, o.exchange_rate, o.scope_title, o.scope_description, + o.notes, o.created_at, o.modified_at, + q.quotation_number, q.project_code, + c.name as customer_name + FROM orders o + LEFT JOIN quotations q ON o.quotation_id = q.id + LEFT JOIN customers c ON o.customer_id = c.id + WHERE o.id = ? + '); + $stmt->execute([$id]); + $order = $stmt->fetch(); + + if (!$order) { + errorResponse('Objednávka nebyla nalezena', 404); + } + + // Get items + $stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position'); + $stmt->execute([$id]); + $order['items'] = $stmt->fetchAll(); + + // Get sections + $stmt = $pdo->prepare('SELECT * FROM order_sections WHERE order_id = ? ORDER BY position'); + $stmt->execute([$id]); + $order['sections'] = $stmt->fetchAll(); + + // Get customer + if ($order['customer_id']) { + $stmt = $pdo->prepare( + 'SELECT id, name, company_id, vat_id, street, city, + postal_code, country, custom_fields + FROM customers WHERE id = ?' + ); + $stmt->execute([$order['customer_id']]); + $order['customer'] = $stmt->fetch(); + } + + // Get linked project + $stmt = $pdo->prepare('SELECT id, project_number, name, status FROM projects WHERE order_id = ?'); + $stmt->execute([$id]); + $order['project'] = $stmt->fetch() ?: null; + + // Get linked invoice + $stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1'); + $stmt->execute([$id]); + $order['invoice'] = $stmt->fetch() ?: null; + + // Valid transitions + $order['valid_transitions'] = getValidTransitions($order['status']); + + successResponse($order); +} + +function handleGetAttachment(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT attachment_data, attachment_name FROM orders WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + + if (!$row || !$row['attachment_data']) { + errorResponse('Příloha nebyla nalezena', 404); + } + + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->buffer($row['attachment_data']); + if ($mime !== 'application/pdf') { + errorResponse('Příloha není platný PDF soubor', 415); + } + + header_remove('Content-Type'); + header('Content-Type: application/pdf'); + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['attachment_name'] ?: 'priloha.pdf')); + header('Content-Disposition: attachment; filename="' . $safeName . '"'); + header('Content-Length: ' . strlen($row['attachment_data'])); + echo $row['attachment_data']; + exit; +} + +function handleCreateOrder(PDO $pdo): void +{ + // Podporuje JSON i FormData (kvuli nahravani prilohy) + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + if (str_contains($contentType, 'multipart/form-data')) { + $quotationId = (int)($_POST['quotationId'] ?? 0); + $customerOrderNumber = trim($_POST['customerOrderNumber'] ?? ''); + } else { + $input = getJsonInput(); + $quotationId = (int)($input['quotationId'] ?? 0); + $customerOrderNumber = trim($input['customerOrderNumber'] ?? ''); + } + + if (!$quotationId) { + errorResponse('ID nabídky je povinné'); + } + + if ($customerOrderNumber === '') { + errorResponse('Číslo objednávky zákazníka je povinné'); + } + if (mb_strlen($customerOrderNumber) > 100) { + errorResponse('Číslo objednávky zákazníka je příliš dlouhé (max 100 znaků)'); + } + + // Validace prilohy + $attachmentData = null; + $attachmentName = null; + if (!empty($_FILES['attachment']['tmp_name'])) { + $file = $_FILES['attachment']; + if ($file['error'] !== UPLOAD_ERR_OK) { + errorResponse('Chyba při nahrávání souboru'); + } + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($file['tmp_name']); + if ($mime !== 'application/pdf') { + errorResponse('Příloha musí být ve formátu PDF'); + } + if ($file['size'] > 10 * 1024 * 1024) { + errorResponse('Příloha nesmí být větší než 10 MB'); + } + $attachmentData = file_get_contents($file['tmp_name']); + $attachmentName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name'])); + } + + // Verify quotation exists and has no order yet + $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); + $stmt->execute([$quotationId]); + $quotation = $stmt->fetch(); + + if (!$quotation) { + errorResponse('Nabídka nebyla nalezena', 404); + } + + if ($quotation['order_id']) { + errorResponse('Tato nabídka již má objednávku'); + } + + // Get quotation items and sections + $stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); + $stmt->execute([$quotationId]); + $quotationItems = $stmt->fetchAll(); + + $stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); + $stmt->execute([$quotationId]); + $quotationSections = $stmt->fetchAll(); + + // Lock for concurrent number generation + $locked = $pdo->query("SELECT GET_LOCK('boha_order_number', 5)")->fetchColumn(); + if (!$locked) { + errorResponse('Nepodařilo se získat zámek pro číslo objednávky, zkuste to znovu', 503); + } + + $pdo->beginTransaction(); + try { + $orderNumber = generateOrderNumber($pdo); + + $stmt = $pdo->prepare(" + INSERT INTO orders ( + order_number, customer_order_number, attachment_data, attachment_name, + quotation_id, customer_id, status, + currency, language, vat_rate, apply_vat, exchange_rate, + scope_title, scope_description, created_at, modified_at + ) VALUES (?, ?, ?, ?, ?, ?, 'prijata', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + "); + $stmt->execute([ + $orderNumber, + $customerOrderNumber, + $attachmentData, + $attachmentName, + $quotationId, + $quotation['customer_id'], + $quotation['currency'] ?? 'EUR', + $quotation['language'] ?? 'EN', + $quotation['vat_rate'] ?? 0, + $quotation['apply_vat'] ?? 0, + $quotation['exchange_rate'], + $quotation['scope_title'] ?? '', + $quotation['scope_description'] ?? '', + ]); + $orderId = (int)$pdo->lastInsertId(); + + // Copy items + if (!empty($quotationItems)) { + $itemStmt = $pdo->prepare(' + INSERT INTO order_items ( + order_id, description, item_description, quantity, unit, + unit_price, is_included_in_total, position, modified_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + '); + foreach ($quotationItems as $item) { + $itemStmt->execute([ + $orderId, + $item['description'] ?? '', + $item['item_description'] ?? '', + $item['quantity'] ?? 1, + $item['unit'] ?? '', + $item['unit_price'] ?? 0, + $item['is_included_in_total'] ?? 1, + $item['position'] ?? 0, + ]); + } + } + + // Copy sections + if (!empty($quotationSections)) { + $sectionStmt = $pdo->prepare(' + INSERT INTO order_sections ( + order_id, title, title_cz, content, position, modified_at + ) VALUES (?, ?, ?, ?, ?, NOW()) + '); + foreach ($quotationSections as $section) { + $sectionStmt->execute([ + $orderId, + $section['title'] ?? '', + $section['title_cz'] ?? '', + $section['content'] ?? '', + $section['position'] ?? 0, + ]); + } + } + + // Create project with same number + $projectName = $quotation['project_code'] ?: ($quotation['customer_name'] ?? 'Projekt ' . $orderNumber); + // Need customer name + if (!$quotation['project_code'] && $quotation['customer_id']) { + $custStmt = $pdo->prepare('SELECT name FROM customers WHERE id = ?'); + $custStmt->execute([$quotation['customer_id']]); + $custName = $custStmt->fetchColumn(); + if ($custName) { + $projectName = $custName; + } + } + + $stmt = $pdo->prepare(" + INSERT INTO projects ( + project_number, name, customer_id, quotation_id, order_id, + status, start_date, created_at, modified_at + ) VALUES (?, ?, ?, ?, ?, 'aktivni', CURDATE(), NOW(), NOW()) + "); + $stmt->execute([ + $orderNumber, + $projectName, + $quotation['customer_id'], + $quotationId, + $orderId, + ]); + $projectId = (int)$pdo->lastInsertId(); + + // Update quotation with back-reference + $stmt = $pdo->prepare('UPDATE quotations SET order_id = ?, modified_at = NOW() WHERE id = ?'); + $stmt->execute([$orderId, $quotationId]); + + + $pdo->commit(); + $pdo->query("SELECT RELEASE_LOCK('boha_order_number')"); + + AuditLog::logCreate('orders_order', $orderId, [ + 'order_number' => $orderNumber, + 'quotation_number' => $quotation['quotation_number'], + 'project_id' => $projectId, + ], "Vytvořena objednávka '$orderNumber' z nabídky '{$quotation['quotation_number']}'"); + + successResponse([ + 'order_id' => $orderId, + 'order_number' => $orderNumber, + 'project_id' => $projectId, + 'project_number' => $orderNumber, + ], 'Objednávka byla vytvořena'); + } catch (PDOException $e) { + $pdo->rollBack(); + $pdo->query("SELECT RELEASE_LOCK('boha_order_number')"); + throw $e; + } +} + +function handleUpdateOrder(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?'); + $stmt->execute([$id]); + $order = $stmt->fetch(); + + if (!$order) { + errorResponse('Objednávka nebyla nalezena', 404); + } + + $input = getJsonInput(); + $newStatus = $input['status'] ?? null; + $notes = $input['notes'] ?? null; + $newOrderNumber = isset($input['order_number']) ? trim($input['order_number']) : null; + + // Delkove limity + if ($notes !== null && mb_strlen($notes) > 5000) { + errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); + } + if ($newOrderNumber !== null && mb_strlen($newOrderNumber) > 50) { + errorResponse('Číslo objednávky je příliš dlouhé (max 50 znaků)'); + } + + // Validate status transition + if ($newStatus && $newStatus !== $order['status']) { + $valid = getValidTransitions($order['status']); + if (!in_array($newStatus, $valid)) { + errorResponse("Neplatný přechod stavu z '{$order['status']}' na '$newStatus'"); + } + } + + // Validate order number uniqueness + if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) { + if (empty($newOrderNumber)) { + errorResponse('Číslo objednávky nesmí být prázdné'); + } + $stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ? AND id != ?'); + $stmt->execute([$newOrderNumber, $id]); + if ($stmt->fetch()) { + errorResponse('Toto číslo objednávky již existuje'); + } + } + + $pdo->beginTransaction(); + try { + $updates = []; + $params = []; + + if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) { + $updates[] = 'order_number = ?'; + $params[] = $newOrderNumber; + + // Sync project number + $stmt = $pdo->prepare('UPDATE projects SET project_number = ?, modified_at = NOW() WHERE order_id = ?'); + $stmt->execute([$newOrderNumber, $id]); + } + if ($newStatus !== null) { + $updates[] = 'status = ?'; + $params[] = $newStatus; + } + if ($notes !== null) { + $updates[] = 'notes = ?'; + $params[] = $notes; + } + + if (!empty($updates)) { + $updates[] = 'modified_at = NOW()'; + $params[] = $id; + $sql = 'UPDATE orders SET ' . implode(', ', $updates) . ' WHERE id = ?'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + } + + // Sync project status with order status + if ($newStatus && $newStatus !== $order['status']) { + $projectStatus = null; + if ($newStatus === 'stornovana') { + $projectStatus = 'zruseny'; + } elseif ($newStatus === 'dokoncena') { + $projectStatus = 'dokonceny'; + } elseif ($newStatus === 'v_realizaci') { + $projectStatus = 'aktivni'; + } + + if ($projectStatus) { + $stmt = $pdo->prepare('UPDATE projects SET status = ?, modified_at = NOW() WHERE order_id = ?'); + $stmt->execute([$projectStatus, $id]); + } + } + + $pdo->commit(); + + AuditLog::logUpdate( + 'orders_order', + $id, + ['status' => $order['status'], 'notes' => $order['notes']], + ['status' => $newStatus ?? $order['status'], 'notes' => $notes ?? $order['notes']], + "Upravena objednávka '{$order['order_number']}'" + ); + + successResponse(null, 'Objednávka byla aktualizována'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleDeleteOrder(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?'); + $stmt->execute([$id]); + $order = $stmt->fetch(); + + if (!$order) { + errorResponse('Objednávka nebyla nalezena', 404); + } + + $pdo->beginTransaction(); + try { + // Delete project linked to this order + $stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?'); + $stmt->execute([$id]); + + // Delete order items and sections + $stmt = $pdo->prepare('DELETE FROM order_items WHERE order_id = ?'); + $stmt->execute([$id]); + + $stmt = $pdo->prepare('DELETE FROM order_sections WHERE order_id = ?'); + $stmt->execute([$id]); + + // Delete order + $stmt = $pdo->prepare('DELETE FROM orders WHERE id = ?'); + $stmt->execute([$id]); + + // Remove back-reference from quotation + $stmt = $pdo->prepare('UPDATE quotations SET order_id = NULL, modified_at = NOW() WHERE order_id = ?'); + $stmt->execute([$id]); + + $pdo->commit(); + + AuditLog::logDelete('orders_order', $id, [ + 'order_number' => $order['order_number'], + 'quotation_id' => $order['quotation_id'], + ], "Smazána objednávka '{$order['order_number']}'"); + + successResponse(null, 'Objednávka byla smazána'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} diff --git a/api/admin/handlers/projects-handlers.php b/api/admin/handlers/projects-handlers.php new file mode 100644 index 0000000..05063dc --- /dev/null +++ b/api/admin/handlers/projects-handlers.php @@ -0,0 +1,423 @@ + $number]); +} + +function handleCreateProject(PDO $pdo): void +{ + $input = getJsonInput(); + + $name = trim($input['name'] ?? ''); + if (!$name) { + errorResponse('Název projektu je povinný'); + } + if (mb_strlen($name) > 255) { + errorResponse('Název projektu je příliš dlouhý (max 255 znaků)'); + } + + $customerId = isset($input['customer_id']) ? (int)$input['customer_id'] : null; + if (!$customerId) { + errorResponse('Zákazník je povinný'); + } + + // Verify customer exists + $stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); + $stmt->execute([$customerId]); + if (!$stmt->fetch()) { + errorResponse('Zákazník nebyl nalezen', 404); + } + + $startDate = $input['start_date'] ?? date('Y-m-d'); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { + errorResponse('Neplatný formát data zahájení'); + } + + $projectNumber = trim($input['project_number'] ?? ''); + if ($projectNumber && mb_strlen($projectNumber) > 50) { + errorResponse('Číslo projektu je příliš dlouhé (max 50 znaků)'); + } + + // Lock for concurrent number generation + $locked = $pdo->query("SELECT GET_LOCK('boha_project_number', 5)")->fetchColumn(); + if (!$locked) { + errorResponse('Nepodařilo se získat zámek pro číslo projektu, zkuste to znovu', 503); + } + + $pdo->beginTransaction(); + try { + // Generate or validate number + if (!$projectNumber) { + $projectNumber = generateProjectNumber($pdo); + } else { + // Validate uniqueness against both tables + $stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ?'); + $stmt->execute([$projectNumber]); + if ($stmt->fetch()) { + $pdo->rollBack(); + $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); + errorResponse('Číslo projektu je již použito jako číslo objednávky'); + } + + $stmt = $pdo->prepare('SELECT id FROM projects WHERE project_number = ?'); + $stmt->execute([$projectNumber]); + if ($stmt->fetch()) { + $pdo->rollBack(); + $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); + errorResponse('Číslo projektu je již použito'); + } + } + + $stmt = $pdo->prepare(" + INSERT INTO projects ( + project_number, name, customer_id, + status, start_date, created_at, modified_at + ) VALUES (?, ?, ?, 'aktivni', ?, NOW(), NOW()) + "); + $stmt->execute([ + $projectNumber, + $name, + $customerId, + $startDate, + ]); + $projectId = (int)$pdo->lastInsertId(); + + + $pdo->commit(); + $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); + + AuditLog::logCreate('projects_project', $projectId, [ + 'project_number' => $projectNumber, + 'name' => $name, + 'customer_id' => $customerId, + ], "Ručně vytvořen projekt '$projectNumber'"); + + successResponse([ + 'project_id' => $projectId, + 'project_number' => $projectNumber, + ], 'Projekt byl vytvořen'); + } catch (PDOException $e) { + $pdo->rollBack(); + $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); + throw $e; + } +} + +function handleDeleteProject(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?'); + $stmt->execute([$id]); + $project = $stmt->fetch(); + + if (!$project) { + errorResponse('Projekt nebyl nalezen', 404); + } + + // Only manually created projects (without order_id) can be deleted + if (!empty($project['order_id'])) { + errorResponse('Projekt propojený s objednávkou nelze smazat. Smažte objednávku.', 400); + } + + $pdo->beginTransaction(); + try { + // Delete project notes + $stmt = $pdo->prepare('DELETE FROM project_notes WHERE project_id = ?'); + $stmt->execute([$id]); + + // Delete project + $stmt = $pdo->prepare('DELETE FROM projects WHERE id = ?'); + $stmt->execute([$id]); + + $pdo->commit(); + + AuditLog::logUpdate( + 'projects_project', + $id, + ['status' => $project['status']], + ['status' => 'deleted'], + "Smazán ruční projekt '{$project['project_number']}'" + ); + + successResponse(null, 'Projekt byl smazán'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleGetList(PDO $pdo): void +{ + $search = trim($_GET['search'] ?? ''); + $sort = $_GET['sort'] ?? 'created_at'; + $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + $page = max(1, (int) ($_GET['page'] ?? 1)); + $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); + + $sortMap = [ + 'ProjectNumber' => 'p.project_number', + 'project_number' => 'p.project_number', + 'Name' => 'p.name', + 'name' => 'p.name', + 'Status' => 'p.status', + 'status' => 'p.status', + 'StartDate' => 'p.start_date', + 'start_date' => 'p.start_date', + 'EndDate' => 'p.end_date', + 'end_date' => 'p.end_date', + 'CreatedAt' => 'p.created_at', + 'created_at' => 'p.created_at', + ]; + if (!isset($sortMap[$sort])) { + errorResponse('Neplatný parametr řazení', 400); + } + $sortCol = $sortMap[$sort]; + + $where = 'WHERE 1=1'; + $params = []; + + if ($search) { + $search = mb_substr($search, 0, 100); + $where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)'; + $searchParam = "%{$search}%"; + $params = [$searchParam, $searchParam, $searchParam]; + } + + $countSql = " + SELECT COUNT(*) + FROM projects p + LEFT JOIN customers c ON p.customer_id = c.id + LEFT JOIN orders o ON p.order_id = o.id + $where + "; + $stmt = $pdo->prepare($countSql); + $stmt->execute($params); + $total = (int) $stmt->fetchColumn(); + + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date, + p.order_id, p.quotation_id, p.created_at, + c.name as customer_name, + o.order_number + FROM projects p + LEFT JOIN customers c ON p.customer_id = c.id + LEFT JOIN orders o ON p.order_id = o.id + $where + ORDER BY $sortCol $order + LIMIT $perPage OFFSET $offset + "; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $projects = $stmt->fetchAll(); + + successResponse([ + 'projects' => $projects, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]); +} + +function handleGetDetail(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare(' + SELECT p.*, + c.name as customer_name, + o.order_number, o.status as order_status, + q.quotation_number + FROM projects p + LEFT JOIN customers c ON p.customer_id = c.id + LEFT JOIN orders o ON p.order_id = o.id + LEFT JOIN quotations q ON p.quotation_id = q.id + WHERE p.id = ? + '); + $stmt->execute([$id]); + $project = $stmt->fetch(); + + if (!$project) { + errorResponse('Projekt nebyl nalezen', 404); + } + + successResponse($project); +} + +function handleUpdateProject(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?'); + $stmt->execute([$id]); + $project = $stmt->fetch(); + + if (!$project) { + errorResponse('Projekt nebyl nalezen', 404); + } + + $input = getJsonInput(); + + // Validace statusu + if (isset($input['status'])) { + $validStatuses = ['aktivni', 'dokonceny', 'zruseny']; + if (!in_array($input['status'], $validStatuses)) { + errorResponse('Neplatný stav projektu'); + } + } + + // Validace dat + if ( + isset($input['start_date']) + && $input['start_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue + && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['start_date']) + ) { + errorResponse('Neplatný formát data zahájení'); + } + if ( + isset($input['end_date']) + && $input['end_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue + && $input['end_date'] !== '' + && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['end_date']) + ) { + errorResponse('Neplatný formát data ukončení'); + } + + // Delkove limity + $name = $input['name'] ?? $project['name']; + if (mb_strlen($name) > 255) { + errorResponse('Název projektu je příliš dlouhý (max 255 znaků)'); + } + $notes = $input['notes'] ?? $project['notes']; + if ($notes !== null && mb_strlen($notes) > 5000) { + errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); + } + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare(' + UPDATE projects SET + name = ?, + status = ?, + start_date = ?, + end_date = ?, + notes = ?, + modified_at = NOW() + WHERE id = ? + '); + $stmt->execute([ + $name, + $input['status'] ?? $project['status'], + $input['start_date'] ?? $project['start_date'], + $input['end_date'] ?? $project['end_date'], + $notes, + $id, + ]); + + $pdo->commit(); + + AuditLog::logUpdate( + 'projects_project', + $id, + ['name' => $project['name'], 'status' => $project['status']], + ['name' => $input['name'] ?? $project['name'], 'status' => $input['status'] ?? $project['status']], + "Upraven projekt '{$project['project_number']}'" + ); + + successResponse(null, 'Projekt byl aktualizován'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +function handleGetNotes(PDO $pdo, int $projectId): void +{ + // Verify project exists + $stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?'); + $stmt->execute([$projectId]); + if (!$stmt->fetch()) { + errorResponse('Projekt nebyl nalezen', 404); + } + + $stmt = $pdo->prepare(' + SELECT id, project_id, user_id, user_name, content, created_at + FROM project_notes + WHERE project_id = ? + ORDER BY created_at DESC + '); + $stmt->execute([$projectId]); + $notes = $stmt->fetchAll(); + + successResponse(['notes' => $notes]); +} + +/** @param array $authData */ +function handleAddNote(PDO $pdo, int $projectId, array $authData): void +{ + // Verify project exists + $stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?'); + $stmt->execute([$projectId]); + if (!$stmt->fetch()) { + errorResponse('Projekt nebyl nalezen', 404); + } + + $input = getJsonInput(); + $content = trim($input['content'] ?? ''); + + if (!$content) { + errorResponse('Text poznámky je povinný'); + } + + if (mb_strlen($content) > 5000) { + errorResponse('Poznámka je příliš dlouhá (max 5000 znaků)'); + } + + $userName = $authData['user']['full_name'] ?? $authData['user']['username'] ?? 'Neznámý'; + + $stmt = $pdo->prepare(' + INSERT INTO project_notes (project_id, user_id, user_name, content, created_at) + VALUES (?, ?, ?, ?, NOW()) + '); + $stmt->execute([$projectId, $authData['user_id'], $userName, $content]); + + $noteId = (int)$pdo->lastInsertId(); + + // Fetch the new note + $stmt = $pdo->prepare( + 'SELECT id, project_id, user_id, user_name, content, created_at FROM project_notes WHERE id = ?' + ); + $stmt->execute([$noteId]); + $note = $stmt->fetch(); + + successResponse(['note' => $note], 'Poznámka byla přidána'); +} + +/** @param array $authData */ +function handleDeleteNote(PDO $pdo, int $noteId, array $authData): void +{ + // Only admins can delete notes + $isAdmin = $authData['user']['is_admin'] ?? false; + if (!$isAdmin) { + errorResponse('Pouze administrátoři mohou mazat poznámky', 403); + } + + $stmt = $pdo->prepare('SELECT id, project_id, content FROM project_notes WHERE id = ?'); + $stmt->execute([$noteId]); + $note = $stmt->fetch(); + + if (!$note) { + errorResponse('Poznámka nebyla nalezena', 404); + } + + $stmt = $pdo->prepare('DELETE FROM project_notes WHERE id = ?'); + $stmt->execute([$noteId]); + + successResponse(null, 'Poznámka byla smazána'); +} diff --git a/api/admin/handlers/received-invoices-handlers.php b/api/admin/handlers/received-invoices-handlers.php new file mode 100644 index 0000000..0c36d0c --- /dev/null +++ b/api/admin/handlers/received-invoices-handlers.php @@ -0,0 +1,506 @@ + */ +function getAllowedMimes(): array +{ + return ['application/pdf', 'image/jpeg', 'image/png']; +} + +// --- Stats --- + +function handleGetStats(PDO $pdo): void +{ + $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); + $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); + + $monthStart = sprintf('%04d-%02d-01', $year, $month); + $monthEnd = date('Y-m-t', strtotime($monthStart)); + + // Celkem v měsíci (issue_date) + $stmt = $pdo->prepare(' + SELECT currency, SUM(amount) as total, SUM(vat_amount) as vat_total, COUNT(*) as cnt + FROM received_invoices + WHERE issue_date BETWEEN ? AND ? + GROUP BY currency + '); + $stmt->execute([$monthStart, $monthEnd]); + $monthRows = $stmt->fetchAll(); + + $totalAmounts = []; + $vatAmounts = []; + $czkItems = []; + $vatCzkItems = []; + $monthCount = 0; + + foreach ($monthRows as $r) { + $totalAmounts[$r['currency']] = round((float) $r['total'], 2); + $vatAmounts[$r['currency']] = round((float) $r['vat_total'], 2); + $monthCount += (int) $r['cnt']; + $czkItems[] = [ + 'amount' => round((float) $r['total'], 2), + 'currency' => $r['currency'], + 'date' => $monthStart, + ]; + $vatCzkItems[] = [ + 'amount' => round((float) $r['vat_total'], 2), + 'currency' => $r['currency'], + 'date' => $monthStart, + ]; + } + + $totalArr = []; + foreach ($totalAmounts as $cur => $amt) { + $totalArr[] = ['amount' => $amt, 'currency' => $cur]; + } + $vatArr = []; + foreach ($vatAmounts as $cur => $amt) { + $vatArr[] = ['amount' => $amt, 'currency' => $cur]; + } + + // Neuhrazeno celkově + $stmt = $pdo->prepare(' + SELECT currency, SUM(amount) as total, COUNT(*) as cnt + FROM received_invoices WHERE status = ? + GROUP BY currency + '); + $stmt->execute(['unpaid']); + $unpaidRows = $stmt->fetchAll(); + + $unpaidAmounts = []; + $unpaidCzkItems = []; + $unpaidCount = 0; + foreach ($unpaidRows as $r) { + $unpaidAmounts[] = ['amount' => round((float) $r['total'], 2), 'currency' => $r['currency']]; + $unpaidCount += (int) $r['cnt']; + $unpaidCzkItems[] = [ + 'amount' => round((float) $r['total'], 2), + 'currency' => $r['currency'], + 'date' => date('Y-m-d'), + ]; + } + + $cnb = CnbRates::getInstance(); + + successResponse([ + 'total_month' => $totalArr, + 'total_month_czk' => $cnb->sumToCzk($czkItems), + 'vat_month' => $vatArr, + 'vat_month_czk' => $cnb->sumToCzk($vatCzkItems), + 'unpaid' => $unpaidAmounts, + 'unpaid_czk' => $cnb->sumToCzk($unpaidCzkItems), + 'unpaid_count' => $unpaidCount, + 'month_count' => $monthCount, + 'month' => $month, + 'year' => $year, + ]); +} + +// --- List --- + +function handleGetList(PDO $pdo): void +{ + $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); + $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); + $search = trim($_GET['search'] ?? ''); + $sort = $_GET['sort'] ?? 'created_at'; + $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + + $sortMap = [ + 'supplier_name' => 'supplier_name', + 'invoice_number' => 'invoice_number', + 'status' => 'status', + 'issue_date' => 'issue_date', + 'due_date' => 'due_date', + 'amount' => 'amount', + 'created_at' => 'created_at', + ]; + if (!isset($sortMap[$sort])) { + errorResponse('Neplatný parametr řazení', 400); + } + $sortCol = $sortMap[$sort]; + + $where = 'WHERE month = ? AND year = ?'; + $params = [$month, $year]; + + if ($search) { + $search = mb_substr($search, 0, 100); + $where .= ' AND (supplier_name LIKE ? OR invoice_number LIKE ?)'; + $searchParam = "%{$search}%"; + $params[] = $searchParam; + $params[] = $searchParam; + } + + $sql = " + SELECT id, supplier_name, invoice_number, description, + amount, currency, vat_rate, vat_amount, + issue_date, due_date, paid_date, status, + file_name, file_mime, file_size, notes, + created_at, modified_at + FROM received_invoices + $where + ORDER BY $sortCol $order + "; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $invoices = $stmt->fetchAll(); + + successResponse(['invoices' => $invoices]); +} + +// --- Detail --- + +function handleGetDetail(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare(' + SELECT id, supplier_name, invoice_number, description, + amount, currency, vat_rate, vat_amount, + issue_date, due_date, paid_date, status, + file_name, file_mime, file_size, notes, + uploaded_by, created_at, modified_at + FROM received_invoices WHERE id = ? + '); + $stmt->execute([$id]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + errorResponse('Přijatá faktura nebyla nalezena', 404); + } + + successResponse($invoice); +} + +// --- File streaming --- + +function handleGetFile(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT file_data, file_name, file_mime, file_size FROM received_invoices WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + + if (!$row || !$row['file_data']) { + errorResponse('Soubor nebyl nalezen', 404); + } + + $safeFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['file_name'])); + header('Content-Type: ' . $row['file_mime']); + header('Content-Disposition: inline; filename="' . $safeFilename . '"'); + header('Content-Length: ' . $row['file_size']); + header_remove('X-Content-Type-Options'); + echo $row['file_data']; + exit(); +} + +// --- Bulk upload --- + +/** @param array $authData */ +function handleBulkUpload(PDO $pdo, array $authData): void +{ + $invoicesJson = $_POST['invoices'] ?? '[]'; + $invoicesMeta = json_decode($invoicesJson, true); + + if (!is_array($invoicesMeta)) { + errorResponse('Neplatná metadata'); + } + if (count($invoicesMeta) === 0) { + errorResponse('Žádné faktury k nahrání'); + } + if (count($invoicesMeta) > 20) { + errorResponse('Maximálně 20 faktur najednou'); + } + + $files = $_FILES['files'] ?? []; + $fileCount = is_array($files['tmp_name'] ?? null) ? count($files['tmp_name']) : 0; + + if ($fileCount !== count($invoicesMeta)) { + errorResponse('Počet souborů neodpovídá počtu metadat'); + } + + $allowedMimes = getAllowedMimes(); + $validCurrencies = ['CZK', 'EUR', 'USD', 'GBP']; + $validVatRates = [0, 10, 12, 15, 21]; + + $pdo->beginTransaction(); + try { + $created = []; + $stmt = $pdo->prepare(' + INSERT INTO received_invoices ( + month, year, supplier_name, invoice_number, description, + amount, currency, vat_rate, vat_amount, + issue_date, due_date, status, + file_data, file_name, file_mime, file_size, + notes, uploaded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + + for ($i = 0; $i < $fileCount; $i++) { + $meta = $invoicesMeta[$i]; + $tmpName = $files['tmp_name'][$i]; + $fileError = $files['error'][$i]; + $fileSize = $files['size'][$i]; + $fileName = $files['name'][$i]; + + if ($fileError !== UPLOAD_ERR_OK) { + errorResponse("Chyba při nahrávání souboru #" . ($i + 1)); + } + if ($fileSize > 10 * 1024 * 1024) { + errorResponse("Soubor #" . ($i + 1) . " je větší než 10 MB"); + } + + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($tmpName); + if (!in_array($mime, $allowedMimes)) { + errorResponse("Soubor #" . ($i + 1) . ": nepodporovaný formát (povoleno: PDF, JPEG, PNG)"); + } + + $supplierName = trim($meta['supplier_name'] ?? ''); + if ($supplierName === '') { + errorResponse("Faktura #" . ($i + 1) . ": dodavatel je povinný"); + } + if (mb_strlen($supplierName) > 255) { + errorResponse("Faktura #" . ($i + 1) . ": název dodavatele je příliš dlouhý"); + } + + $amount = (float) ($meta['amount'] ?? 0); + if ($amount <= 0) { + errorResponse("Faktura #" . ($i + 1) . ": částka musí být větší než 0"); + } + + $currency = trim($meta['currency'] ?? 'CZK'); + if (!in_array($currency, $validCurrencies)) { + errorResponse("Faktura #" . ($i + 1) . ": neplatná měna"); + } + + $vatRate = (float) ($meta['vat_rate'] ?? 21); + if (!in_array((int) $vatRate, $validVatRates)) { + errorResponse("Faktura #" . ($i + 1) . ": neplatná sazba DPH"); + } + + $vatAmount = round($amount * $vatRate / 100, 2); + $invoiceNumber = trim($meta['invoice_number'] ?? ''); + $description = trim($meta['description'] ?? ''); + $issueDate = trim($meta['issue_date'] ?? ''); + $dueDate = trim($meta['due_date'] ?? ''); + $notes = trim($meta['notes'] ?? ''); + + // Validace dat + if ($issueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $issueDate) || !strtotime($issueDate))) { + errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data vystavení"); + } + if ($dueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dueDate) || !strtotime($dueDate))) { + errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data splatnosti"); + } + + // Délkové limity + if (mb_strlen($invoiceNumber) > 100) { + errorResponse("Faktura #" . ($i + 1) . ": číslo faktury je příliš dlouhé"); + } + if (mb_strlen($description) > 500) { + errorResponse("Faktura #" . ($i + 1) . ": popis je příliš dlouhý"); + } + if (mb_strlen($notes) > 5000) { + errorResponse("Faktura #" . ($i + 1) . ": poznámka je příliš dlouhá"); + } + + // Určit month/year z issue_date nebo aktuální + if ($issueDate) { + $dt = new DateTime($issueDate); + $month = (int) $dt->format('n'); + $year = (int) $dt->format('Y'); + } else { + $month = (int) date('n'); + $year = (int) date('Y'); + } + + $fileData = file_get_contents($tmpName); + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName)); + + $stmt->execute([ + $month, + $year, + $supplierName, + $invoiceNumber ?: null, + $description ?: null, + $amount, + $currency, + $vatRate, + $vatAmount, + $issueDate ?: null, + $dueDate ?: null, + 'unpaid', + $fileData, + $safeName, + $mime, + $fileSize, + $notes ?: null, + $authData['user_id'], + ]); + + $created[] = (int) $pdo->lastInsertId(); + } + + $pdo->commit(); + + AuditLog::logCreate('received_invoices', $created[0], [ + 'count' => count($created), + 'ids' => $created, + ], 'Nahráno ' . count($created) . ' přijatých faktur'); + + successResponse(['ids' => $created], 'Faktury byly nahrány'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +// --- Update --- + +function handleUpdateReceivedInvoice(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT * FROM received_invoices WHERE id = ?'); + $stmt->execute([$id]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + errorResponse('Přijatá faktura nebyla nalezena', 404); + } + + $input = getJsonInput(); + + $updates = []; + $params = []; + + $stringFields = [ + 'supplier_name' => 255, + 'invoice_number' => 100, + 'description' => 500, + 'notes' => 5000, + ]; + foreach ($stringFields as $field => $maxLen) { + if (array_key_exists($field, $input)) { + $val = trim((string) $input[$field]); + if ($field === 'supplier_name' && $val === '') { + errorResponse('Dodavatel je povinný'); + } + if (mb_strlen($val) > $maxLen) { + errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)"); + } + $updates[] = "$field = ?"; + $params[] = $val ?: null; + } + } + + if (array_key_exists('amount', $input)) { + $amount = (float) $input['amount']; + if ($amount <= 0) { + errorResponse('Částka musí být větší než 0'); + } + $updates[] = 'amount = ?'; + $params[] = $amount; + } + + if (array_key_exists('currency', $input)) { + if (!in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { + errorResponse('Neplatná měna'); + } + $updates[] = 'currency = ?'; + $params[] = $input['currency']; + } + + if (array_key_exists('vat_rate', $input)) { + $vatRate = (float) $input['vat_rate']; + if (!in_array((int) $vatRate, [0, 10, 12, 15, 21])) { + errorResponse('Neplatná sazba DPH'); + } + $updates[] = 'vat_rate = ?'; + $params[] = $vatRate; + + $amount = (float) ($input['amount'] ?? $invoice['amount']); + $updates[] = 'vat_amount = ?'; + $params[] = round($amount * $vatRate / 100, 2); + } elseif (array_key_exists('amount', $input)) { + $vatRate = (float) ($input['vat_rate'] ?? $invoice['vat_rate']); + $updates[] = 'vat_amount = ?'; + $params[] = round((float) $input['amount'] * $vatRate / 100, 2); + } + + foreach (['issue_date', 'due_date'] as $dateField) { + if (array_key_exists($dateField, $input)) { + $val = trim((string) $input[$dateField]); + if ($val && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $val) || !strtotime($val))) { + errorResponse("Neplatný formát data: $dateField"); + } + $updates[] = "$dateField = ?"; + $params[] = $val ?: null; + } + } + + // Aktualizace month/year pokud se změní issue_date + if (array_key_exists('issue_date', $input) && $input['issue_date']) { + $dt = new DateTime($input['issue_date']); + $updates[] = 'month = ?'; + $params[] = (int) $dt->format('n'); + $updates[] = 'year = ?'; + $params[] = (int) $dt->format('Y'); + } + + // Změna stavu - pouze unpaid -> paid (jednosmerny prechod) + if (array_key_exists('status', $input)) { + $newStatus = $input['status']; + if (!in_array($newStatus, ['unpaid', 'paid'])) { + errorResponse('Neplatný stav'); + } + if ($invoice['status'] === 'paid' && $newStatus !== 'paid') { + errorResponse('Uhrazenou fakturu nelze vrátit do stavu neuhrazená'); + } + if ($newStatus !== $invoice['status']) { + $updates[] = 'status = ?'; + $params[] = $newStatus; + if ($newStatus === 'paid') { + $updates[] = 'paid_date = CURDATE()'; + } + } + } + + if (empty($updates)) { + errorResponse('Žádné změny k uložení'); + } + + $updates[] = 'modified_at = NOW()'; + $params[] = $id; + $sql = 'UPDATE received_invoices SET ' . implode(', ', $updates) . ' WHERE id = ?'; + $pdo->prepare($sql)->execute($params); + + AuditLog::logUpdate( + 'received_invoices', + $id, + ['status' => $invoice['status']], + ['status' => $input['status'] ?? $invoice['status']], + "Aktualizována přijatá faktura #{$id}" + ); + + successResponse(null, 'Faktura byla aktualizována'); +} + +// --- Delete --- + +function handleDeleteReceivedInvoice(PDO $pdo, int $id): void +{ + $stmt = $pdo->prepare('SELECT id, supplier_name, invoice_number FROM received_invoices WHERE id = ?'); + $stmt->execute([$id]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + errorResponse('Přijatá faktura nebyla nalezena', 404); + } + + $pdo->prepare('DELETE FROM received_invoices WHERE id = ?')->execute([$id]); + + AuditLog::logDelete('received_invoices', $id, [ + 'supplier_name' => $invoice['supplier_name'], + 'invoice_number' => $invoice['invoice_number'], + ], "Smazána přijatá faktura #{$id}"); + + successResponse(null, 'Faktura byla smazána'); +} diff --git a/api/admin/handlers/roles-handlers.php b/api/admin/handlers/roles-handlers.php new file mode 100644 index 0000000..4597c69 --- /dev/null +++ b/api/admin/handlers/roles-handlers.php @@ -0,0 +1,237 @@ +query(' + SELECT r.*, COUNT(u.id) as user_count + FROM roles r + LEFT JOIN users u ON u.role_id = r.id + GROUP BY r.id + ORDER BY r.id + '); + $roles = $stmt->fetchAll(); + + // Batch fetch all role-permission mappings in one query (was N+1) + $stmt = $pdo->query(' + SELECT rp.role_id, p.name + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + '); + $allRolePerms = $stmt->fetchAll(); + + // Group permissions by role_id + $permsByRole = []; + foreach ($allRolePerms as $rp) { + $permsByRole[$rp['role_id']][] = $rp['name']; + } + + foreach ($roles as &$role) { + $role['permissions'] = $permsByRole[$role['id']] ?? []; + $role['permission_count'] = count($role['permissions']); + } + unset($role); + + // Get all available permissions grouped by module + $stmt = $pdo->query('SELECT id, name, display_name, description FROM permissions ORDER BY id'); + $allPermissions = $stmt->fetchAll(); + + $grouped = []; + foreach ($allPermissions as $perm) { + $parts = explode('.', $perm['name'], 2); + $module = $parts[0]; + if (!isset($grouped[$module])) { + $grouped[$module] = []; + } + $grouped[$module][] = $perm; + } + + successResponse([ + 'roles' => $roles, + 'permissions' => $allPermissions, + 'permission_groups' => $grouped, + ]); +} + +/** + * POST - Create new role + */ +function handleCreateRole(PDO $pdo): void +{ + $input = getJsonInput(); + + $name = trim($input['name'] ?? ''); + $displayName = trim($input['display_name'] ?? ''); + $description = trim($input['description'] ?? ''); + $permissions = $input['permissions'] ?? []; + + if (!$name) { + errorResponse('Název role je povinný'); + } + + if (!$displayName) { + errorResponse('Zobrazovaný název je povinný'); + } + + // Validate name format (slug) + if (!preg_match('/^[a-z0-9_-]+$/', $name)) { + errorResponse('Název role může obsahovat pouze malá písmena, čísla, pomlčky a podtržítka'); + } + + // Check uniqueness + $stmt = $pdo->prepare('SELECT id FROM roles WHERE name = ?'); + $stmt->execute([$name]); + if ($stmt->fetch()) { + errorResponse('Role s tímto názvem již existuje'); + } + + $pdo->beginTransaction(); + + try { + // Create role + $stmt = $pdo->prepare(' + INSERT INTO roles (name, display_name, description) + VALUES (?, ?, ?) + '); + $stmt->execute([$name, $displayName, $description ?: null]); + $newRoleId = (int)$pdo->lastInsertId(); + + // Assign permissions + if (!empty($permissions)) { + $stmt = $pdo->prepare(' + INSERT INTO role_permissions (role_id, permission_id) + SELECT ?, id FROM permissions WHERE name = ? + '); + foreach ($permissions as $permName) { + $stmt->execute([$newRoleId, $permName]); + } + } + + $pdo->commit(); + + AuditLog::logCreate('role', $newRoleId, [ + 'name' => $name, + 'display_name' => $displayName, + 'permissions' => $permissions, + ], "Vytvořena role '$displayName'"); + + successResponse(['id' => $newRoleId], 'Role byla vytvořena'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +/** + * PUT - Update role + */ +function handleUpdateRole(PDO $pdo, int $roleId): void +{ + // Get existing role + $stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); + $stmt->execute([$roleId]); + $role = $stmt->fetch(); + + if (!$role) { + errorResponse('Role nebyla nalezena', 404); + } + + // Block editing admin role name + if ($role['name'] === 'admin') { + errorResponse('Roli administrátora nelze upravovat'); + } + + $input = getJsonInput(); + + $displayName = trim($input['display_name'] ?? $role['display_name']); + $description = trim($input['description'] ?? $role['description'] ?? ''); + $permissions = $input['permissions'] ?? null; + + if (!$displayName) { + errorResponse('Zobrazovaný název je povinný'); + } + + $pdo->beginTransaction(); + + try { + // Update role + $stmt = $pdo->prepare(' + UPDATE roles SET display_name = ?, description = ? + WHERE id = ? + '); + $stmt->execute([$displayName, $description ?: null, $roleId]); + + // Update permissions if provided + if ($permissions !== null) { + // Remove existing permissions + $stmt = $pdo->prepare('DELETE FROM role_permissions WHERE role_id = ?'); + $stmt->execute([$roleId]); + + // Add new permissions + if (!empty($permissions)) { + $stmt = $pdo->prepare(' + INSERT INTO role_permissions (role_id, permission_id) + SELECT ?, id FROM permissions WHERE name = ? + '); + foreach ($permissions as $permName) { + $stmt->execute([$roleId, $permName]); + } + } + } + + $pdo->commit(); + + AuditLog::logUpdate('role', $roleId, [ + 'display_name' => $role['display_name'], + ], [ + 'display_name' => $displayName, + 'permissions' => $permissions, + ], "Upravena role '$displayName'"); + + successResponse(null, 'Role byla aktualizována'); + } catch (PDOException $e) { + $pdo->rollBack(); + throw $e; + } +} + +/** + * DELETE - Delete role + */ +function handleDeleteRole(PDO $pdo, int $roleId): void +{ + $stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); + $stmt->execute([$roleId]); + $role = $stmt->fetch(); + + if (!$role) { + errorResponse('Role nebyla nalezena', 404); + } + + // Block deleting admin role + if ($role['name'] === 'admin') { + errorResponse('Roli administrátora nelze smazat'); + } + + // Check if role has users + $stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role_id = ?'); + $stmt->execute([$roleId]); + $userCount = $stmt->fetchColumn(); + + if ($userCount > 0) { + errorResponse("Nelze smazat roli s {$userCount} přiřazenými uživateli. Nejprve změňte roli těmto uživatelům."); + } + + // Delete role (cascade deletes role_permissions) + $stmt = $pdo->prepare('DELETE FROM roles WHERE id = ?'); + $stmt->execute([$roleId]); + + AuditLog::logDelete('role', $roleId, $role, "Smazána role '{$role['display_name']}'"); + + successResponse(null, 'Role byla smazána'); +} diff --git a/api/admin/handlers/session-handlers.php b/api/admin/handlers/session-handlers.php new file mode 100644 index 0000000..303454a --- /dev/null +++ b/api/admin/handlers/session-handlers.php @@ -0,0 +1,21 @@ + */ +function get2FAInfo(PDO $pdo, int $userId): array +{ + try { + $stmt = $pdo->prepare("SELECT totp_enabled FROM users WHERE id = ?"); + $stmt->execute([$userId]); + $row = $stmt->fetch(); + + $r2fa = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1"); + return [ + 'totp_enabled' => (bool) ($row['totp_enabled'] ?? false), + 'require_2fa' => (bool) $r2fa->fetchColumn(), + ]; + } catch (PDOException $e) { + return ['totp_enabled' => false, 'require_2fa' => false]; + } +} diff --git a/api/admin/handlers/sessions-handlers.php b/api/admin/handlers/sessions-handlers.php new file mode 100644 index 0000000..6fc7623 --- /dev/null +++ b/api/admin/handlers/sessions-handlers.php @@ -0,0 +1,180 @@ +prepare( + 'DELETE FROM refresh_tokens WHERE user_id = ? AND (expires_at < NOW()' + . ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL ' + . JWTAuth::getGracePeriod() . ' SECOND)))' + ); + $stmt->execute([$userId]); + + // Jen aktivní sessions (nereplacované) + $stmt = $pdo->prepare(' + SELECT + id, + ip_address, + user_agent, + created_at, + expires_at, + token_hash + FROM refresh_tokens + WHERE user_id = ? AND replaced_at IS NULL + ORDER BY created_at DESC + '); + $stmt->execute([$userId]); + $sessions = $stmt->fetchAll(); + + // Process sessions to add is_current flag and parse user agent + $processedSessions = array_map(function ($session) use ($currentTokenHash) { + return [ + 'id' => (int) $session['id'], + 'ip_address' => $session['ip_address'], + 'user_agent' => $session['user_agent'], + 'device_info' => parseUserAgent($session['user_agent']), + 'created_at' => $session['created_at'], + 'expires_at' => $session['expires_at'], + 'is_current' => $currentTokenHash && $session['token_hash'] === $currentTokenHash, + ]; + }, $sessions); + + successResponse([ + 'sessions' => $processedSessions, + 'total' => count($processedSessions), + ]); +} + +/** + * DELETE - Delete a specific session + */ +function handleDeleteSession(PDO $pdo, int $sessionId, int $userId, ?string $currentTokenHash): void +{ + // Verify the session belongs to the current user + $stmt = $pdo->prepare('SELECT token_hash FROM refresh_tokens WHERE id = ? AND user_id = ?'); + $stmt->execute([$sessionId, $userId]); + $session = $stmt->fetch(); + + if (!$session) { + errorResponse('Relace nebyla nalezena', 404); + } + + // Check if trying to delete current session + if ($currentTokenHash && $session['token_hash'] === $currentTokenHash) { + // Check if force parameter is set + $input = getJsonInput(); + if (!($input['force'] ?? false)) { + errorResponse('Nelze smazat aktuální relaci. Použijte tlačítko odhlášení.', 400); + } + } + + // Delete the session + $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?'); + $stmt->execute([$sessionId, $userId]); + + successResponse(null, 'Relace byla úspěšně ukončena'); +} + +/** + * DELETE - Delete all sessions except current + */ +function handleDeleteAllSessions(PDO $pdo, int $userId, ?string $currentTokenHash): void +{ + if (!$currentTokenHash) { + $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?'); + $stmt->execute([$userId]); + $deleted = $stmt->rowCount(); + } else { + // Ponechat aktuální session, smazat ostatní (včetně replaced) + $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND token_hash != ?'); + $stmt->execute([$userId, $currentTokenHash]); + $deleted = $stmt->rowCount(); + } + + successResponse([ + 'deleted' => $deleted, + ], $deleted > 0 ? 'Ostatní relace byly úspěšně ukončeny' : 'Žádné další relace k ukončení'); +} + +/** + * Parse user agent string to extract device/browser info + * + * @return array{browser: string, os: string} + */ +function parseUserAgent(?string $userAgent): array +{ + if (empty($userAgent)) { + return [ + 'browser' => 'Neznámý prohlížeč', + 'os' => 'Neznámý systém', + 'device' => 'Neznámé zařízení', + 'icon' => 'device', + ]; + } + + $browser = 'Neznámý prohlížeč'; + $os = 'Neznámý systém'; + $device = 'desktop'; + $icon = 'desktop'; + + // Detect browser + if (preg_match('/Edg(e|A|iOS)?\/[\d.]+/i', $userAgent)) { + $browser = 'Microsoft Edge'; + } elseif (preg_match('/OPR\/[\d.]+|Opera/i', $userAgent)) { + $browser = 'Opera'; + } elseif (preg_match('/Chrome\/[\d.]+/i', $userAgent) && !preg_match('/Chromium/i', $userAgent)) { + $browser = 'Google Chrome'; + } elseif (preg_match('/Firefox\/[\d.]+/i', $userAgent)) { + $browser = 'Mozilla Firefox'; + } elseif (preg_match('/Safari\/[\d.]+/i', $userAgent) && !preg_match('/Chrome/i', $userAgent)) { + $browser = 'Safari'; + } elseif (preg_match('/MSIE|Trident/i', $userAgent)) { + $browser = 'Internet Explorer'; + } + + // Detect OS + if (preg_match('/Windows NT 10/i', $userAgent)) { + $os = 'Windows 10/11'; + } elseif (preg_match('/Windows NT 6\.3/i', $userAgent)) { + $os = 'Windows 8.1'; + } elseif (preg_match('/Windows NT 6\.2/i', $userAgent)) { + $os = 'Windows 8'; + } elseif (preg_match('/Windows NT 6\.1/i', $userAgent)) { + $os = 'Windows 7'; + } elseif (preg_match('/Windows/i', $userAgent)) { + $os = 'Windows'; + } elseif (preg_match('/Macintosh|Mac OS X/i', $userAgent)) { + $os = 'macOS'; + } elseif (preg_match('/Linux/i', $userAgent) && !preg_match('/Android/i', $userAgent)) { + $os = 'Linux'; + } elseif (preg_match('/iPhone/i', $userAgent)) { + $os = 'iOS'; + $device = 'mobile'; + $icon = 'smartphone'; + } elseif (preg_match('/iPad/i', $userAgent)) { + $os = 'iPadOS'; + $device = 'tablet'; + $icon = 'tablet'; + } elseif (preg_match('/Android/i', $userAgent)) { + $os = 'Android'; + if (preg_match('/Mobile/i', $userAgent)) { + $device = 'mobile'; + $icon = 'smartphone'; + } else { + $device = 'tablet'; + $icon = 'tablet'; + } + } + + return [ + 'browser' => $browser, + 'os' => $os, + 'device' => $device, + 'icon' => $icon, + ]; +} diff --git a/api/admin/handlers/totp-handlers.php b/api/admin/handlers/totp-handlers.php new file mode 100644 index 0000000..b6dfc6c --- /dev/null +++ b/api/admin/handlers/totp-handlers.php @@ -0,0 +1,421 @@ +prepare('SELECT totp_enabled FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + successResponse([ + 'totp_enabled' => (bool) ($user['totp_enabled'] ?? false), + ]); +} + +/** POST ?action=setup - vygenerovat secret + QR URI (jeste neaktivuje 2FA) */ +function handleSetup(PDO $pdo, TwoFactorAuth $tfa): void +{ + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + errorResponse('Metoda není povolena', 405); + } + + $authData = JWTAuth::requireAuth(); + AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); + $userId = $authData['user_id']; + + $stmt = $pdo->prepare('SELECT totp_enabled, username, email FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if ($user['totp_enabled']) { + errorResponse('2FA je již aktivní. Nejdříve ji deaktivujte.'); + } + + $secret = $tfa->createSecret(); + + $stmt = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?'); + $stmt->execute([Encryption::encrypt($secret), $userId]); + + $label = $user['email'] ?: $user['username']; + $qrUri = $tfa->getQRText($label, $secret); + + successResponse([ + 'secret' => $secret, + 'qr_uri' => $qrUri, + ]); +} + +/** POST ?action=enable { "code": "123456" } */ +function handleEnable(PDO $pdo, TwoFactorAuth $tfa): void +{ + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + errorResponse('Metoda není povolena', 405); + } + + $authData = JWTAuth::requireAuth(); + AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); + $userId = $authData['user_id']; + $input = getJsonInput(); + $code = trim($input['code'] ?? ''); + + if (empty($code)) { + errorResponse('Ověřovací kód je povinný'); + } + + $stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user['totp_secret']) { + errorResponse('Nejprve vygenerujte tajný klíč (setup)'); + } + + if ($user['totp_enabled']) { + errorResponse('2FA je již aktivní'); + } + + $decryptedSecret = decryptTotpSecret($user['totp_secret']); + if (!$tfa->verifyCode($decryptedSecret, $code)) { + errorResponse('Neplatný ověřovací kód. Zkontrolujte čas na telefonu.'); + } + + $backupCodes = generateBackupCodes(); + $hashedCodes = array_map(fn ($c) => password_hash($c, PASSWORD_BCRYPT, ['cost' => 10]), $backupCodes); + + $stmt = $pdo->prepare('UPDATE users SET totp_enabled = 1, totp_backup_codes = ? WHERE id = ?'); + $stmt->execute([json_encode($hashedCodes), $userId]); + + AuditLog::logUpdate('user', $userId, ['totp_enabled' => 0], ['totp_enabled' => 1], 'Uživatel aktivoval 2FA'); + + successResponse([ + 'backup_codes' => $backupCodes, + ], '2FA bylo úspěšně aktivováno'); +} + +/** POST ?action=disable { "code": "123456" } */ +function handleDisable(PDO $pdo, TwoFactorAuth $tfa): void +{ + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + errorResponse('Metoda není povolena', 405); + } + + $authData = JWTAuth::requireAuth(); + AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); + $userId = $authData['user_id']; + $input = getJsonInput(); + $code = trim($input['code'] ?? ''); + + if (empty($code)) { + errorResponse('Ověřovací kód je povinný'); + } + + $stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user['totp_enabled']) { + errorResponse('2FA není aktivní'); + } + + $decryptedSecret = decryptTotpSecret($user['totp_secret']); + if (!$tfa->verifyCode($decryptedSecret, $code)) { + errorResponse('Neplatný ověřovací kód'); + } + $stmt = $pdo->prepare( + 'UPDATE users SET totp_enabled = 0, totp_secret = NULL, + totp_backup_codes = NULL WHERE id = ?' + ); + $stmt->execute([$userId]); + + AuditLog::logUpdate('user', $userId, ['totp_enabled' => 1], ['totp_enabled' => 0], 'Uživatel deaktivoval 2FA'); + + successResponse(null, '2FA bylo deaktivováno'); +} + +/** + * POST ?action=verify - overeni TOTP kodu pri loginu (pre-auth) + * Body: { "login_token": "...", "code": "123456", "remember": false } + */ +function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void +{ + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + errorResponse('Metoda není povolena', 405); + } + + $rateLimiter = new RateLimiter(); + $rateLimiter->setFailClosed(); + $rateLimiter->enforce('totp_2fa', 5); + + $input = getJsonInput(); + $loginToken = $input['login_token'] ?? ''; + $code = trim($input['code'] ?? ''); + $remember = (bool) ($input['remember'] ?? false); + + if (empty($loginToken) || empty($code)) { + errorResponse('Přihlašovací token a ověřovací kód jsou povinné'); + } + + $tokenData = verifyLoginToken($pdo, $loginToken); + if (!$tokenData) { + errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401); + } + + $userId = $tokenData['user_id']; + + $stmt = $pdo->prepare(' + SELECT u.*, r.name as role_name, r.display_name as role_display_name + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + WHERE u.id = ? AND u.totp_enabled = 1 + '); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user) { + errorResponse('Uživatel nenalezen nebo 2FA není aktivní', 401); + } + + $decryptedSecret = decryptTotpSecret($user['totp_secret']); + if (!$tfa->verifyCode($decryptedSecret, $code, 1)) { + errorResponse('Neplatný ověřovací kód'); + } + + deleteLoginToken($pdo, $loginToken); + completeLogin($pdo, $user, $remember); +} + +/** POST ?action=backup_verify { "login_token": "...", "code": "XXXXXXXX", "remember": false } */ +function handleBackupVerify(PDO $pdo): void +{ + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + errorResponse('Metoda není povolena', 405); + } + + $rateLimiter = new RateLimiter(); + $rateLimiter->setFailClosed(); + $rateLimiter->enforce('totp_2fa', 5); + + $input = getJsonInput(); + $loginToken = $input['login_token'] ?? ''; + $code = strtoupper(trim($input['code'] ?? '')); + $remember = (bool) ($input['remember'] ?? false); + + if (empty($loginToken) || empty($code)) { + errorResponse('Přihlašovací token a záložní kód jsou povinné'); + } + + $tokenData = verifyLoginToken($pdo, $loginToken); + if (!$tokenData) { + errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401); + } + + $userId = $tokenData['user_id']; + + $stmt = $pdo->prepare(' + SELECT u.*, r.name as role_name, r.display_name as role_display_name + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + WHERE u.id = ? AND u.totp_enabled = 1 + '); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user || !$user['totp_backup_codes']) { + errorResponse('Uživatel nenalezen nebo nemá záložní kódy', 401); + } + + $hashedCodes = json_decode($user['totp_backup_codes'], true); + $matched = false; + $remainingCodes = []; + + foreach ($hashedCodes as $hashed) { + if (!$matched && password_verify($code, $hashed)) { + $matched = true; + } else { + $remainingCodes[] = $hashed; + } + } + + if (!$matched) { + errorResponse('Neplatný záložní kód'); + } + + $stmt = $pdo->prepare('UPDATE users SET totp_backup_codes = ? WHERE id = ?'); + $stmt->execute([json_encode($remainingCodes), $userId]); + + deleteLoginToken($pdo, $loginToken); + completeLogin($pdo, $user, $remember); +} + +/** GET ?action=get_required (admin only) */ +function handleGetRequired(PDO $pdo): void +{ + $authData = JWTAuth::requireAuth(); + AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); + requirePermission($authData, 'settings.security'); + + $stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1"); + + successResponse([ + 'require_2fa' => (bool) $stmt->fetchColumn(), + ]); +} + +/** POST ?action=set_required { "required": true/false } (admin only) */ +function handleSetRequired(PDO $pdo): void +{ + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + errorResponse('Metoda není povolena', 405); + } + + $authData = JWTAuth::requireAuth(); + AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); + requirePermission($authData, 'settings.security'); + + $input = getJsonInput(); + $required = (bool) ($input['required'] ?? false); + + $stmt = $pdo->prepare("UPDATE company_settings SET require_2fa = ? LIMIT 1"); + $stmt->execute([$required ? 1 : 0]); + + successResponse([ + 'require_2fa' => $required, + ], $required ? '2FA je nyní povinná pro všechny uživatele' : '2FA již není povinná'); +} + +// --- Helper functions --- + +/** Desifrovat TOTP secret z DB (zpetne kompatibilni s plaintextem pred migraci) */ +function decryptTotpSecret(string $value): string +{ + if (Encryption::isEncrypted($value)) { + return Encryption::decrypt($value); + } + return $value; +} + +/** + * Generovat 8 nahodnych backup kodu + * + * @return list + */ +function generateBackupCodes(int $count = 8): array +{ + $codes = []; + for ($i = 0; $i < $count; $i++) { + $codes[] = strtoupper(bin2hex(random_bytes(4))); // 8-char hex + } + return $codes; +} + +/** Docasny login token pro 2FA (5 min) */ +function createLoginToken(PDO $pdo, int $userId): string +{ + $token = bin2hex(random_bytes(32)); + $hashedToken = hash('sha256', $token); + $expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes + + $stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()'); + $stmt->execute([$userId]); + + $stmt = $pdo->prepare(' + INSERT INTO totp_login_tokens (user_id, token_hash, expires_at) + VALUES (?, ?, ?) + '); + $stmt->execute([$userId, $hashedToken, $expiresAt]); + + return $token; +} + +/** + * Overit login token + * + * @return array|null + */ +function verifyLoginToken(PDO $pdo, string $token): ?array +{ + $hashedToken = hash('sha256', $token); + + $stmt = $pdo->prepare(' + SELECT * FROM totp_login_tokens + WHERE token_hash = ? AND expires_at > NOW() + '); + $stmt->execute([$hashedToken]); + return $stmt->fetch() ?: null; +} + +/** Smazat login token po pouziti */ +function deleteLoginToken(PDO $pdo, string $token): void +{ + $hashedToken = hash('sha256', $token); + $stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE token_hash = ?'); + $stmt->execute([$hashedToken]); +} + +/** + * Dokoncit login po uspesnem 2FA - vydat JWT + refresh token + * + * @param array $user + */ +function completeLogin(PDO $pdo, array $user, bool $remember): void +{ + $stmt = $pdo->prepare(' + UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = NOW() + WHERE id = ? + '); + $stmt->execute([$user['id']]); + + $userData = [ + 'id' => $user['id'], + 'username' => $user['username'], + 'email' => $user['email'], + 'first_name' => $user['first_name'], + 'last_name' => $user['last_name'], + 'role' => $user['role_name'] ?? null, + 'role_display' => $user['role_display_name'] ?? $user['role_name'] ?? null, + 'is_admin' => ($user['role_name'] ?? '') === 'admin', + ]; + + $accessToken = JWTAuth::generateAccessToken($userData); + JWTAuth::generateRefreshToken($user['id'], $remember); + + AuditLog::logLogin($user['id'], $user['username']); + + $stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1"); + $require2FA = (bool) $stmt->fetchColumn(); + + successResponse([ + 'access_token' => $accessToken, + 'expires_in' => JWTAuth::getAccessTokenExpiry(), + 'user' => [ + 'id' => $userData['id'], + 'username' => $userData['username'], + 'email' => $userData['email'], + 'full_name' => trim($userData['first_name'] . ' ' . $userData['last_name']), + 'role' => $userData['role'], + 'role_display' => $userData['role_display'], + 'is_admin' => $userData['is_admin'], + 'permissions' => JWTAuth::getUserPermissions($user['id']), + 'totp_enabled' => true, + 'require_2fa' => $require2FA, + ], + ], 'Přihlášení úspěšné'); +} diff --git a/api/admin/handlers/trips-handlers.php b/api/admin/handlers/trips-handlers.php new file mode 100644 index 0000000..bb512cc --- /dev/null +++ b/api/admin/handlers/trips-handlers.php @@ -0,0 +1,660 @@ +prepare(' + SELECT COALESCE( + (SELECT MAX(end_km) FROM trips WHERE vehicle_id = ?), + (SELECT initial_km FROM vehicles WHERE id = ?), + 0 + ) as last_km + '); + $stmt->execute([$vehicleId, $vehicleId]); + $result = $stmt->fetch(); + + return $result ? (int)$result['last_km'] : 0; +} + +function formatKm(int $km): string +{ + return number_format($km, 0, ',', ' ') . ' km'; +} + +// ============================================================================ +// GET Handlers +// ============================================================================ + +/** + * GET - Current month trips (filtered to current user) + */ +function handleGetCurrent(PDO $pdo, int $userId): void +{ + $month = validateMonth(); + $vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null; + $startDate = "{$month}-01"; + $endDate = date('Y-m-t', strtotime($startDate)); + + $sql = " + SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, + CONCAT(u.first_name, ' ', u.last_name) as driver_name + FROM trips t + JOIN vehicles v ON t.vehicle_id = v.id + JOIN users u ON t.user_id = u.id + WHERE t.trip_date BETWEEN ? AND ? + AND t.user_id = ? + "; + $params = [$startDate, $endDate, $userId]; + + if ($vehicleId) { + $sql .= ' AND t.vehicle_id = ?'; + $params[] = $vehicleId; + } + + $sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC'; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $trips = $stmt->fetchAll(); + + // Get active vehicles for selection + $stmt = $pdo->query('SELECT id, spz, name, brand, model FROM vehicles WHERE is_active = 1 ORDER BY name'); + $vehicles = $stmt->fetchAll(); + + // Calculate totals + $totalDistance = 0; + $businessDistance = 0; + $privateDistance = 0; + + foreach ($trips as $trip) { + $totalDistance += $trip['distance']; + if ($trip['is_business']) { + $businessDistance += $trip['distance']; + } else { + $privateDistance += $trip['distance']; + } + } + + successResponse([ + 'trips' => $trips, + 'vehicles' => $vehicles, + 'month' => $month, + 'totals' => [ + 'total' => $totalDistance, + 'business' => $businessDistance, + 'private' => $privateDistance, + 'count' => count($trips), + ], + ]); +} + +/** + * GET - Trip history with filters (filtered to current user) + */ +function handleGetHistory(PDO $pdo, int $userId): void +{ + $month = validateMonth(); + $vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null; + + $startDate = "{$month}-01"; + $endDate = date('Y-m-t', strtotime($startDate)); + + $sql = " + SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, + CONCAT(u.first_name, ' ', u.last_name) as driver_name + FROM trips t + JOIN vehicles v ON t.vehicle_id = v.id + JOIN users u ON t.user_id = u.id + WHERE t.trip_date BETWEEN ? AND ? + AND t.user_id = ? + "; + $params = [$startDate, $endDate, $userId]; + + if ($vehicleId) { + $sql .= ' AND t.vehicle_id = ?'; + $params[] = $vehicleId; + } + + $sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC'; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $trips = $stmt->fetchAll(); + + // Get vehicles for filter + $stmt = $pdo->query('SELECT id, spz, name FROM vehicles WHERE is_active = 1 ORDER BY name'); + $vehicles = $stmt->fetchAll(); + + // Calculate totals + $totalDistance = 0; + $businessDistance = 0; + + foreach ($trips as $trip) { + $totalDistance += $trip['distance']; + if ($trip['is_business']) { + $businessDistance += $trip['distance']; + } + } + + successResponse([ + 'trips' => $trips, + 'vehicles' => $vehicles, + 'month' => $month, + 'totals' => [ + 'total' => $totalDistance, + 'business' => $businessDistance, + 'count' => count($trips), + ], + ]); +} + +/** + * GET - Admin view of all trips + */ +function handleGetAdmin(PDO $pdo): void +{ + $dateFrom = $_GET['date_from'] ?? null; + $dateTo = $_GET['date_to'] ?? null; + $vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null; + $filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null; + + // Default to current month if no dates provided + if (!$dateFrom || !$dateTo) { + $month = date('Y-m'); + $startDate = "{$month}-01"; + $endDate = date('Y-m-t', strtotime($startDate)); + } else { + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) { + errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)'); + } + $startDate = $dateFrom; + $endDate = $dateTo; + } + + $sql = " + SELECT t.*, v.spz, v.name as vehicle_name, + CONCAT(u.first_name, ' ', u.last_name) as driver_name + FROM trips t + JOIN vehicles v ON t.vehicle_id = v.id + JOIN users u ON t.user_id = u.id + WHERE t.trip_date BETWEEN ? AND ? + "; + $params = [$startDate, $endDate]; + + if ($vehicleId) { + $sql .= ' AND t.vehicle_id = ?'; + $params[] = $vehicleId; + } + + if ($filterUserId) { + $sql .= ' AND t.user_id = ?'; + $params[] = $filterUserId; + } + + $sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC'; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $trips = $stmt->fetchAll(); + + // Get vehicles for filter + $stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name'); + $vehicles = $stmt->fetchAll(); + + // Get users for filter + $stmt = $pdo->query( + "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" + ); + $users = $stmt->fetchAll(); + + // Calculate totals + $totalDistance = 0; + $businessDistance = 0; + + foreach ($trips as $trip) { + $totalDistance += $trip['distance']; + if ($trip['is_business']) { + $businessDistance += $trip['distance']; + } + } + + successResponse([ + 'trips' => $trips, + 'vehicles' => $vehicles, + 'users' => $users, + 'date_from' => $startDate, + 'date_to' => $endDate, + 'totals' => [ + 'total' => $totalDistance, + 'business' => $businessDistance, + 'count' => count($trips), + ], + ]); +} + +/** + * GET - All vehicles (admin) + */ +function handleGetVehicles(PDO $pdo): void +{ + $stmt = $pdo->query(' + SELECT v.*, COUNT(t.id) as trip_count, + COALESCE(MAX(t.end_km), v.initial_km) as current_km + FROM vehicles v + LEFT JOIN trips t ON t.vehicle_id = v.id + GROUP BY v.id + ORDER BY v.is_active DESC, v.name + '); + $vehicles = $stmt->fetchAll(); + + successResponse(['vehicles' => $vehicles]); +} + +/** + * GET - Active vehicles for selection + */ +function handleGetActiveVehicles(PDO $pdo): void +{ + $stmt = $pdo->query(' + SELECT v.id, v.spz, v.name, v.brand, v.model, + COALESCE(MAX(t.end_km), v.initial_km) as current_km + FROM vehicles v + LEFT JOIN trips t ON t.vehicle_id = v.id + WHERE v.is_active = 1 + GROUP BY v.id + ORDER BY v.name + '); + $vehicles = $stmt->fetchAll(); + + successResponse(['vehicles' => $vehicles]); +} + +/** + * GET - Last km for vehicle + */ +function handleGetLastKm(PDO $pdo): void +{ + $vehicleId = (int)($_GET['vehicle_id'] ?? 0); + if (!$vehicleId) { + errorResponse('Vehicle ID je povinné'); + } + + $lastKm = getLastKmForVehicle($pdo, $vehicleId); + successResponse(['last_km' => $lastKm]); +} + +// ============================================================================ +// POST Handlers +// ============================================================================ + +/** + * POST - Create trip + */ +function handleCreateTrip(PDO $pdo, int $userId): void +{ + $input = getJsonInput(); + + $vehicleId = (int)($input['vehicle_id'] ?? 0); + $tripDate = $input['trip_date'] ?? ''; + $startKm = (int)($input['start_km'] ?? 0); + $endKm = (int)($input['end_km'] ?? 0); + $routeFrom = trim($input['route_from'] ?? ''); + $routeTo = trim($input['route_to'] ?? ''); + $isBusiness = (int)($input['is_business'] ?? 1); + $notes = trim($input['notes'] ?? ''); + + // Validation + if (!$vehicleId) { + errorResponse('Vyberte vozidlo'); + } + if (!$tripDate) { + errorResponse('Datum jízdy je povinné'); + } + if (!$startKm) { + errorResponse('Počáteční stav km je povinný'); + } + if (!$endKm) { + errorResponse('Konečný stav km je povinný'); + } + if (!$routeFrom) { + errorResponse('Místo odjezdu je povinné'); + } + if (!$routeTo) { + errorResponse('Místo příjezdu je povinné'); + } + if ($endKm <= $startKm) { + errorResponse('Konečný stav km musí být větší než počáteční'); + } + + // Check vehicle exists + $stmt = $pdo->prepare('SELECT id FROM vehicles WHERE id = ? AND is_active = 1'); + $stmt->execute([$vehicleId]); + if (!$stmt->fetch()) { + errorResponse('Vozidlo neexistuje nebo není aktivní'); + } + + $stmt = $pdo->prepare(' + INSERT INTO trips (vehicle_id, user_id, trip_date, start_km, end_km, route_from, route_to, is_business, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $vehicleId, $userId, $tripDate, $startKm, $endKm, + $routeFrom, $routeTo, $isBusiness, $notes ?: null, + ]); + + $newId = (int)$pdo->lastInsertId(); + AuditLog::logCreate('trips', $newId, $input, 'Vytvořen záznam jízdy'); + + successResponse(['id' => $newId], 'Jízda byla zaznamenána'); +} + +/** + * POST - Create/update vehicle (admin) + */ +function handleVehicle(PDO $pdo): void +{ + $input = getJsonInput(); + + $id = (int)($input['id'] ?? 0); + $spz = strtoupper(trim($input['spz'] ?? '')); + $name = trim($input['name'] ?? ''); + $brand = trim($input['brand'] ?? ''); + $model = trim($input['model'] ?? ''); + $initialKm = (int)($input['initial_km'] ?? 0); + $isActive = isset($input['is_active']) ? (int)$input['is_active'] : 1; + + if (!$spz) { + errorResponse('SPZ je povinná'); + } + if (!$name) { + errorResponse('Název je povinný'); + } + + if ($id) { + // Update + $stmt = $pdo->prepare(' + UPDATE vehicles + SET spz = ?, name = ?, brand = ?, model = ?, initial_km = ?, is_active = ? + WHERE id = ? + '); + $stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive, $id]); + + AuditLog::logUpdate('vehicles', $id, [], $input, 'Upraveno vozidlo'); + successResponse(null, 'Vozidlo bylo aktualizováno'); + } else { + // Create + $stmt = $pdo->prepare(' + INSERT INTO vehicles (spz, name, brand, model, initial_km, is_active) + VALUES (?, ?, ?, ?, ?, ?) + '); + + try { + $stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive]); + $newId = (int)$pdo->lastInsertId(); + + AuditLog::logCreate('vehicles', $newId, $input, 'Vytvořeno vozidlo'); + successResponse(['id' => $newId], 'Vozidlo bylo vytvořeno'); + } catch (PDOException $e) { + if ($e->getCode() == 23000) { + errorResponse('Vozidlo s touto SPZ již existuje'); + } + throw $e; + } + } +} + +// ============================================================================ +// PUT Handler +// ============================================================================ + +/** + * PUT - Update trip + * + * @param array $authData + */ +function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void +{ + $stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?'); + $stmt->execute([$id]); + $trip = $stmt->fetch(); + + if (!$trip) { + errorResponse('Záznam nebyl nalezen', 404); + } + + // Check permission - own trips or trips.admin + if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) { + errorResponse('Nemáte oprávnění upravit tento záznam', 403); + } + + $input = getJsonInput(); + + $vehicleId = (int)($input['vehicle_id'] ?? $trip['vehicle_id']); + $tripDate = $input['trip_date'] ?? $trip['trip_date']; + $startKm = (int)($input['start_km'] ?? $trip['start_km']); + $endKm = (int)($input['end_km'] ?? $trip['end_km']); + $routeFrom = trim($input['route_from'] ?? $trip['route_from']); + $routeTo = trim($input['route_to'] ?? $trip['route_to']); + $isBusiness = isset($input['is_business']) ? (int)$input['is_business'] : $trip['is_business']; + $notes = trim($input['notes'] ?? $trip['notes'] ?? ''); + + if ($endKm <= $startKm) { + errorResponse('Konečný stav km musí být větší než počáteční'); + } + + $stmt = $pdo->prepare(' + UPDATE trips + SET vehicle_id = ?, trip_date = ?, start_km = ?, end_km = ?, + route_from = ?, route_to = ?, is_business = ?, notes = ? + WHERE id = ? + '); + $stmt->execute([$vehicleId, $tripDate, $startKm, $endKm, $routeFrom, $routeTo, $isBusiness, $notes ?: null, $id]); + + AuditLog::logUpdate('trips', $id, $trip, $input, 'Upraven záznam jízdy'); + + successResponse(null, 'Záznam byl aktualizován'); +} + +// ============================================================================ +// DELETE Handlers +// ============================================================================ + +/** + * DELETE - Delete trip + * + * @param array $authData + */ +function handleDeleteTrip(PDO $pdo, int $id, int $userId, array $authData): void +{ + $stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?'); + $stmt->execute([$id]); + $trip = $stmt->fetch(); + + if (!$trip) { + errorResponse('Záznam nebyl nalezen', 404); + } + + // Check permission - own trips or trips.admin + if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) { + errorResponse('Nemáte oprávnění smazat tento záznam', 403); + } + + $stmt = $pdo->prepare('DELETE FROM trips WHERE id = ?'); + $stmt->execute([$id]); + + AuditLog::logDelete('trips', $id, $trip, 'Smazán záznam jízdy'); + + successResponse(null, 'Záznam byl smazán'); +} + +/** + * DELETE - Delete vehicle (admin) + */ +function handleDeleteVehicle(PDO $pdo, int $id): void +{ + if (!$id) { + errorResponse('ID je povinné'); + } + + $stmt = $pdo->prepare('SELECT * FROM vehicles WHERE id = ?'); + $stmt->execute([$id]); + $vehicle = $stmt->fetch(); + + if (!$vehicle) { + errorResponse('Vozidlo nebylo nalezeno', 404); + } + + // Check if vehicle has trips + $stmt = $pdo->prepare('SELECT COUNT(*) FROM trips WHERE vehicle_id = ?'); + $stmt->execute([$id]); + $tripCount = $stmt->fetchColumn(); + + if ($tripCount > 0) { + errorResponse( + "Nelze smazat vozidlo s {$tripCount} záznamy jízd. Nejprve smažte záznamy jízd nebo deaktivujte vozidlo." + ); + } + + $stmt = $pdo->prepare('DELETE FROM vehicles WHERE id = ?'); + $stmt->execute([$id]); + + AuditLog::logDelete('vehicles', $id, $vehicle, 'Smazáno vozidlo'); + + successResponse(null, 'Vozidlo bylo smazáno'); +} + +// ============================================================================ +// Print Handler +// ============================================================================ + +/** + * Format date range for display + */ +function formatPeriodName(string $startDate, string $endDate): string +{ + $start = new DateTime($startDate); + $end = new DateTime($endDate); + + // If same month + if ($start->format('Y-m') === $end->format('Y-m')) { + return getCzechMonthName((int)$start->format('n')) . ' ' . $start->format('Y'); + } + + // If same year + if ($start->format('Y') === $end->format('Y')) { + return $start->format('j.n.') . ' - ' . $end->format('j.n.Y'); + } + + // Different years + return $start->format('j.n.Y') . ' - ' . $end->format('j.n.Y'); +} + +/** + * GET - Print data for trips (admin) + */ +function handleGetPrint(PDO $pdo): void +{ + $dateFrom = $_GET['date_from'] ?? null; + $dateTo = $_GET['date_to'] ?? null; + $vehicleId = isset($_GET['vehicle_id']) && $_GET['vehicle_id'] !== '' ? (int)$_GET['vehicle_id'] : null; + $filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null; + + // Default to current month if no dates provided + if (!$dateFrom || !$dateTo) { + $startDate = date('Y-m-01'); + $endDate = date('Y-m-t'); + } else { + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) { + errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)'); + } + $startDate = $dateFrom; + $endDate = $dateTo; + } + + $sql = " + SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, + CONCAT(u.first_name, ' ', u.last_name) as driver_name + FROM trips t + JOIN vehicles v ON t.vehicle_id = v.id + JOIN users u ON t.user_id = u.id + WHERE t.trip_date BETWEEN ? AND ? + "; + $params = [$startDate, $endDate]; + + if ($vehicleId) { + $sql .= ' AND t.vehicle_id = ?'; + $params[] = $vehicleId; + } + + if ($filterUserId) { + $sql .= ' AND t.user_id = ?'; + $params[] = $filterUserId; + } + + $sql .= ' ORDER BY t.trip_date ASC, t.start_km ASC'; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $trips = $stmt->fetchAll(); + + // Get vehicles for filter + $stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name'); + $vehicles = $stmt->fetchAll(); + + // Get users for filter + $stmt = $pdo->query( + "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" + ); + $users = $stmt->fetchAll(); + + // Calculate totals + $totalDistance = 0; + $businessDistance = 0; + $privateDistance = 0; + + foreach ($trips as $trip) { + $totalDistance += $trip['distance']; + if ($trip['is_business']) { + $businessDistance += $trip['distance']; + } else { + $privateDistance += $trip['distance']; + } + } + + // Get selected vehicle/user names for header + $selectedVehicleName = ''; + if ($vehicleId) { + $stmt = $pdo->prepare("SELECT CONCAT(spz, ' - ', name) as name FROM vehicles WHERE id = ?"); + $stmt->execute([$vehicleId]); + $v = $stmt->fetch(); + $selectedVehicleName = $v ? $v['name'] : ''; + } + + $selectedUserName = ''; + if ($filterUserId) { + $stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?"); + $stmt->execute([$filterUserId]); + $u = $stmt->fetch(); + $selectedUserName = $u ? $u['name'] : ''; + } + + successResponse([ + 'trips' => $trips, + 'vehicles' => $vehicles, + 'users' => $users, + 'date_from' => $startDate, + 'date_to' => $endDate, + 'period_name' => formatPeriodName($startDate, $endDate), + 'selected_vehicle' => $vehicleId, + 'selected_vehicle_name' => $selectedVehicleName, + 'selected_user' => $filterUserId, + 'selected_user_name' => $selectedUserName, + 'totals' => [ + 'total' => $totalDistance, + 'business' => $businessDistance, + 'private' => $privateDistance, + 'count' => count($trips), + ], + ]); +} diff --git a/api/admin/handlers/users-handlers.php b/api/admin/handlers/users-handlers.php new file mode 100644 index 0000000..e3c8f85 --- /dev/null +++ b/api/admin/handlers/users-handlers.php @@ -0,0 +1,273 @@ +query(' + SELECT + u.id, + u.username, + u.email, + u.first_name, + u.last_name, + u.role_id, + u.is_active, + u.last_login, + u.created_at, + r.name as role_name, + r.display_name as role_display_name + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + ORDER BY u.created_at DESC + '); + $users = $stmt->fetchAll(); + + // Get roles for dropdown + $stmt = $pdo->query('SELECT id, name, display_name FROM roles ORDER BY id'); + $roles = $stmt->fetchAll(); + + successResponse([ + 'users' => $users, + 'roles' => $roles, + ]); +} + +/** + * POST - Create new user + * + * @param array $authData + */ +function handleCreateUser(PDO $pdo, array $authData): void +{ + $input = getJsonInput(); + + // Validate required fields + $requiredFields = [ + 'username' => 'Uživatelské jméno', + 'email' => 'E-mail', + 'password' => 'Heslo', + 'first_name' => 'Jméno', + 'last_name' => 'Příjmení', + 'role_id' => 'Role', + ]; + foreach ($requiredFields as $field => $label) { + if (empty($input[$field])) { + errorResponse("$label je povinné"); + } + } + + $username = sanitize($input['username']); + $email = sanitize($input['email']); + $password = $input['password']; + $firstName = sanitize($input['first_name']); + $lastName = sanitize($input['last_name']); + $roleId = (int) $input['role_id']; + $isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : 1; + + // Non-admin nesmí přiřadit admin roli + if (!($authData['user']['is_admin'] ?? false)) { + $stmt = $pdo->prepare('SELECT name FROM roles WHERE id = ?'); + $stmt->execute([$roleId]); + $targetRole = $stmt->fetch(); + if ($targetRole && $targetRole['name'] === 'admin') { + errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403); + } + } + + // Validate email format + if (!isValidEmail($email)) { + errorResponse('Neplatný formát e-mailu'); + } + + // Validate password length + if (strlen($password) < 8) { + errorResponse('Heslo musí mít alespoň 8 znaků'); + } + + // Check username uniqueness + $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?'); + $stmt->execute([$username]); + if ($stmt->fetch()) { + errorResponse('Uživatelské jméno již existuje'); + } + + // Check email uniqueness + $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?'); + $stmt->execute([$email]); + if ($stmt->fetch()) { + errorResponse('E-mail již existuje'); + } + + // Validate role exists + $stmt = $pdo->prepare('SELECT id FROM roles WHERE id = ?'); + $stmt->execute([$roleId]); + if (!$stmt->fetch()) { + errorResponse('Neplatná role'); + } + + // Hash password + $passwordHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]); + + // Insert user + $stmt = $pdo->prepare(' + INSERT INTO users (username, email, password_hash, first_name, last_name, role_id, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive]); + + $newUserId = (int)$pdo->lastInsertId(); + + // Audit log + AuditLog::logCreate('user', $newUserId, [ + 'username' => $username, + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'role_id' => $roleId, + 'is_active' => $isActive, + ], "Vytvořen uživatel '$username'"); + + successResponse(['id' => $newUserId], 'Uživatel byl úspěšně vytvořen'); +} + +/** + * PUT - Update user + * + * @param array $authData + */ +function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void +{ + // Get existing user + $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $existingUser = $stmt->fetch(); + + if (!$existingUser) { + errorResponse('Uživatel nebyl nalezen', 404); + } + + $input = getJsonInput(); + + $username = isset($input['username']) ? sanitize($input['username']) : $existingUser['username']; + $email = isset($input['email']) ? sanitize($input['email']) : $existingUser['email']; + $firstName = isset($input['first_name']) ? sanitize($input['first_name']) : $existingUser['first_name']; + $lastName = isset($input['last_name']) ? sanitize($input['last_name']) : $existingUser['last_name']; + $roleId = isset($input['role_id']) ? (int) $input['role_id'] : $existingUser['role_id']; + $isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : $existingUser['is_active']; + + // Validate email format + if (!isValidEmail($email)) { + errorResponse('Neplatný formát e-mailu'); + } + + // Check username uniqueness (excluding current user) + $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ? AND id != ?'); + $stmt->execute([$username, $userId]); + if ($stmt->fetch()) { + errorResponse('Uživatelské jméno již existuje'); + } + + // Check email uniqueness (excluding current user) + $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND id != ?'); + $stmt->execute([$email, $userId]); + if ($stmt->fetch()) { + errorResponse('E-mail již existuje'); + } + + // Validate role exists + $stmt = $pdo->prepare('SELECT id, name FROM roles WHERE id = ?'); + $stmt->execute([$roleId]); + $targetRole = $stmt->fetch(); + if (!$targetRole) { + errorResponse('Neplatná role'); + } + + // Non-admin nesmí přiřadit admin roli + if (!($authData['user']['is_admin'] ?? false) && $targetRole['name'] === 'admin') { + errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403); + } + + // Update user + if (!empty($input['password'])) { + // Validate password length + if (strlen($input['password']) < 8) { + errorResponse('Heslo musí mít alespoň 8 znaků'); + } + + $passwordHash = password_hash($input['password'], PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]); + + $stmt = $pdo->prepare(' + UPDATE users + SET username = ?, email = ?, password_hash = ?, + first_name = ?, last_name = ?, role_id = ?, + is_active = ?, password_changed_at = NOW() + WHERE id = ? + '); + $stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive, $userId]); + } else { + $stmt = $pdo->prepare(' + UPDATE users + SET username = ?, email = ?, first_name = ?, last_name = ?, role_id = ?, is_active = ? + WHERE id = ? + '); + $stmt->execute([$username, $email, $firstName, $lastName, $roleId, $isActive, $userId]); + } + + // Note: With JWT, user data is in the token - no session to update + + // Audit log + AuditLog::logUpdate('user', $userId, [ + 'username' => $existingUser['username'], + 'email' => $existingUser['email'], + 'first_name' => $existingUser['first_name'], + 'last_name' => $existingUser['last_name'], + 'role_id' => $existingUser['role_id'], + 'is_active' => $existingUser['is_active'], + ], [ + 'username' => $username, + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'role_id' => $roleId, + 'is_active' => $isActive, + ], "Upraven uživatel '$username'"); + + successResponse(null, 'Uživatel byl úspěšně aktualizován'); +} + +/** + * DELETE - Delete user + */ +function handleDeleteUser(PDO $pdo, int $userId, int $currentUserId): void +{ + // Prevent self-deletion + if ($userId === $currentUserId) { + errorResponse('Nemůžete smazat svůj vlastní účet'); + } + + // Get user for audit log + $stmt = $pdo->prepare('SELECT username FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user) { + errorResponse('Uživatel nebyl nalezen', 404); + } + + // Delete related records first (refresh tokens for JWT auth) + $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?'); + $stmt->execute([$userId]); + + // Delete user + $stmt = $pdo->prepare('DELETE FROM users WHERE id = ?'); + $stmt->execute([$userId]); + + // Audit log + AuditLog::logDelete('user', $userId, ['username' => $user['username']], "Smazán uživatel '{$user['username']}'"); + + successResponse(null, 'Uživatel byl úspěšně smazán'); +} diff --git a/api/admin/invoices-pdf.php b/api/admin/invoices-pdf.php index aeb941a..898acff 100644 --- a/api/admin/invoices-pdf.php +++ b/api/admin/invoices-pdf.php @@ -12,8 +12,6 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; -// QR kod -require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; require_once dirname(__DIR__) . '/includes/CnbRates.php'; use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QROptions; diff --git a/api/admin/invoices.php b/api/admin/invoices.php index 9a55c7c..7df0b63 100644 --- a/api/admin/invoices.php +++ b/api/admin/invoices.php @@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/CnbRates.php'; +require_once __DIR__ . '/handlers/invoices-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -101,703 +102,3 @@ try { // --- Status transitions --- /** @return list */ -function getValidTransitions(string $status): array -{ - return match ($status) { - 'issued' => ['paid'], - 'overdue' => ['paid'], - default => [] - }; -} - -// --- Invoice number generation --- - -function generateInvoiceNumber(PDO $pdo): string -{ - $yy = date('y'); - - $settings = $pdo->query('SELECT invoice_type_code FROM company_settings LIMIT 1')->fetch(); - $typeCode = ($settings && !empty($settings['invoice_type_code'])) ? $settings['invoice_type_code'] : '81'; - - $prefix = $yy . $typeCode; - $prefixLen = strlen($prefix); - $likePattern = $prefix . '%'; - - $stmt = $pdo->prepare(' - SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ? + 1) AS UNSIGNED)), 0) - FROM invoices WHERE invoice_number LIKE ? - '); - $stmt->execute([$prefixLen, $likePattern]); - $max = (int) $stmt->fetchColumn(); - - return sprintf('%s%04d', $prefix, $max + 1); -} - -// --- Stats --- - -/** - * Spocita celkovou castku faktur seskupenou podle meny + CZK prepocet dle kurzu k datu faktury. - * - * @param array $params - * @return array{amounts: array, count: int, total_czk: float} - */ -function sumInvoicesByCurrency(PDO $pdo, string $where, array $params): array -{ - // Per-faktura pro presny prepocet kurzem k datu - $perInvoiceSql = " - SELECT i.id, i.currency, i.issue_date, - COALESCE(SUM(ii.quantity * ii.unit_price), 0) - + COALESCE(SUM(CASE WHEN i.apply_vat - THEN ii.quantity * ii.unit_price * ii.vat_rate / 100 - ELSE 0 END), 0) AS total - FROM invoices i - JOIN invoice_items ii ON ii.invoice_id = i.id - $where - GROUP BY i.id, i.currency, i.issue_date - "; - $stmt = $pdo->prepare($perInvoiceSql); - $stmt->execute($params); - $rows = $stmt->fetchAll(); - - // Seskupit podle meny pro zobrazeni - $byCurrency = []; - $czkItems = []; - foreach ($rows as $r) { - $cur = $r['currency']; - $amt = round((float) $r['total'], 2); - $byCurrency[$cur] = ($byCurrency[$cur] ?? 0) + $amt; - $czkItems[] = [ - 'amount' => $amt, - 'currency' => $cur, - 'date' => $r['issue_date'], - ]; - } - - $amounts = []; - foreach ($byCurrency as $cur => $total) { - $amounts[] = ['amount' => round($total, 2), 'currency' => $cur]; - } - - $cnb = CnbRates::getInstance(); - $totalCzk = $cnb->sumToCzk($czkItems); - - $countSql = "SELECT COUNT(*) FROM invoices i $where"; - $countStmt = $pdo->prepare($countSql); - $countStmt->execute($params); - - return [ - 'amounts' => $amounts, - 'count' => (int) $countStmt->fetchColumn(), - 'total_czk' => $totalCzk, - ]; -} - -function handleGetStats(PDO $pdo): void -{ - $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); - $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); - - // Lazy overdue detekce - $pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()"); - - $monthStart = sprintf('%04d-%02d-01', $year, $month); - $monthEnd = date('Y-m-t', strtotime($monthStart)); - - // a) Uhrazeno v danem mesici (dle data vystaveni, ne uhrazeni) - $paidWhere = "WHERE i.status = 'paid' AND i.issue_date BETWEEN ? AND ?"; - $paid = sumInvoicesByCurrency($pdo, $paidWhere, [$monthStart, $monthEnd]); - - // b) Ceka uhrada (aktualni stav) - $awaiting = sumInvoicesByCurrency($pdo, "WHERE i.status = 'issued'", []); - - // c) Po splatnosti (aktualni stav) - $overdue = sumInvoicesByCurrency($pdo, "WHERE i.status = 'overdue'", []); - - // d) DPH v danem mesici - per faktura pro kurz k datu - $vatSql = " - SELECT i.id, i.currency, i.issue_date, - COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0) AS vat_total - FROM invoices i - JOIN invoice_items ii ON ii.invoice_id = i.id - WHERE i.apply_vat = 1 AND i.issue_date BETWEEN ? AND ? - GROUP BY i.id, i.currency, i.issue_date - "; - $vatStmt = $pdo->prepare($vatSql); - $vatStmt->execute([$monthStart, $monthEnd]); - $vatRows = $vatStmt->fetchAll(); - - $vatByCurrency = []; - $vatCzkItems = []; - foreach ($vatRows as $r) { - $cur = $r['currency']; - $amt = round((float) $r['vat_total'], 2); - $vatByCurrency[$cur] = ($vatByCurrency[$cur] ?? 0) + $amt; - $vatCzkItems[] = [ - 'amount' => $amt, - 'currency' => $cur, - 'date' => $r['issue_date'], - ]; - } - - $vatAmounts = []; - foreach ($vatByCurrency as $cur => $total) { - $vatAmounts[] = ['amount' => round($total, 2), 'currency' => $cur]; - } - - $cnb = CnbRates::getInstance(); - - successResponse([ - 'paid_month' => $paid['amounts'], - 'paid_month_czk' => $paid['total_czk'], - 'paid_month_count' => $paid['count'], - 'awaiting' => $awaiting['amounts'], - 'awaiting_czk' => $awaiting['total_czk'], - 'awaiting_count' => $awaiting['count'], - 'overdue' => $overdue['amounts'], - 'overdue_czk' => $overdue['total_czk'], - 'overdue_count' => $overdue['count'], - 'vat_month' => $vatAmounts, - 'vat_month_czk' => $cnb->sumToCzk($vatCzkItems), - 'month' => $month, - 'year' => $year, - ]); -} - -// --- Handlers --- - -function handleGetList(PDO $pdo): void -{ - $search = trim($_GET['search'] ?? ''); - $statusFilter = trim($_GET['status'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); - - $sortMap = [ - 'InvoiceNumber' => 'i.invoice_number', - 'invoice_number' => 'i.invoice_number', - 'CreatedAt' => 'i.created_at', - 'created_at' => 'i.created_at', - 'Status' => 'i.status', - 'status' => 'i.status', - 'DueDate' => 'i.due_date', - 'due_date' => 'i.due_date', - 'IssueDate' => 'i.issue_date', - 'issue_date' => 'i.issue_date', - ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; - - // Lazy overdue detekce - $pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()"); - - $where = 'WHERE 1=1'; - $params = []; - - if ($search) { - $search = mb_substr($search, 0, 100); - $where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)'; - $searchParam = "%{$search}%"; - $params = array_merge($params, [$searchParam, $searchParam, $searchParam]); - } - - if ($statusFilter) { - $statuses = array_filter(explode(',', $statusFilter)); - if ($statuses) { - $placeholders = implode(',', array_fill(0, count($statuses), '?')); - $where .= " AND i.status IN ($placeholders)"; - $params = array_merge($params, $statuses); - } - } - - $countSql = " - SELECT COUNT(*) - FROM invoices i - LEFT JOIN customers c ON i.customer_id = c.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); - - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency, - i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat, - c.name as customer_name, - (SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0) - FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal, - o.order_number - FROM invoices i - LEFT JOIN customers c ON i.customer_id = c.id - LEFT JOIN orders o ON i.order_id = o.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $invoices = $stmt->fetchAll(); - - // Dopocitat celkovou castku s DPH - foreach ($invoices as &$inv) { - $subtotal = (float) $inv['subtotal']; - if ($inv['apply_vat']) { - $vatStmt = $pdo->prepare(' - SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0) - FROM invoice_items WHERE invoice_id = ? - '); - $vatStmt->execute([$inv['id']]); - $vatAmount = (float) $vatStmt->fetchColumn(); - $inv['total'] = $subtotal + $vatAmount; - } else { - $inv['total'] = $subtotal; - } - } - unset($inv); - - successResponse([ - 'invoices' => $invoices, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, - ]); -} - -function handleGetDetail(PDO $pdo, int $id): void -{ - // Lazy overdue - $pdo->prepare( - "UPDATE invoices SET status = 'overdue' WHERE id = ? AND status = 'issued' AND due_date < CURDATE()" - )->execute([$id]); - - $stmt = $pdo->prepare(' - SELECT i.*, c.name as customer_name, o.order_number - FROM invoices i - LEFT JOIN customers c ON i.customer_id = c.id - LEFT JOIN orders o ON i.order_id = o.id - WHERE i.id = ? - '); - $stmt->execute([$id]); - $invoice = $stmt->fetch(); - - if (!$invoice) { - errorResponse('Faktura nebyla nalezena', 404); - } - - // Polozky - $stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position'); - $stmt->execute([$id]); - $invoice['items'] = $stmt->fetchAll(); - - // Zakaznik - if ($invoice['customer_id']) { - $stmt = $pdo->prepare( - 'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields - FROM customers WHERE id = ?' - ); - $stmt->execute([$invoice['customer_id']]); - $invoice['customer'] = $stmt->fetch(); - } - - $invoice['valid_transitions'] = getValidTransitions($invoice['status']); - - successResponse($invoice); -} - -function handleGetNextNumber(PDO $pdo): void -{ - $number = generateInvoiceNumber($pdo); - successResponse(['number' => $number]); -} - -function handleGetOrderData(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare(' - SELECT o.id, o.order_number, o.customer_id, o.status, o.currency, - o.language, o.vat_rate, o.apply_vat, o.exchange_rate, - o.created_at, o.modified_at, - c.name as customer_name - FROM orders o - LEFT JOIN customers c ON o.customer_id = c.id - WHERE o.id = ? - '); - $stmt->execute([$id]); - $order = $stmt->fetch(); - - if (!$order) { - errorResponse('Objednávka nebyla nalezena', 404); - } - - // Polozky objednavky - $stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position'); - $stmt->execute([$id]); - $order['items'] = $stmt->fetchAll(); - - successResponse($order); -} - -/** @param array $authData */ -function handleCreateInvoice(PDO $pdo, array $authData): void -{ - $input = getJsonInput(); - - $customerId = isset($input['customer_id']) ? (int) $input['customer_id'] : null; - $orderId = !empty($input['order_id']) ? (int) $input['order_id'] : null; - $issueDate = trim($input['issue_date'] ?? ''); - $dueDate = trim($input['due_date'] ?? ''); - $taxDate = trim($input['tax_date'] ?? ''); - $currency = trim($input['currency'] ?? 'CZK'); - $applyVat = isset($input['apply_vat']) ? (int) $input['apply_vat'] : 1; - $paymentMethod = trim($input['payment_method'] ?? 'Příkazem'); - $constantSymbol = trim($input['constant_symbol'] ?? '0308'); - $issuedBy = trim($input['issued_by'] ?? ''); - $notes = trim($input['notes'] ?? ''); - $items = $input['items'] ?? []; - - // Bankovni udaje - $bankName = trim($input['bank_name'] ?? ''); - $bankSwift = trim($input['bank_swift'] ?? ''); - $bankIban = trim($input['bank_iban'] ?? ''); - $bankAccount = trim($input['bank_account'] ?? ''); - - if (!$customerId) { - errorResponse('Zákazník je povinný'); - } - if (!$issueDate || !$dueDate || !$taxDate) { - errorResponse('Všechna data (vystavení, splatnost, DÚZP) jsou povinná'); - } - - // Validace formatu dat - foreach (['issue_date' => $issueDate, 'due_date' => $dueDate, 'tax_date' => $taxDate] as $label => $date) { - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { - errorResponse("Neplatný formát data: $label"); - } - } - - // Validace meny - $validCurrencies = ['CZK', 'EUR', 'USD', 'GBP']; - if (!in_array($currency, $validCurrencies)) { - errorResponse('Neplatná měna'); - } - - // Delkove limity - if (mb_strlen($paymentMethod) > 50) { - errorResponse('Forma úhrady je příliš dlouhá (max 50 znaků)'); - } - if (mb_strlen($issuedBy) > 255) { - errorResponse('Vystavil je příliš dlouhé (max 255 znaků)'); - } - if (mb_strlen($notes) > 5000) { - errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); - } - if (mb_strlen($bankName) > 255) { - errorResponse('Název banky je příliš dlouhý'); - } - if (mb_strlen($bankIban) > 50) { - errorResponse('IBAN je příliš dlouhý'); - } - if (mb_strlen($bankSwift) > 20) { - errorResponse('BIC/SWIFT je příliš dlouhý'); - } - if (mb_strlen($bankAccount) > 50) { - errorResponse('Číslo účtu je příliš dlouhé'); - } - if (!$bankAccount && !$bankIban) { - errorResponse('Bankovní účet je povinný'); - } - - if (empty($items)) { - errorResponse('Faktura musí mít alespoň jednu položku'); - } - - // Validace polozek - foreach ($items as $i => $item) { - $qty = $item['quantity'] ?? 1; - $price = $item['unit_price'] ?? 0; - $vatRate = $item['vat_rate'] ?? 21; - if (!is_numeric($qty) || $qty < 0) { - errorResponse('Položka #' . ($i + 1) . ': neplatné množství'); - } - if (!is_numeric($price)) { - errorResponse('Položka #' . ($i + 1) . ': neplatná cena'); - } - if (!is_numeric($vatRate) || $vatRate < 0 || $vatRate > 100) { - errorResponse('Položka #' . ($i + 1) . ': neplatná sazba DPH'); - } - if (mb_strlen($item['description'] ?? '') > 500) { - errorResponse('Položka #' . ($i + 1) . ': popis je příliš dlouhý (max 500 znaků)'); - } - } - - // Overit zakaznika - $stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); - $stmt->execute([$customerId]); - if (!$stmt->fetch()) { - errorResponse('Zákazník nebyl nalezen', 404); - } - - // Lock pro cislovani - $locked = $pdo->query("SELECT GET_LOCK('boha_invoice_number', 5)")->fetchColumn(); - if (!$locked) { - errorResponse('Nepodařilo se získat zámek pro číslo faktury, zkuste to znovu', 503); - } - - $pdo->beginTransaction(); - try { - $invoiceNumber = !empty($input['invoice_number']) - ? trim($input['invoice_number']) - : generateInvoiceNumber($pdo); - - $stmt = $pdo->prepare(" - INSERT INTO invoices ( - invoice_number, order_id, customer_id, status, currency, - vat_rate, apply_vat, payment_method, constant_symbol, - bank_name, bank_swift, bank_iban, bank_account, - issue_date, due_date, tax_date, issued_by, notes, - created_at, modified_at - ) VALUES (?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) - "); - $stmt->execute([ - $invoiceNumber, - $orderId, - $customerId, - $currency, - $input['vat_rate'] ?? 21, - $applyVat, - $paymentMethod, - $constantSymbol, - $bankName, - $bankSwift, - $bankIban, - $bankAccount, - $issueDate, - $dueDate, - $taxDate, - $issuedBy, - $notes, - ]); - $invoiceId = (int) $pdo->lastInsertId(); - - // Vlozit polozky - $itemStmt = $pdo->prepare(' - INSERT INTO invoice_items ( - invoice_id, description, quantity, unit, unit_price, vat_rate, position - ) VALUES (?, ?, ?, ?, ?, ?, ?) - '); - foreach ($items as $i => $item) { - $itemStmt->execute([ - $invoiceId, - trim($item['description'] ?? ''), - $item['quantity'] ?? 1, - trim($item['unit'] ?? ''), - $item['unit_price'] ?? 0, - $item['vat_rate'] ?? 21, - $item['position'] ?? $i, - ]); - } - - $pdo->commit(); - $pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')"); - - AuditLog::logCreate('invoices_invoice', $invoiceId, [ - 'invoice_number' => $invoiceNumber, - 'customer_id' => $customerId, - 'order_id' => $orderId, - ], "Vytvořena faktura '$invoiceNumber'"); - - successResponse([ - 'invoice_id' => $invoiceId, - 'invoice_number' => $invoiceNumber, - ], 'Faktura byla vystavena'); - } catch (PDOException $e) { - $pdo->rollBack(); - $pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')"); - throw $e; - } -} - -function handleUpdateInvoice(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); - $stmt->execute([$id]); - $invoice = $stmt->fetch(); - - if (!$invoice) { - errorResponse('Faktura nebyla nalezena', 404); - } - - $input = getJsonInput(); - $newStatus = $input['status'] ?? null; - $isDraft = $invoice['status'] === 'issued'; - - // Zmena stavu - if ($newStatus && $newStatus !== $invoice['status']) { - $valid = getValidTransitions($invoice['status']); - if (!in_array($newStatus, $valid)) { - errorResponse("Neplatný přechod stavu z '{$invoice['status']}' na '$newStatus'"); - } - } - - $pdo->beginTransaction(); - try { - $updates = []; - $params = []; - - if ($newStatus !== null && $newStatus !== $invoice['status']) { - $updates[] = 'status = ?'; - $params[] = $newStatus; - - if ($newStatus === 'paid') { - $updates[] = 'paid_date = CURDATE()'; - } - } - - // V issued stavu lze editovat vsechna pole - if ($isDraft) { - // Validace dat - foreach (['issue_date', 'due_date', 'tax_date'] as $dateField) { - if ( - isset($input[$dateField]) - && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $input[$dateField]) || !strtotime($input[$dateField])) - ) { - errorResponse("Neplatný formát data: $dateField"); - } - } - // Validace meny - if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { - errorResponse('Neplatná měna'); - } - // Validace DPH - if ( - isset($input['vat_rate']) - && (!is_numeric($input['vat_rate']) || $input['vat_rate'] < 0 || $input['vat_rate'] > 100) - ) { - errorResponse('Neplatná sazba DPH'); - } - // Validace zakaznika - if (isset($input['customer_id'])) { - $custStmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); - $custStmt->execute([(int)$input['customer_id']]); - if (!$custStmt->fetch()) { - errorResponse('Zákazník nebyl nalezen', 404); - } - } - - $stringFields = [ - 'issue_date' => 20, 'due_date' => 20, 'tax_date' => 20, - 'payment_method' => 50, 'constant_symbol' => 10, - 'bank_name' => 255, 'bank_swift' => 20, 'bank_iban' => 50, 'bank_account' => 50, - 'issued_by' => 255, - ]; - foreach ($stringFields as $field => $maxLen) { - if (array_key_exists($field, $input)) { - $val = trim((string)$input[$field]); - if (mb_strlen($val) > $maxLen) { - errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)"); - } - $updates[] = "$field = ?"; - $params[] = $val; - } - } - $numericFields = ['currency', 'vat_rate', 'apply_vat', 'customer_id']; - foreach ($numericFields as $field) { - if (array_key_exists($field, $input)) { - $updates[] = "$field = ?"; - $params[] = $input[$field]; - } - } - - // Aktualizace polozek - if (isset($input['items']) && is_array($input['items'])) { - $pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]); - - $itemStmt = $pdo->prepare(' - INSERT INTO invoice_items ( - invoice_id, description, quantity, unit, unit_price, vat_rate, position - ) VALUES (?, ?, ?, ?, ?, ?, ?) - '); - foreach ($input['items'] as $i => $item) { - $itemStmt->execute([ - $id, - trim($item['description'] ?? ''), - $item['quantity'] ?? 1, - trim($item['unit'] ?? ''), - $item['unit_price'] ?? 0, - $item['vat_rate'] ?? 21, - $item['position'] ?? $i, - ]); - } - } - } - - // Poznamky lze editovat jen v issued/overdue stavu - if ($isDraft || $invoice['status'] === 'overdue') { - if (array_key_exists('notes', $input)) { - $updates[] = 'notes = ?'; - $params[] = $input['notes']; - } - if (array_key_exists('internal_notes', $input)) { - $updates[] = 'internal_notes = ?'; - $params[] = $input['internal_notes']; - } - } - - if (!empty($updates)) { - $updates[] = 'modified_at = NOW()'; - $params[] = $id; - $sql = 'UPDATE invoices SET ' . implode(', ', $updates) . ' WHERE id = ?'; - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - } - - $pdo->commit(); - - AuditLog::logUpdate( - 'invoices_invoice', - $id, - ['status' => $invoice['status']], - ['status' => $newStatus ?? $invoice['status']], - "Aktualizována faktura '{$invoice['invoice_number']}'" - ); - - successResponse(null, 'Faktura byla aktualizována'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleDeleteInvoice(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); - $stmt->execute([$id]); - $invoice = $stmt->fetch(); - - if (!$invoice) { - errorResponse('Faktura nebyla nalezena', 404); - } - - $pdo->beginTransaction(); - try { - $pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]); - $pdo->prepare('DELETE FROM invoices WHERE id = ?')->execute([$id]); - - $pdo->commit(); - - AuditLog::logDelete('invoices_invoice', $id, [ - 'invoice_number' => $invoice['invoice_number'], - 'customer_id' => $invoice['customer_id'], - ], "Smazána faktura '{$invoice['invoice_number']}'"); - - successResponse(null, 'Faktura byla smazána'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} diff --git a/api/admin/leave-requests.php b/api/admin/leave-requests.php index 1a4ac5b..acb35f4 100644 --- a/api/admin/leave-requests.php +++ b/api/admin/leave-requests.php @@ -19,7 +19,9 @@ require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php'; +require_once dirname(__DIR__) . '/includes/Mailer.php'; require_once dirname(__DIR__) . '/includes/LeaveNotification.php'; +require_once __DIR__ . '/handlers/leave-requests-handlers.php'; // Set headers setCorsHeaders(); @@ -76,462 +78,3 @@ try { // ============================================================================ // Helper Functions // ============================================================================ - -/** - * Calculate number of business days between two dates (skip Sat/Sun) - */ -function calculateBusinessDays(string $dateFrom, string $dateTo): int -{ - $start = new DateTime($dateFrom); - $end = new DateTime($dateTo); - $end->modify('+1 day'); // include the end date - - $days = 0; - $period = new DatePeriod($start, new DateInterval('P1D'), $end); - foreach ($period as $date) { - $dayOfWeek = (int)$date->format('N'); // 1=Mon, 7=Sun - if ($dayOfWeek <= 5) { - $days++; - } - } - return $days; -} - -/** - * Get leave balance for user (reuse logic from attendance.php) - * - * @return array - */ -function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): array -{ - $year = $year ?: (int)date('Y'); - - $stmt = $pdo->prepare('SELECT * FROM leave_balances WHERE user_id = ? AND year = ?'); - $stmt->execute([$userId, $year]); - $balance = $stmt->fetch(); - - if (!$balance) { - return [ - 'vacation_total' => 160, - 'vacation_used' => 0, - 'vacation_remaining' => 160, - 'sick_used' => 0, - ]; - } - - return [ - 'vacation_total' => (float)$balance['vacation_total'], - 'vacation_used' => (float)$balance['vacation_used'], - 'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'], - 'sick_used' => (float)$balance['sick_used'], - ]; -} - -/** - * Get hours already locked in pending requests for vacation - */ -function getPendingVacationHours(PDO $pdo, int $userId, int $year): float -{ - $stmt = $pdo->prepare(" - SELECT COALESCE(SUM(total_hours), 0) as pending_hours - FROM leave_requests - WHERE user_id = ? AND leave_type = 'vacation' AND status = 'pending' - AND YEAR(date_from) = ? - "); - $stmt->execute([$userId, $year]); - return (float)$stmt->fetchColumn(); -} - -// ============================================================================ -// GET Handlers -// ============================================================================ - -/** - * GET - Own leave requests - */ -function handleGetMyRequests(PDO $pdo, int $userId): void -{ - $stmt = $pdo->prepare(" - SELECT lr.*, - CONCAT(u.first_name, ' ', u.last_name) as reviewer_name - FROM leave_requests lr - LEFT JOIN users u ON lr.reviewer_id = u.id - WHERE lr.user_id = ? - ORDER BY lr.created_at DESC - "); - $stmt->execute([$userId]); - $requests = $stmt->fetchAll(); - - successResponse($requests); -} - -/** - * GET - All pending requests (for approver) - */ -function handleGetPending(PDO $pdo): void -{ - $stmt = $pdo->prepare(" - SELECT lr.*, - CONCAT(u.first_name, ' ', u.last_name) as employee_name, - CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name - FROM leave_requests lr - JOIN users u ON lr.user_id = u.id - LEFT JOIN users rv ON lr.reviewer_id = rv.id - WHERE lr.status = 'pending' - ORDER BY lr.created_at ASC - "); - $stmt->execute(); - $requests = $stmt->fetchAll(); - - successResponse([ - 'requests' => $requests, - 'count' => count($requests), - ]); -} - -/** - * GET - All requests with filters (for approver) - */ -function handleGetAll(PDO $pdo): void -{ - $status = $_GET['status'] ?? ''; - $userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null; - - $where = []; - $params = []; - - if ($status && in_array($status, ['pending', 'approved', 'rejected', 'cancelled'])) { - $where[] = 'lr.status = ?'; - $params[] = $status; - } - - if ($userId) { - $where[] = 'lr.user_id = ?'; - $params[] = $userId; - } - - $whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - - $stmt = $pdo->prepare(" - SELECT lr.*, - CONCAT(u.first_name, ' ', u.last_name) as employee_name, - CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name - FROM leave_requests lr - JOIN users u ON lr.user_id = u.id - LEFT JOIN users rv ON lr.reviewer_id = rv.id - $whereClause - ORDER BY lr.created_at DESC - LIMIT 200 - "); - $stmt->execute($params); - $requests = $stmt->fetchAll(); - - successResponse($requests); -} - -// ============================================================================ -// POST Handlers -// ============================================================================ - -/** - * POST - Submit new leave request - */ -function handleSubmitRequest(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - - $leaveType = $input['leave_type'] ?? ''; - $dateFrom = $input['date_from'] ?? ''; - $dateTo = $input['date_to'] ?? ''; - $notes = trim($input['notes'] ?? ''); - - if (!$leaveType || !$dateFrom || !$dateTo) { - errorResponse('Vyplňte všechna povinná pole'); - } - - if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) { - errorResponse('Neplatný typ nepřítomnosti'); - } - - // Validate dates - $from = new DateTime($dateFrom); - $to = new DateTime($dateTo); - if ($to < $from) { - errorResponse('Datum "do" nesmí být před datem "od"'); - } - - // Calculate business days - $businessDays = calculateBusinessDays($dateFrom, $dateTo); - if ($businessDays === 0) { - errorResponse('Zvolené období neobsahuje žádné pracovní dny'); - } - - $totalHours = $businessDays * 8; - - // Check vacation balance - if ($leaveType === 'vacation') { - $year = (int)$from->format('Y'); - $balance = getLeaveBalanceForRequest($pdo, $userId, $year); - $pendingHours = getPendingVacationHours($pdo, $userId, $year); - $availableHours = $balance['vacation_remaining'] - $pendingHours; - - if ($availableHours < $totalHours) { - errorResponse( - "Nemáte dostatek hodin dovolené. Dostupné: {$availableHours}h" - . " (zbývá {$balance['vacation_remaining']}h, v čekajících žádostech: {$pendingHours}h)," - . " požadujete: {$totalHours}h." - ); - } - } - - // Check overlapping requests - $stmt = $pdo->prepare(" - SELECT id FROM leave_requests - WHERE user_id = ? AND status IN ('pending', 'approved') - AND date_from <= ? AND date_to >= ? - "); - $stmt->execute([$userId, $dateTo, $dateFrom]); - if ($stmt->fetch()) { - errorResponse('Pro toto období již existuje žádost o nepřítomnost'); - } - - // Insert request - $stmt = $pdo->prepare(" - INSERT INTO leave_requests (user_id, leave_type, date_from, date_to, total_hours, total_days, notes, status) - VALUES (?, ?, ?, ?, ?, ?, ?, 'pending') - "); - $stmt->execute([$userId, $leaveType, $dateFrom, $dateTo, $totalHours, $businessDays, $notes ?: null]); - - $requestId = (int)$pdo->lastInsertId(); - - AuditLog::logCreate('leave_request', $requestId, [ - 'leave_type' => $leaveType, - 'date_from' => $dateFrom, - 'date_to' => $dateTo, - 'total_days' => $businessDays, - 'total_hours' => $totalHours, - ], "Podána žádost o nepřítomnost: $leaveType ($dateFrom - $dateTo)"); - - // Send email notification - try { - $stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?"); - $stmt->execute([$userId]); - $employeeName = $stmt->fetchColumn() ?: 'Neznámý'; - - LeaveNotification::notifyNewRequest([ - 'leave_type' => $leaveType, - 'date_from' => $dateFrom, - 'date_to' => $dateTo, - 'total_days' => $businessDays, - 'total_hours' => $totalHours, - 'notes' => $notes, - ], $employeeName); - } catch (\Exception $e) { - error_log('Leave notification error: ' . $e->getMessage()); - } - - successResponse(['id' => $requestId], 'Žádost byla odeslána ke schválení'); -} - -/** - * POST - Cancel own pending request - */ -function handleCancelRequest(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - $requestId = (int)($input['request_id'] ?? 0); - - if (!$requestId) { - errorResponse('ID žádosti je povinné'); - } - - $stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ? AND user_id = ?'); - $stmt->execute([$requestId, $userId]); - $request = $stmt->fetch(); - - if (!$request) { - errorResponse('Žádost nebyla nalezena'); - } - - if ($request['status'] !== 'pending') { - errorResponse('Lze zrušit pouze čekající žádosti'); - } - - $stmt = $pdo->prepare("UPDATE leave_requests SET status = 'cancelled' WHERE id = ?"); - $stmt->execute([$requestId]); - - AuditLog::logUpdate( - 'leave_request', - $requestId, - ['status' => 'pending'], - ['status' => 'cancelled'], - 'Žádost o nepřítomnost zrušena zaměstnancem' - ); - - successResponse(null, 'Žádost byla zrušena'); -} - -/** - * POST - Approve a leave request - * - * @param array $authData - */ -function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void -{ - $input = getJsonInput(); - $requestId = (int)($input['request_id'] ?? 0); - - if (!$requestId) { - errorResponse('ID žádosti je povinné'); - } - - $stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?'); - $stmt->execute([$requestId]); - $request = $stmt->fetch(); - - if (!$request) { - errorResponse('Žádost nebyla nalezena'); - } - - if ($request['status'] !== 'pending') { - errorResponse('Lze schválit pouze čekající žádosti'); - } - - if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) { - errorResponse('Nemůžete schválit vlastní žádost', 403); - } - - // Re-check vacation balance - if ($request['leave_type'] === 'vacation') { - $year = (int)date('Y', strtotime($request['date_from'])); - $balance = getLeaveBalanceForRequest($pdo, (int)$request['user_id'], $year); - - if ($balance['vacation_remaining'] < (float)$request['total_hours']) { - errorResponse( - "Zaměstnanec nemá dostatek hodin dovolené." - . " Zbývá: {$balance['vacation_remaining']}h, požadováno: {$request['total_hours']}h." - ); - } - } - - // Begin transaction - $pdo->beginTransaction(); - - try { - // Create attendance records for each business day - $start = new DateTime($request['date_from']); - $end = new DateTime($request['date_to']); - $end->modify('+1 day'); - - $period = new DatePeriod($start, new DateInterval('P1D'), $end); - $insertStmt = $pdo->prepare(' - INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes) - VALUES (?, ?, ?, 8, ?) - '); - - $leaveNote = "Schválená žádost #$requestId"; - $totalBusinessDays = 0; - - foreach ($period as $date) { - $dayOfWeek = (int)$date->format('N'); - if ($dayOfWeek <= 5) { - $shiftDate = $date->format('Y-m-d'); - $insertStmt->execute([ - $request['user_id'], - $shiftDate, - $request['leave_type'], - $leaveNote, - ]); - $totalBusinessDays++; - } - } - - // Update leave balance ONCE with total hours (was N queries, one per day) - if ($totalBusinessDays > 0) { - updateLeaveBalance( - $pdo, - (int)$request['user_id'], - $request['date_from'], - $request['leave_type'], - (float)($totalBusinessDays * 8) - ); - } - - // Update request status - $stmt = $pdo->prepare(" - UPDATE leave_requests - SET status = 'approved', reviewer_id = ?, reviewed_at = NOW() - WHERE id = ? - "); - $stmt->execute([$reviewerId, $requestId]); - - $pdo->commit(); - - AuditLog::logUpdate( - 'leave_request', - $requestId, - ['status' => 'pending'], - ['status' => 'approved', 'reviewer_id' => $reviewerId], - 'Žádost o nepřítomnost schválena' - ); - - successResponse(null, 'Žádost byla schválena'); - } catch (\Exception $e) { - $pdo->rollBack(); - error_log('Approve request error: ' . $e->getMessage()); - errorResponse('Chyba při schvalování žádosti', 500); - } -} - -/** - * POST - Reject a leave request - * - * @param array $authData - */ -function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void -{ - $input = getJsonInput(); - $requestId = (int)($input['request_id'] ?? 0); - $note = trim($input['note'] ?? ''); - - if (!$requestId) { - errorResponse('ID žádosti je povinné'); - } - - if (!$note) { - errorResponse('Důvod zamítnutí je povinný'); - } - - $stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?'); - $stmt->execute([$requestId]); - $request = $stmt->fetch(); - - if (!$request) { - errorResponse('Žádost nebyla nalezena'); - } - - if ($request['status'] !== 'pending') { - errorResponse('Lze zamítnout pouze čekající žádosti'); - } - - if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) { - errorResponse('Nemůžete zamítnout vlastní žádost', 403); - } - - $stmt = $pdo->prepare(" - UPDATE leave_requests - SET status = 'rejected', reviewer_id = ?, reviewer_note = ?, reviewed_at = NOW() - WHERE id = ? - "); - $stmt->execute([$reviewerId, $note, $requestId]); - - AuditLog::logUpdate( - 'leave_request', - $requestId, - ['status' => 'pending'], - ['status' => 'rejected', 'reviewer_id' => $reviewerId, 'reviewer_note' => $note], - "Žádost o nepřítomnost zamítnuta: $note" - ); - - successResponse(null, 'Žádost byla zamítnuta'); -} diff --git a/api/admin/offers-templates.php b/api/admin/offers-templates.php index 0a0200a..b27b0f8 100644 --- a/api/admin/offers-templates.php +++ b/api/admin/offers-templates.php @@ -17,6 +17,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/offers-templates-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -98,263 +99,3 @@ try { } // --- Item Templates --- - -function handleGetItemTemplates(PDO $pdo): void -{ - $stmt = $pdo->query('SELECT * FROM item_templates ORDER BY category, name'); - successResponse(['templates' => $stmt->fetchAll()]); -} - -function handleSaveItemTemplate(PDO $pdo): void -{ - $input = getJsonInput(); - - if (empty($input['name'])) { - errorResponse('Název šablony je povinný'); - } - - $id = isset($input['id']) ? (int)$input['id'] : null; - - if ($id) { - // Update - $stmt = $pdo->prepare(' - UPDATE item_templates SET - name = ?, description = ?, default_price = ?, category = ?, - modified_at = NOW(), sync_version = sync_version + 1 - WHERE id = ? - '); - $stmt->execute([ - $input['name'], - $input['description'] ?? '', - $input['default_price'] ?? 0, - $input['category'] ?? '', - $id, - ]); - successResponse(null, 'Šablona byla aktualizována'); - } else { - // Create - $uuid = sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0x0fff) | 0x4000, - random_int(0, 0x3fff) | 0x8000, - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff) - ); - - $stmt = $pdo->prepare(' - INSERT INTO item_templates (name, description, default_price, category, uuid, modified_at, sync_version) - VALUES (?, ?, ?, ?, ?, NOW(), 1) - '); - $stmt->execute([ - $input['name'], - $input['description'] ?? '', - $input['default_price'] ?? 0, - $input['category'] ?? '', - $uuid, - ]); - $newId = (int)$pdo->lastInsertId(); - - AuditLog::logCreate( - 'offers_item_template', - (int)$newId, - ['name' => $input['name']], - "Vytvořena šablona položky '{$input['name']}'" - ); - - successResponse(['id' => $newId], 'Šablona byla vytvořena'); - } -} - -function handleDeleteItemTemplate(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT name FROM item_templates WHERE id = ?'); - $stmt->execute([$id]); - $template = $stmt->fetch(); - - if (!$template) { - errorResponse('Šablona nebyla nalezena', 404); - } - - $stmt = $pdo->prepare('DELETE FROM item_templates WHERE id = ?'); - $stmt->execute([$id]); - - - AuditLog::logDelete( - 'offers_item_template', - $id, - ['name' => $template['name']], - "Smazána šablona položky '{$template['name']}'" - ); - - successResponse(null, 'Šablona byla smazána'); -} - -// --- Scope Templates --- - -function handleGetScopeTemplates(PDO $pdo): void -{ - $stmt = $pdo->query('SELECT * FROM scope_templates ORDER BY name'); - successResponse(['templates' => $stmt->fetchAll()]); -} - -function handleGetScopeDetail(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM scope_templates WHERE id = ?'); - $stmt->execute([$id]); - $template = $stmt->fetch(); - - if (!$template) { - errorResponse('Šablona nebyla nalezena', 404); - } - - $stmt = $pdo->prepare('SELECT * FROM scope_template_sections WHERE scope_template_id = ? ORDER BY position'); - $stmt->execute([$id]); - $template['sections'] = $stmt->fetchAll(); - - successResponse($template); -} - -function handleSaveScopeTemplate(PDO $pdo): void -{ - $input = getJsonInput(); - - if (empty($input['name'])) { - errorResponse('Název šablony je povinný'); - } - - $id = isset($input['id']) ? (int)$input['id'] : null; - $sections = $input['sections'] ?? []; - - $pdo->beginTransaction(); - try { - if ($id) { - // Update template - $stmt = $pdo->prepare(' - UPDATE scope_templates SET - name = ?, - title = ?, - description = ?, - modified_at = NOW(), - sync_version = sync_version + 1 - WHERE id = ? - '); - $stmt->execute([ - $input['name'], - $input['title'] ?? '', - $input['description'] ?? '', - $id, - ]); - } else { - // Create template - $uuid = sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0x0fff) | 0x4000, - random_int(0, 0x3fff) | 0x8000, - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff) - ); - - $stmt = $pdo->prepare(' - INSERT INTO scope_templates (name, title, description, uuid, modified_at, sync_version) - VALUES (?, ?, ?, ?, NOW(), 1) - '); - $stmt->execute([ - $input['name'], - $input['title'] ?? '', - $input['description'] ?? '', - $uuid, - ]); - $id = (int)$pdo->lastInsertId(); - } - - // Delete existing sections and re-insert - $stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?'); - $stmt->execute([$id]); - - $stmt = $pdo->prepare(' - INSERT INTO scope_template_sections - (scope_template_id, title, title_cz, content, position, uuid, modified_at, sync_version) - VALUES (?, ?, ?, ?, ?, ?, NOW(), 1) - '); - - foreach ($sections as $i => $section) { - $sectionUuid = sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0x0fff) | 0x4000, - random_int(0, 0x3fff) | 0x8000, - random_int(0, 0xffff), - random_int(0, 0xffff), - random_int(0, 0xffff) - ); - $stmt->execute([ - $id, - $section['title'] ?? '', - $section['title_cz'] ?? '', - $section['content'] ?? '', - $i + 1, - $sectionUuid, - ]); - } - - $pdo->commit(); - - AuditLog::logCreate( - 'offers_scope_template', - $id, - ['name' => $input['name']], - "Uložena šablona rozsahu '{$input['name']}'" - ); - - successResponse(['id' => $id], 'Šablona rozsahu byla uložena'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleDeleteScopeTemplate(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT name FROM scope_templates WHERE id = ?'); - $stmt->execute([$id]); - $template = $stmt->fetch(); - - if (!$template) { - errorResponse('Šablona nebyla nalezena', 404); - } - - $pdo->beginTransaction(); - try { - // Delete sections - $stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?'); - $stmt->execute([$id]); - - // Delete template - $stmt = $pdo->prepare('DELETE FROM scope_templates WHERE id = ?'); - $stmt->execute([$id]); - - $pdo->commit(); - - AuditLog::logDelete( - 'offers_scope_template', - $id, - ['name' => $template['name']], - "Smazána šablona rozsahu '{$template['name']}'" - ); - - successResponse(null, 'Šablona rozsahu byla smazána'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} diff --git a/api/admin/offers.php b/api/admin/offers.php index 861aec6..e08b041 100644 --- a/api/admin/offers.php +++ b/api/admin/offers.php @@ -18,6 +18,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/offers-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -92,588 +93,3 @@ try { errorResponse('Chyba databáze', 500); } } - -function handleGetList(PDO $pdo): void -{ - $search = trim($_GET['search'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); - - $sortMap = [ - 'Date' => 'q.created_at', - 'CreatedAt' => 'q.created_at', - 'created_at' => 'q.created_at', - 'QuotationNumber' => 'q.quotation_number', - 'quotation_number' => 'q.quotation_number', - 'ProjectCode' => 'q.project_code', - 'project_code' => 'q.project_code', - 'ValidUntil' => 'q.valid_until', - 'valid_until' => 'q.valid_until', - 'Currency' => 'q.currency', - 'currency' => 'q.currency', - ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; - - $where = 'WHERE 1=1'; - $params = []; - - if ($search) { - $search = mb_substr($search, 0, 100); - $where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; - $searchParam = "%{$search}%"; - $params = [$searchParam, $searchParam, $searchParam]; - } - - // Celkovy pocet pro pagination - $countSql = " - SELECT COUNT(*) - FROM quotations q - LEFT JOIN customers c ON q.customer_id = c.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); - - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until, - q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate, - q.customer_id, q.order_id, q.status, - c.name as customer_name, - (SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0) - FROM quotation_items qi WHERE qi.quotation_id = q.id) as total - FROM quotations q - LEFT JOIN customers c ON q.customer_id = c.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $quotations = $stmt->fetchAll(); - - successResponse([ - 'quotations' => $quotations, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, - ]); -} - -function handleGetDetail(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare(' - SELECT q.*, c.name as customer_name - FROM quotations q - LEFT JOIN customers c ON q.customer_id = c.id - WHERE q.id = ? - '); - $stmt->execute([$id]); - $quotation = $stmt->fetch(); - - if (!$quotation) { - errorResponse('Nabídka nebyla nalezena', 404); - } - - // Get items - $stmt = $pdo->prepare(' - SELECT * FROM quotation_items - WHERE quotation_id = ? - ORDER BY position - '); - $stmt->execute([$id]); - $quotation['items'] = $stmt->fetchAll(); - - // Get scope sections - $stmt = $pdo->prepare(' - SELECT * FROM scope_sections - WHERE quotation_id = ? - ORDER BY position - '); - $stmt->execute([$id]); - $quotation['sections'] = $stmt->fetchAll(); - - // Get customer - if ($quotation['customer_id']) { - $stmt = $pdo->prepare( - 'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields - FROM customers WHERE id = ?' - ); - $stmt->execute([$quotation['customer_id']]); - $quotation['customer'] = $stmt->fetch(); - } - - // Get linked order info - if ($quotation['order_id']) { - $stmt = $pdo->prepare('SELECT id, order_number, status FROM orders WHERE id = ?'); - $stmt->execute([$quotation['order_id']]); - $quotation['order'] = $stmt->fetch() ?: null; - } else { - $quotation['order'] = null; - } - - successResponse($quotation); -} - -function handleGetNextNumber(PDO $pdo): void -{ - $settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch(); - if (!$settings) { - errorResponse('Nastavení firmy nenalezeno'); - } - - $year = date('Y'); - $prefix = $settings['quotation_prefix'] ?: 'N'; - $number = getMaxQuotationNumber($pdo, $year, $prefix) + 1; - - $formatted = sprintf('%s/%s/%03d', $year, $prefix, $number); - - successResponse([ - 'number' => $formatted, - 'raw_number' => $number, - 'prefix' => $prefix, - 'year' => $year, - ]); -} - -function getMaxQuotationNumber(PDO $pdo, string $year, string $prefix): int -{ - $likePattern = "{$year}/{$prefix}/%"; - $stmt = $pdo->prepare(" - SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) - FROM quotations - WHERE quotation_number LIKE ? - "); - $stmt->execute([$likePattern]); - return (int) $stmt->fetchColumn(); -} - -function generateNextNumber(PDO $pdo): string -{ - $settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch(); - - $year = date('Y'); - $prefix = $settings['quotation_prefix'] ?: 'N'; - $number = getMaxQuotationNumber($pdo, $year, $prefix) + 1; - - return sprintf('%s/%s/%03d', $year, $prefix, $number); -} - -/** @param array $q */ -function validateQuotationInput(array $q): void -{ - if (empty($q['customer_id'])) { - errorResponse('Vyberte zákazníka'); - } - if (empty($q['created_at'])) { - errorResponse('Zadejte datum vytvoření'); - } - if (empty($q['valid_until'])) { - errorResponse('Zadejte datum platnosti'); - } - if (!empty($q['created_at']) && !empty($q['valid_until']) && $q['valid_until'] < $q['created_at']) { - errorResponse('Datum platnosti nesmí být před datem vytvoření'); - } - if (empty($q['currency'])) { - errorResponse('Vyberte měnu'); - } - - // Validace formatu dat - foreach (['created_at', 'valid_until'] as $dateField) { - if (!empty($q[$dateField]) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $q[$dateField])) { - errorResponse("Neplatný formát data: $dateField"); - } - } - // Validace meny a jazyka - if (!in_array($q['currency'] ?? '', ['EUR', 'USD', 'CZK', 'GBP'])) { - errorResponse('Neplatná měna'); - } - if (!empty($q['language']) && !in_array($q['language'], ['EN', 'CZ'])) { - errorResponse('Neplatný jazyk'); - } - // Validace DPH - if (isset($q['vat_rate'])) { - $rate = floatval($q['vat_rate']); - if ($rate < 0 || $rate > 100) { - errorResponse('Sazba DPH musí být mezi 0 a 100'); - } - } - // Delkove limity - if (!empty($q['project_code']) && mb_strlen($q['project_code']) > 100) { - errorResponse('Kód projektu je příliš dlouhý (max 100 znaků)'); - } -} - -function handleCreateOffer(PDO $pdo): void -{ - $input = getJsonInput(); - $quotation = $input['quotation'] ?? $input; - $items = $input['items'] ?? []; - $sections = $input['sections'] ?? []; - - validateQuotationInput($quotation); - - // Serialize number generation across concurrent requests - $locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn(); - if (!$locked) { - errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503); - } - - $pdo->beginTransaction(); - try { - $quotationNumber = generateNextNumber($pdo); - - $stmt = $pdo->prepare(' - INSERT INTO quotations ( - quotation_number, project_code, customer_id, created_at, valid_until, - currency, language, vat_rate, apply_vat, exchange_rate, - scope_title, scope_description, modified_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) - '); - - $stmt->execute([ - $quotationNumber, - $quotation['project_code'] ?? '', - $quotation['customer_id'] ? (int)$quotation['customer_id'] : null, - $quotation['created_at'] ?? date('Y-m-d H:i:s'), - $quotation['valid_until'] ?? date('Y-m-d H:i:s', strtotime('+30 days')), - $quotation['currency'] ?? 'EUR', - $quotation['language'] ?? 'EN', - $quotation['vat_rate'] ?? 21, - isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : 0, - $quotation['exchange_rate'] ?? null, - $quotation['scope_title'] ?? '', - $quotation['scope_description'] ?? '', - ]); - - $quotationId = (int)$pdo->lastInsertId(); - - saveItems($pdo, $quotationId, $items); - saveSections($pdo, $quotationId, $sections); - - - $pdo->commit(); - $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); - - AuditLog::logCreate('offers_quotation', $quotationId, [ - 'quotation_number' => $quotationNumber, - 'project_code' => $quotation['project_code'] ?? '', - ], "Vytvořena nabídka '$quotationNumber'"); - - successResponse([ - 'id' => $quotationId, - 'number' => $quotationNumber, - ], 'Nabídka byla vytvořena'); - } catch (PDOException $e) { - $pdo->rollBack(); - $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); - throw $e; - } -} - -function handleUpdateOffer(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); - $stmt->execute([$id]); - $existing = $stmt->fetch(); - - if (!$existing) { - errorResponse('Nabídka nebyla nalezena', 404); - } - - if ($existing['status'] === 'invalidated') { - errorResponse('Zneplatněnou nabídku nelze upravovat', 403); - } - - $input = getJsonInput(); - $quotation = $input['quotation'] ?? $input; - $items = $input['items'] ?? []; - $sections = $input['sections'] ?? []; - - validateQuotationInput($quotation); - - $pdo->beginTransaction(); - try { - $stmt = $pdo->prepare(' - UPDATE quotations SET - project_code = ?, - customer_id = ?, - created_at = ?, - valid_until = ?, - currency = ?, - language = ?, - vat_rate = ?, - apply_vat = ?, - exchange_rate = ?, - scope_title = ?, - scope_description = ?, - modified_at = NOW() - WHERE id = ? - '); - - $stmt->execute([ - $quotation['project_code'] ?? $existing['project_code'], - isset($quotation['customer_id']) - ? ($quotation['customer_id'] ? (int)$quotation['customer_id'] : null) - : $existing['customer_id'], - $quotation['created_at'] ?? $existing['created_at'], - $quotation['valid_until'] ?? $existing['valid_until'], - $quotation['currency'] ?? $existing['currency'], - $quotation['language'] ?? $existing['language'], - $quotation['vat_rate'] ?? $existing['vat_rate'], - isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : $existing['apply_vat'], - array_key_exists('exchange_rate', $quotation) ? $quotation['exchange_rate'] : $existing['exchange_rate'], - $quotation['scope_title'] ?? $existing['scope_title'], - $quotation['scope_description'] ?? $existing['scope_description'], - $id, - ]); - - // Replace items - $stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?'); - $stmt->execute([$id]); - saveItems($pdo, $id, $items); - - // Replace sections - $stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?'); - $stmt->execute([$id]); - saveSections($pdo, $id, $sections); - - - $pdo->commit(); - - AuditLog::logUpdate( - 'offers_quotation', - $id, - ['quotation_number' => $existing['quotation_number']], - ['project_code' => $quotation['project_code'] ?? $existing['project_code']], - "Upravena nabídka '{$existing['quotation_number']}'" - ); - - successResponse(null, 'Nabídka byla aktualizována'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleDuplicate(PDO $pdo, int $sourceId): void -{ - $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); - $stmt->execute([$sourceId]); - $source = $stmt->fetch(); - - if (!$source) { - errorResponse('Zdrojová nabídka nebyla nalezena', 404); - } - - $stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); - $stmt->execute([$sourceId]); - $sourceItems = $stmt->fetchAll(); - - $stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); - $stmt->execute([$sourceId]); - $sourceSections = $stmt->fetchAll(); - - $locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn(); - if (!$locked) { - errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503); - } - - $pdo->beginTransaction(); - try { - $newNumber = generateNextNumber($pdo); - - $stmt = $pdo->prepare(' - INSERT INTO quotations ( - quotation_number, project_code, customer_id, created_at, valid_until, - currency, language, vat_rate, apply_vat, exchange_rate, - scope_title, scope_description, modified_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) - '); - - $stmt->execute([ - $newNumber, - $source['project_code'], - $source['customer_id'], - date('Y-m-d H:i:s'), - date('Y-m-d H:i:s', strtotime('+30 days')), - $source['currency'], - $source['language'], - $source['vat_rate'], - $source['apply_vat'], - $source['exchange_rate'], - $source['scope_title'], - $source['scope_description'], - ]); - - $newId = (int)$pdo->lastInsertId(); - - $items = array_map(function ($item) { - return [ - 'description' => $item['description'], - 'item_description' => $item['item_description'], - 'quantity' => $item['quantity'], - 'unit_price' => $item['unit_price'], - 'is_included_in_total' => $item['is_included_in_total'], - 'position' => $item['position'], - ]; - }, $sourceItems); - saveItems($pdo, $newId, $items); - - $sections = array_map(function ($section) { - return [ - 'title' => $section['title'], - 'title_cz' => $section['title_cz'], - 'content' => $section['content'], - 'position' => $section['position'], - ]; - }, $sourceSections); - saveSections($pdo, $newId, $sections); - - $pdo->commit(); - $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); - - AuditLog::logCreate('offers_quotation', $newId, [ - 'quotation_number' => $newNumber, - 'duplicated_from' => $source['quotation_number'], - ], "Duplikována nabídka '{$source['quotation_number']}' jako '$newNumber'"); - - successResponse([ - 'id' => $newId, - 'number' => $newNumber, - ], 'Nabídka byla duplikována'); - } catch (PDOException $e) { - $pdo->rollBack(); - $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); - throw $e; - } -} - -function handleInvalidateOffer(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT quotation_number, status, order_id FROM quotations WHERE id = ?'); - $stmt->execute([$id]); - $quotation = $stmt->fetch(); - - if (!$quotation) { - errorResponse('Nabídka nebyla nalezena', 404); - } - - if ($quotation['status'] === 'invalidated') { - errorResponse('Nabídka je již zneplatněna', 400); - } - - if ($quotation['order_id']) { - errorResponse('Nabídku s objednávkou nelze zneplatnit', 400); - } - - $stmt = $pdo->prepare('UPDATE quotations SET status = ?, modified_at = NOW() WHERE id = ?'); - $stmt->execute(['invalidated', $id]); - - AuditLog::logUpdate( - 'offers_quotation', - $id, - ['status' => 'active'], - ['status' => 'invalidated'], - "Zneplatněna nabídka '{$quotation['quotation_number']}'" - ); - - successResponse(null, 'Nabídka byla zneplatněna'); -} - -function handleDeleteQuotation(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT quotation_number FROM quotations WHERE id = ?'); - $stmt->execute([$id]); - $quotation = $stmt->fetch(); - - - if (!$quotation) { - errorResponse('Nabídka nebyla nalezena', 404); - } - - $pdo->beginTransaction(); - try { - $stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?'); - $stmt->execute([$id]); - - $stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?'); - $stmt->execute([$id]); - - $stmt = $pdo->prepare('DELETE FROM quotations WHERE id = ?'); - $stmt->execute([$id]); - - $pdo->commit(); - - AuditLog::logDelete('offers_quotation', $id, [ - 'quotation_number' => $quotation['quotation_number'], - ], "Smazána nabídka '{$quotation['quotation_number']}'"); - - successResponse(null, 'Nabídka byla smazána'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -// --- Helpers --- - -/** @param list> $items */ -function saveItems(PDO $pdo, int $quotationId, array $items): void -{ - if (empty($items)) { - return; - } - - $stmt = $pdo->prepare(' - INSERT INTO quotation_items ( - quotation_id, description, item_description, quantity, unit, - unit_price, is_included_in_total, position, modified_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) - '); - - foreach ($items as $i => $item) { - $stmt->execute([ - $quotationId, - $item['description'] ?? '', - $item['item_description'] ?? '', - $item['quantity'] ?? 1, - $item['unit'] ?? '', - $item['unit_price'] ?? 0, - isset($item['is_included_in_total']) ? ($item['is_included_in_total'] ? 1 : 0) : 1, - $item['position'] ?? ($i + 1), - ]); - } -} - -/** @param list> $sections */ -function saveSections(PDO $pdo, int $quotationId, array $sections): void -{ - if (empty($sections)) { - return; - } - - $stmt = $pdo->prepare(' - INSERT INTO scope_sections ( - quotation_id, title, title_cz, content, position, modified_at - ) VALUES (?, ?, ?, ?, ?, NOW()) - '); - - foreach ($sections as $i => $section) { - $stmt->execute([ - $quotationId, - $section['title'] ?? '', - $section['title_cz'] ?? '', - $section['content'] ?? '', - $section['position'] ?? ($i + 1), - ]); - } -} diff --git a/api/admin/orders.php b/api/admin/orders.php index bd9fed0..09318c6 100644 --- a/api/admin/orders.php +++ b/api/admin/orders.php @@ -15,6 +15,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/orders-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -88,528 +89,3 @@ try { // --- Valid status transitions --- /** @return list */ -function getValidTransitions(string $currentStatus): array -{ - $map = [ - 'prijata' => ['v_realizaci', 'stornovana'], - 'v_realizaci' => ['dokoncena', 'stornovana'], - 'dokoncena' => [], - 'stornovana' => [], - ]; - return $map[$currentStatus] ?? []; -} - -// --- Number generation --- - -function generateOrderNumber(PDO $pdo): string -{ - return generateSharedNumber($pdo); -} - -// --- Handlers --- - -function handleGetList(PDO $pdo): void -{ - $search = trim($_GET['search'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); - - $sortMap = [ - 'OrderNumber' => 'o.order_number', - 'order_number' => 'o.order_number', - 'CreatedAt' => 'o.created_at', - 'created_at' => 'o.created_at', - 'Status' => 'o.status', - 'status' => 'o.status', - 'Currency' => 'o.currency', - 'currency' => 'o.currency', - ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; - - $where = 'WHERE 1=1'; - $params = []; - - if ($search) { - $search = mb_substr($search, 0, 100); - $where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; - $searchParam = "%{$search}%"; - $params = [$searchParam, $searchParam, $searchParam, $searchParam]; - } - - $countSql = " - SELECT COUNT(*) - FROM orders o - LEFT JOIN quotations q ON o.quotation_id = q.id - LEFT JOIN customers c ON o.customer_id = c.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); - - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency, - o.created_at, o.apply_vat, o.vat_rate, - q.quotation_number, q.project_code, - c.name as customer_name, - (SELECT COALESCE(SUM(CASE WHEN oi.is_included_in_total THEN oi.quantity * oi.unit_price ELSE 0 END), 0) - FROM order_items oi WHERE oi.order_id = o.id) as total, - (SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id, - (SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number - FROM orders o - LEFT JOIN quotations q ON o.quotation_id = q.id - LEFT JOIN customers c ON o.customer_id = c.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $orders = $stmt->fetchAll(); - - successResponse([ - 'orders' => $orders, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, - ]); -} - -function handleGetDetail(PDO $pdo, int $id): void -{ - // BLOB vynechany - stahuje se pres action=attachment - $stmt = $pdo->prepare(' - SELECT o.id, o.order_number, o.customer_order_number, o.attachment_name, - o.quotation_id, o.customer_id, o.status, o.currency, o.language, - o.vat_rate, o.apply_vat, o.exchange_rate, o.scope_title, o.scope_description, - o.notes, o.created_at, o.modified_at, - q.quotation_number, q.project_code, - c.name as customer_name - FROM orders o - LEFT JOIN quotations q ON o.quotation_id = q.id - LEFT JOIN customers c ON o.customer_id = c.id - WHERE o.id = ? - '); - $stmt->execute([$id]); - $order = $stmt->fetch(); - - if (!$order) { - errorResponse('Objednávka nebyla nalezena', 404); - } - - // Get items - $stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position'); - $stmt->execute([$id]); - $order['items'] = $stmt->fetchAll(); - - // Get sections - $stmt = $pdo->prepare('SELECT * FROM order_sections WHERE order_id = ? ORDER BY position'); - $stmt->execute([$id]); - $order['sections'] = $stmt->fetchAll(); - - // Get customer - if ($order['customer_id']) { - $stmt = $pdo->prepare( - 'SELECT id, name, company_id, vat_id, street, city, - postal_code, country, custom_fields - FROM customers WHERE id = ?' - ); - $stmt->execute([$order['customer_id']]); - $order['customer'] = $stmt->fetch(); - } - - // Get linked project - $stmt = $pdo->prepare('SELECT id, project_number, name, status FROM projects WHERE order_id = ?'); - $stmt->execute([$id]); - $order['project'] = $stmt->fetch() ?: null; - - // Get linked invoice - $stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1'); - $stmt->execute([$id]); - $order['invoice'] = $stmt->fetch() ?: null; - - // Valid transitions - $order['valid_transitions'] = getValidTransitions($order['status']); - - successResponse($order); -} - -function handleGetAttachment(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT attachment_data, attachment_name FROM orders WHERE id = ?'); - $stmt->execute([$id]); - $row = $stmt->fetch(); - - if (!$row || !$row['attachment_data']) { - errorResponse('Příloha nebyla nalezena', 404); - } - - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mime = $finfo->buffer($row['attachment_data']); - if ($mime !== 'application/pdf') { - errorResponse('Příloha není platný PDF soubor', 415); - } - - header_remove('Content-Type'); - header('Content-Type: application/pdf'); - $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['attachment_name'] ?: 'priloha.pdf')); - header('Content-Disposition: attachment; filename="' . $safeName . '"'); - header('Content-Length: ' . strlen($row['attachment_data'])); - echo $row['attachment_data']; - exit; -} - -function handleCreateOrder(PDO $pdo): void -{ - // Podporuje JSON i FormData (kvuli nahravani prilohy) - $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; - if (str_contains($contentType, 'multipart/form-data')) { - $quotationId = (int)($_POST['quotationId'] ?? 0); - $customerOrderNumber = trim($_POST['customerOrderNumber'] ?? ''); - } else { - $input = getJsonInput(); - $quotationId = (int)($input['quotationId'] ?? 0); - $customerOrderNumber = trim($input['customerOrderNumber'] ?? ''); - } - - if (!$quotationId) { - errorResponse('ID nabídky je povinné'); - } - - if ($customerOrderNumber === '') { - errorResponse('Číslo objednávky zákazníka je povinné'); - } - if (mb_strlen($customerOrderNumber) > 100) { - errorResponse('Číslo objednávky zákazníka je příliš dlouhé (max 100 znaků)'); - } - - // Validace prilohy - $attachmentData = null; - $attachmentName = null; - if (!empty($_FILES['attachment']['tmp_name'])) { - $file = $_FILES['attachment']; - if ($file['error'] !== UPLOAD_ERR_OK) { - errorResponse('Chyba při nahrávání souboru'); - } - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mime = $finfo->file($file['tmp_name']); - if ($mime !== 'application/pdf') { - errorResponse('Příloha musí být ve formátu PDF'); - } - if ($file['size'] > 10 * 1024 * 1024) { - errorResponse('Příloha nesmí být větší než 10 MB'); - } - $attachmentData = file_get_contents($file['tmp_name']); - $attachmentName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name'])); - } - - // Verify quotation exists and has no order yet - $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); - $stmt->execute([$quotationId]); - $quotation = $stmt->fetch(); - - if (!$quotation) { - errorResponse('Nabídka nebyla nalezena', 404); - } - - if ($quotation['order_id']) { - errorResponse('Tato nabídka již má objednávku'); - } - - // Get quotation items and sections - $stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); - $stmt->execute([$quotationId]); - $quotationItems = $stmt->fetchAll(); - - $stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); - $stmt->execute([$quotationId]); - $quotationSections = $stmt->fetchAll(); - - // Lock for concurrent number generation - $locked = $pdo->query("SELECT GET_LOCK('boha_order_number', 5)")->fetchColumn(); - if (!$locked) { - errorResponse('Nepodařilo se získat zámek pro číslo objednávky, zkuste to znovu', 503); - } - - $pdo->beginTransaction(); - try { - $orderNumber = generateOrderNumber($pdo); - - $stmt = $pdo->prepare(" - INSERT INTO orders ( - order_number, customer_order_number, attachment_data, attachment_name, - quotation_id, customer_id, status, - currency, language, vat_rate, apply_vat, exchange_rate, - scope_title, scope_description, created_at, modified_at - ) VALUES (?, ?, ?, ?, ?, ?, 'prijata', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) - "); - $stmt->execute([ - $orderNumber, - $customerOrderNumber, - $attachmentData, - $attachmentName, - $quotationId, - $quotation['customer_id'], - $quotation['currency'] ?? 'EUR', - $quotation['language'] ?? 'EN', - $quotation['vat_rate'] ?? 0, - $quotation['apply_vat'] ?? 0, - $quotation['exchange_rate'], - $quotation['scope_title'] ?? '', - $quotation['scope_description'] ?? '', - ]); - $orderId = (int)$pdo->lastInsertId(); - - // Copy items - if (!empty($quotationItems)) { - $itemStmt = $pdo->prepare(' - INSERT INTO order_items ( - order_id, description, item_description, quantity, unit, - unit_price, is_included_in_total, position, modified_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) - '); - foreach ($quotationItems as $item) { - $itemStmt->execute([ - $orderId, - $item['description'] ?? '', - $item['item_description'] ?? '', - $item['quantity'] ?? 1, - $item['unit'] ?? '', - $item['unit_price'] ?? 0, - $item['is_included_in_total'] ?? 1, - $item['position'] ?? 0, - ]); - } - } - - // Copy sections - if (!empty($quotationSections)) { - $sectionStmt = $pdo->prepare(' - INSERT INTO order_sections ( - order_id, title, title_cz, content, position, modified_at - ) VALUES (?, ?, ?, ?, ?, NOW()) - '); - foreach ($quotationSections as $section) { - $sectionStmt->execute([ - $orderId, - $section['title'] ?? '', - $section['title_cz'] ?? '', - $section['content'] ?? '', - $section['position'] ?? 0, - ]); - } - } - - // Create project with same number - $projectName = $quotation['project_code'] ?: ($quotation['customer_name'] ?? 'Projekt ' . $orderNumber); - // Need customer name - if (!$quotation['project_code'] && $quotation['customer_id']) { - $custStmt = $pdo->prepare('SELECT name FROM customers WHERE id = ?'); - $custStmt->execute([$quotation['customer_id']]); - $custName = $custStmt->fetchColumn(); - if ($custName) { - $projectName = $custName; - } - } - - $stmt = $pdo->prepare(" - INSERT INTO projects ( - project_number, name, customer_id, quotation_id, order_id, - status, start_date, created_at, modified_at - ) VALUES (?, ?, ?, ?, ?, 'aktivni', CURDATE(), NOW(), NOW()) - "); - $stmt->execute([ - $orderNumber, - $projectName, - $quotation['customer_id'], - $quotationId, - $orderId, - ]); - $projectId = (int)$pdo->lastInsertId(); - - // Update quotation with back-reference - $stmt = $pdo->prepare('UPDATE quotations SET order_id = ?, modified_at = NOW() WHERE id = ?'); - $stmt->execute([$orderId, $quotationId]); - - - $pdo->commit(); - $pdo->query("SELECT RELEASE_LOCK('boha_order_number')"); - - AuditLog::logCreate('orders_order', $orderId, [ - 'order_number' => $orderNumber, - 'quotation_number' => $quotation['quotation_number'], - 'project_id' => $projectId, - ], "Vytvořena objednávka '$orderNumber' z nabídky '{$quotation['quotation_number']}'"); - - successResponse([ - 'order_id' => $orderId, - 'order_number' => $orderNumber, - 'project_id' => $projectId, - 'project_number' => $orderNumber, - ], 'Objednávka byla vytvořena'); - } catch (PDOException $e) { - $pdo->rollBack(); - $pdo->query("SELECT RELEASE_LOCK('boha_order_number')"); - throw $e; - } -} - -function handleUpdateOrder(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?'); - $stmt->execute([$id]); - $order = $stmt->fetch(); - - if (!$order) { - errorResponse('Objednávka nebyla nalezena', 404); - } - - $input = getJsonInput(); - $newStatus = $input['status'] ?? null; - $notes = $input['notes'] ?? null; - $newOrderNumber = isset($input['order_number']) ? trim($input['order_number']) : null; - - // Delkove limity - if ($notes !== null && mb_strlen($notes) > 5000) { - errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); - } - if ($newOrderNumber !== null && mb_strlen($newOrderNumber) > 50) { - errorResponse('Číslo objednávky je příliš dlouhé (max 50 znaků)'); - } - - // Validate status transition - if ($newStatus && $newStatus !== $order['status']) { - $valid = getValidTransitions($order['status']); - if (!in_array($newStatus, $valid)) { - errorResponse("Neplatný přechod stavu z '{$order['status']}' na '$newStatus'"); - } - } - - // Validate order number uniqueness - if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) { - if (empty($newOrderNumber)) { - errorResponse('Číslo objednávky nesmí být prázdné'); - } - $stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ? AND id != ?'); - $stmt->execute([$newOrderNumber, $id]); - if ($stmt->fetch()) { - errorResponse('Toto číslo objednávky již existuje'); - } - } - - $pdo->beginTransaction(); - try { - $updates = []; - $params = []; - - if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) { - $updates[] = 'order_number = ?'; - $params[] = $newOrderNumber; - - // Sync project number - $stmt = $pdo->prepare('UPDATE projects SET project_number = ?, modified_at = NOW() WHERE order_id = ?'); - $stmt->execute([$newOrderNumber, $id]); - } - if ($newStatus !== null) { - $updates[] = 'status = ?'; - $params[] = $newStatus; - } - if ($notes !== null) { - $updates[] = 'notes = ?'; - $params[] = $notes; - } - - if (!empty($updates)) { - $updates[] = 'modified_at = NOW()'; - $params[] = $id; - $sql = 'UPDATE orders SET ' . implode(', ', $updates) . ' WHERE id = ?'; - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - } - - // Sync project status with order status - if ($newStatus && $newStatus !== $order['status']) { - $projectStatus = null; - if ($newStatus === 'stornovana') { - $projectStatus = 'zruseny'; - } elseif ($newStatus === 'dokoncena') { - $projectStatus = 'dokonceny'; - } elseif ($newStatus === 'v_realizaci') { - $projectStatus = 'aktivni'; - } - - if ($projectStatus) { - $stmt = $pdo->prepare('UPDATE projects SET status = ?, modified_at = NOW() WHERE order_id = ?'); - $stmt->execute([$projectStatus, $id]); - } - } - - $pdo->commit(); - - AuditLog::logUpdate( - 'orders_order', - $id, - ['status' => $order['status'], 'notes' => $order['notes']], - ['status' => $newStatus ?? $order['status'], 'notes' => $notes ?? $order['notes']], - "Upravena objednávka '{$order['order_number']}'" - ); - - successResponse(null, 'Objednávka byla aktualizována'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleDeleteOrder(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?'); - $stmt->execute([$id]); - $order = $stmt->fetch(); - - if (!$order) { - errorResponse('Objednávka nebyla nalezena', 404); - } - - $pdo->beginTransaction(); - try { - // Delete project linked to this order - $stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?'); - $stmt->execute([$id]); - - // Delete order items and sections - $stmt = $pdo->prepare('DELETE FROM order_items WHERE order_id = ?'); - $stmt->execute([$id]); - - $stmt = $pdo->prepare('DELETE FROM order_sections WHERE order_id = ?'); - $stmt->execute([$id]); - - // Delete order - $stmt = $pdo->prepare('DELETE FROM orders WHERE id = ?'); - $stmt->execute([$id]); - - // Remove back-reference from quotation - $stmt = $pdo->prepare('UPDATE quotations SET order_id = NULL, modified_at = NOW() WHERE order_id = ?'); - $stmt->execute([$id]); - - $pdo->commit(); - - AuditLog::logDelete('orders_order', $id, [ - 'order_number' => $order['order_number'], - 'quotation_id' => $order['quotation_id'], - ], "Smazána objednávka '{$order['order_number']}'"); - - successResponse(null, 'Objednávka byla smazána'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} diff --git a/api/admin/projects.php b/api/admin/projects.php index 8b85d2a..dd061ed 100644 --- a/api/admin/projects.php +++ b/api/admin/projects.php @@ -18,6 +18,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/projects-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -111,423 +112,3 @@ try { } // --- Number generation --- - -function generateProjectNumber(PDO $pdo): string -{ - return generateSharedNumber($pdo); -} - -function handleGetNextNumber(PDO $pdo): void -{ - $number = generateProjectNumber($pdo); - successResponse(['number' => $number]); -} - -function handleCreateProject(PDO $pdo): void -{ - $input = getJsonInput(); - - $name = trim($input['name'] ?? ''); - if (!$name) { - errorResponse('Název projektu je povinný'); - } - if (mb_strlen($name) > 255) { - errorResponse('Název projektu je příliš dlouhý (max 255 znaků)'); - } - - $customerId = isset($input['customer_id']) ? (int)$input['customer_id'] : null; - if (!$customerId) { - errorResponse('Zákazník je povinný'); - } - - // Verify customer exists - $stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); - $stmt->execute([$customerId]); - if (!$stmt->fetch()) { - errorResponse('Zákazník nebyl nalezen', 404); - } - - $startDate = $input['start_date'] ?? date('Y-m-d'); - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { - errorResponse('Neplatný formát data zahájení'); - } - - $projectNumber = trim($input['project_number'] ?? ''); - if ($projectNumber && mb_strlen($projectNumber) > 50) { - errorResponse('Číslo projektu je příliš dlouhé (max 50 znaků)'); - } - - // Lock for concurrent number generation - $locked = $pdo->query("SELECT GET_LOCK('boha_project_number', 5)")->fetchColumn(); - if (!$locked) { - errorResponse('Nepodařilo se získat zámek pro číslo projektu, zkuste to znovu', 503); - } - - $pdo->beginTransaction(); - try { - // Generate or validate number - if (!$projectNumber) { - $projectNumber = generateProjectNumber($pdo); - } else { - // Validate uniqueness against both tables - $stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ?'); - $stmt->execute([$projectNumber]); - if ($stmt->fetch()) { - $pdo->rollBack(); - $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); - errorResponse('Číslo projektu je již použito jako číslo objednávky'); - } - - $stmt = $pdo->prepare('SELECT id FROM projects WHERE project_number = ?'); - $stmt->execute([$projectNumber]); - if ($stmt->fetch()) { - $pdo->rollBack(); - $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); - errorResponse('Číslo projektu je již použito'); - } - } - - $stmt = $pdo->prepare(" - INSERT INTO projects ( - project_number, name, customer_id, - status, start_date, created_at, modified_at - ) VALUES (?, ?, ?, 'aktivni', ?, NOW(), NOW()) - "); - $stmt->execute([ - $projectNumber, - $name, - $customerId, - $startDate, - ]); - $projectId = (int)$pdo->lastInsertId(); - - - $pdo->commit(); - $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); - - AuditLog::logCreate('projects_project', $projectId, [ - 'project_number' => $projectNumber, - 'name' => $name, - 'customer_id' => $customerId, - ], "Ručně vytvořen projekt '$projectNumber'"); - - successResponse([ - 'project_id' => $projectId, - 'project_number' => $projectNumber, - ], 'Projekt byl vytvořen'); - } catch (PDOException $e) { - $pdo->rollBack(); - $pdo->query("SELECT RELEASE_LOCK('boha_project_number')"); - throw $e; - } -} - -function handleDeleteProject(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?'); - $stmt->execute([$id]); - $project = $stmt->fetch(); - - if (!$project) { - errorResponse('Projekt nebyl nalezen', 404); - } - - // Only manually created projects (without order_id) can be deleted - if (!empty($project['order_id'])) { - errorResponse('Projekt propojený s objednávkou nelze smazat. Smažte objednávku.', 400); - } - - $pdo->beginTransaction(); - try { - // Delete project notes - $stmt = $pdo->prepare('DELETE FROM project_notes WHERE project_id = ?'); - $stmt->execute([$id]); - - // Delete project - $stmt = $pdo->prepare('DELETE FROM projects WHERE id = ?'); - $stmt->execute([$id]); - - $pdo->commit(); - - AuditLog::logUpdate( - 'projects_project', - $id, - ['status' => $project['status']], - ['status' => 'deleted'], - "Smazán ruční projekt '{$project['project_number']}'" - ); - - successResponse(null, 'Projekt byl smazán'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleGetList(PDO $pdo): void -{ - $search = trim($_GET['search'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - $page = max(1, (int) ($_GET['page'] ?? 1)); - $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); - - $sortMap = [ - 'ProjectNumber' => 'p.project_number', - 'project_number' => 'p.project_number', - 'Name' => 'p.name', - 'name' => 'p.name', - 'Status' => 'p.status', - 'status' => 'p.status', - 'StartDate' => 'p.start_date', - 'start_date' => 'p.start_date', - 'EndDate' => 'p.end_date', - 'end_date' => 'p.end_date', - 'CreatedAt' => 'p.created_at', - 'created_at' => 'p.created_at', - ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; - - $where = 'WHERE 1=1'; - $params = []; - - if ($search) { - $search = mb_substr($search, 0, 100); - $where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)'; - $searchParam = "%{$search}%"; - $params = [$searchParam, $searchParam, $searchParam]; - } - - $countSql = " - SELECT COUNT(*) - FROM projects p - LEFT JOIN customers c ON p.customer_id = c.id - LEFT JOIN orders o ON p.order_id = o.id - $where - "; - $stmt = $pdo->prepare($countSql); - $stmt->execute($params); - $total = (int) $stmt->fetchColumn(); - - $offset = ($page - 1) * $perPage; - - $sql = " - SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date, - p.order_id, p.quotation_id, p.created_at, - c.name as customer_name, - o.order_number - FROM projects p - LEFT JOIN customers c ON p.customer_id = c.id - LEFT JOIN orders o ON p.order_id = o.id - $where - ORDER BY $sortCol $order - LIMIT $perPage OFFSET $offset - "; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $projects = $stmt->fetchAll(); - - successResponse([ - 'projects' => $projects, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, - ]); -} - -function handleGetDetail(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare(' - SELECT p.*, - c.name as customer_name, - o.order_number, o.status as order_status, - q.quotation_number - FROM projects p - LEFT JOIN customers c ON p.customer_id = c.id - LEFT JOIN orders o ON p.order_id = o.id - LEFT JOIN quotations q ON p.quotation_id = q.id - WHERE p.id = ? - '); - $stmt->execute([$id]); - $project = $stmt->fetch(); - - if (!$project) { - errorResponse('Projekt nebyl nalezen', 404); - } - - successResponse($project); -} - -function handleUpdateProject(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?'); - $stmt->execute([$id]); - $project = $stmt->fetch(); - - if (!$project) { - errorResponse('Projekt nebyl nalezen', 404); - } - - $input = getJsonInput(); - - // Validace statusu - if (isset($input['status'])) { - $validStatuses = ['aktivni', 'dokonceny', 'zruseny']; - if (!in_array($input['status'], $validStatuses)) { - errorResponse('Neplatný stav projektu'); - } - } - - // Validace dat - if ( - isset($input['start_date']) - && $input['start_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue - && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['start_date']) - ) { - errorResponse('Neplatný formát data zahájení'); - } - if ( - isset($input['end_date']) - && $input['end_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue - && $input['end_date'] !== '' - && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['end_date']) - ) { - errorResponse('Neplatný formát data ukončení'); - } - - // Delkove limity - $name = $input['name'] ?? $project['name']; - if (mb_strlen($name) > 255) { - errorResponse('Název projektu je příliš dlouhý (max 255 znaků)'); - } - $notes = $input['notes'] ?? $project['notes']; - if ($notes !== null && mb_strlen($notes) > 5000) { - errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); - } - - $pdo->beginTransaction(); - try { - $stmt = $pdo->prepare(' - UPDATE projects SET - name = ?, - status = ?, - start_date = ?, - end_date = ?, - notes = ?, - modified_at = NOW() - WHERE id = ? - '); - $stmt->execute([ - $name, - $input['status'] ?? $project['status'], - $input['start_date'] ?? $project['start_date'], - $input['end_date'] ?? $project['end_date'], - $notes, - $id, - ]); - - $pdo->commit(); - - AuditLog::logUpdate( - 'projects_project', - $id, - ['name' => $project['name'], 'status' => $project['status']], - ['name' => $input['name'] ?? $project['name'], 'status' => $input['status'] ?? $project['status']], - "Upraven projekt '{$project['project_number']}'" - ); - - successResponse(null, 'Projekt byl aktualizován'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -function handleGetNotes(PDO $pdo, int $projectId): void -{ - // Verify project exists - $stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?'); - $stmt->execute([$projectId]); - if (!$stmt->fetch()) { - errorResponse('Projekt nebyl nalezen', 404); - } - - $stmt = $pdo->prepare(' - SELECT id, project_id, user_id, user_name, content, created_at - FROM project_notes - WHERE project_id = ? - ORDER BY created_at DESC - '); - $stmt->execute([$projectId]); - $notes = $stmt->fetchAll(); - - successResponse(['notes' => $notes]); -} - -/** @param array $authData */ -function handleAddNote(PDO $pdo, int $projectId, array $authData): void -{ - // Verify project exists - $stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?'); - $stmt->execute([$projectId]); - if (!$stmt->fetch()) { - errorResponse('Projekt nebyl nalezen', 404); - } - - $input = getJsonInput(); - $content = trim($input['content'] ?? ''); - - if (!$content) { - errorResponse('Text poznámky je povinný'); - } - - if (mb_strlen($content) > 5000) { - errorResponse('Poznámka je příliš dlouhá (max 5000 znaků)'); - } - - $userName = $authData['user']['full_name'] ?? $authData['user']['username'] ?? 'Neznámý'; - - $stmt = $pdo->prepare(' - INSERT INTO project_notes (project_id, user_id, user_name, content, created_at) - VALUES (?, ?, ?, ?, NOW()) - '); - $stmt->execute([$projectId, $authData['user_id'], $userName, $content]); - - $noteId = (int)$pdo->lastInsertId(); - - // Fetch the new note - $stmt = $pdo->prepare( - 'SELECT id, project_id, user_id, user_name, content, created_at FROM project_notes WHERE id = ?' - ); - $stmt->execute([$noteId]); - $note = $stmt->fetch(); - - successResponse(['note' => $note], 'Poznámka byla přidána'); -} - -/** @param array $authData */ -function handleDeleteNote(PDO $pdo, int $noteId, array $authData): void -{ - // Only admins can delete notes - $isAdmin = $authData['user']['is_admin'] ?? false; - if (!$isAdmin) { - errorResponse('Pouze administrátoři mohou mazat poznámky', 403); - } - - $stmt = $pdo->prepare('SELECT id, project_id, content FROM project_notes WHERE id = ?'); - $stmt->execute([$noteId]); - $note = $stmt->fetch(); - - if (!$note) { - errorResponse('Poznámka nebyla nalezena', 404); - } - - $stmt = $pdo->prepare('DELETE FROM project_notes WHERE id = ?'); - $stmt->execute([$noteId]); - - successResponse(null, 'Poznámka byla smazána'); -} diff --git a/api/admin/received-invoices.php b/api/admin/received-invoices.php index 0219c00..a5aac2f 100644 --- a/api/admin/received-invoices.php +++ b/api/admin/received-invoices.php @@ -18,6 +18,7 @@ require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/CnbRates.php'; +require_once __DIR__ . '/handlers/received-invoices-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -94,504 +95,3 @@ try { // --- Allowed MIME types --- /** @return list */ -function getAllowedMimes(): array -{ - return ['application/pdf', 'image/jpeg', 'image/png']; -} - -// --- Stats --- - -function handleGetStats(PDO $pdo): void -{ - $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); - $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); - - $monthStart = sprintf('%04d-%02d-01', $year, $month); - $monthEnd = date('Y-m-t', strtotime($monthStart)); - - // Celkem v měsíci (issue_date) - $stmt = $pdo->prepare(' - SELECT currency, SUM(amount) as total, SUM(vat_amount) as vat_total, COUNT(*) as cnt - FROM received_invoices - WHERE issue_date BETWEEN ? AND ? - GROUP BY currency - '); - $stmt->execute([$monthStart, $monthEnd]); - $monthRows = $stmt->fetchAll(); - - $totalAmounts = []; - $vatAmounts = []; - $czkItems = []; - $vatCzkItems = []; - $monthCount = 0; - - foreach ($monthRows as $r) { - $totalAmounts[$r['currency']] = round((float) $r['total'], 2); - $vatAmounts[$r['currency']] = round((float) $r['vat_total'], 2); - $monthCount += (int) $r['cnt']; - $czkItems[] = [ - 'amount' => round((float) $r['total'], 2), - 'currency' => $r['currency'], - 'date' => $monthStart, - ]; - $vatCzkItems[] = [ - 'amount' => round((float) $r['vat_total'], 2), - 'currency' => $r['currency'], - 'date' => $monthStart, - ]; - } - - $totalArr = []; - foreach ($totalAmounts as $cur => $amt) { - $totalArr[] = ['amount' => $amt, 'currency' => $cur]; - } - $vatArr = []; - foreach ($vatAmounts as $cur => $amt) { - $vatArr[] = ['amount' => $amt, 'currency' => $cur]; - } - - // Neuhrazeno celkově - $stmt = $pdo->prepare(' - SELECT currency, SUM(amount) as total, COUNT(*) as cnt - FROM received_invoices WHERE status = ? - GROUP BY currency - '); - $stmt->execute(['unpaid']); - $unpaidRows = $stmt->fetchAll(); - - $unpaidAmounts = []; - $unpaidCzkItems = []; - $unpaidCount = 0; - foreach ($unpaidRows as $r) { - $unpaidAmounts[] = ['amount' => round((float) $r['total'], 2), 'currency' => $r['currency']]; - $unpaidCount += (int) $r['cnt']; - $unpaidCzkItems[] = [ - 'amount' => round((float) $r['total'], 2), - 'currency' => $r['currency'], - 'date' => date('Y-m-d'), - ]; - } - - $cnb = CnbRates::getInstance(); - - successResponse([ - 'total_month' => $totalArr, - 'total_month_czk' => $cnb->sumToCzk($czkItems), - 'vat_month' => $vatArr, - 'vat_month_czk' => $cnb->sumToCzk($vatCzkItems), - 'unpaid' => $unpaidAmounts, - 'unpaid_czk' => $cnb->sumToCzk($unpaidCzkItems), - 'unpaid_count' => $unpaidCount, - 'month_count' => $monthCount, - 'month' => $month, - 'year' => $year, - ]); -} - -// --- List --- - -function handleGetList(PDO $pdo): void -{ - $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); - $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); - $search = trim($_GET['search'] ?? ''); - $sort = $_GET['sort'] ?? 'created_at'; - $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; - - $sortMap = [ - 'supplier_name' => 'supplier_name', - 'invoice_number' => 'invoice_number', - 'status' => 'status', - 'issue_date' => 'issue_date', - 'due_date' => 'due_date', - 'amount' => 'amount', - 'created_at' => 'created_at', - ]; - if (!isset($sortMap[$sort])) { - errorResponse('Neplatný parametr řazení', 400); - } - $sortCol = $sortMap[$sort]; - - $where = 'WHERE month = ? AND year = ?'; - $params = [$month, $year]; - - if ($search) { - $search = mb_substr($search, 0, 100); - $where .= ' AND (supplier_name LIKE ? OR invoice_number LIKE ?)'; - $searchParam = "%{$search}%"; - $params[] = $searchParam; - $params[] = $searchParam; - } - - $sql = " - SELECT id, supplier_name, invoice_number, description, - amount, currency, vat_rate, vat_amount, - issue_date, due_date, paid_date, status, - file_name, file_mime, file_size, notes, - created_at, modified_at - FROM received_invoices - $where - ORDER BY $sortCol $order - "; - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $invoices = $stmt->fetchAll(); - - successResponse(['invoices' => $invoices]); -} - -// --- Detail --- - -function handleGetDetail(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare(' - SELECT id, supplier_name, invoice_number, description, - amount, currency, vat_rate, vat_amount, - issue_date, due_date, paid_date, status, - file_name, file_mime, file_size, notes, - uploaded_by, created_at, modified_at - FROM received_invoices WHERE id = ? - '); - $stmt->execute([$id]); - $invoice = $stmt->fetch(); - - if (!$invoice) { - errorResponse('Přijatá faktura nebyla nalezena', 404); - } - - successResponse($invoice); -} - -// --- File streaming --- - -function handleGetFile(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT file_data, file_name, file_mime, file_size FROM received_invoices WHERE id = ?'); - $stmt->execute([$id]); - $row = $stmt->fetch(); - - if (!$row || !$row['file_data']) { - errorResponse('Soubor nebyl nalezen', 404); - } - - $safeFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['file_name'])); - header('Content-Type: ' . $row['file_mime']); - header('Content-Disposition: inline; filename="' . $safeFilename . '"'); - header('Content-Length: ' . $row['file_size']); - header_remove('X-Content-Type-Options'); - echo $row['file_data']; - exit(); -} - -// --- Bulk upload --- - -/** @param array $authData */ -function handleBulkUpload(PDO $pdo, array $authData): void -{ - $invoicesJson = $_POST['invoices'] ?? '[]'; - $invoicesMeta = json_decode($invoicesJson, true); - - if (!is_array($invoicesMeta)) { - errorResponse('Neplatná metadata'); - } - if (count($invoicesMeta) === 0) { - errorResponse('Žádné faktury k nahrání'); - } - if (count($invoicesMeta) > 20) { - errorResponse('Maximálně 20 faktur najednou'); - } - - $files = $_FILES['files'] ?? []; - $fileCount = is_array($files['tmp_name'] ?? null) ? count($files['tmp_name']) : 0; - - if ($fileCount !== count($invoicesMeta)) { - errorResponse('Počet souborů neodpovídá počtu metadat'); - } - - $allowedMimes = getAllowedMimes(); - $validCurrencies = ['CZK', 'EUR', 'USD', 'GBP']; - $validVatRates = [0, 10, 12, 15, 21]; - - $pdo->beginTransaction(); - try { - $created = []; - $stmt = $pdo->prepare(' - INSERT INTO received_invoices ( - month, year, supplier_name, invoice_number, description, - amount, currency, vat_rate, vat_amount, - issue_date, due_date, status, - file_data, file_name, file_mime, file_size, - notes, uploaded_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - '); - - for ($i = 0; $i < $fileCount; $i++) { - $meta = $invoicesMeta[$i]; - $tmpName = $files['tmp_name'][$i]; - $fileError = $files['error'][$i]; - $fileSize = $files['size'][$i]; - $fileName = $files['name'][$i]; - - if ($fileError !== UPLOAD_ERR_OK) { - errorResponse("Chyba při nahrávání souboru #" . ($i + 1)); - } - if ($fileSize > 10 * 1024 * 1024) { - errorResponse("Soubor #" . ($i + 1) . " je větší než 10 MB"); - } - - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mime = $finfo->file($tmpName); - if (!in_array($mime, $allowedMimes)) { - errorResponse("Soubor #" . ($i + 1) . ": nepodporovaný formát (povoleno: PDF, JPEG, PNG)"); - } - - $supplierName = trim($meta['supplier_name'] ?? ''); - if ($supplierName === '') { - errorResponse("Faktura #" . ($i + 1) . ": dodavatel je povinný"); - } - if (mb_strlen($supplierName) > 255) { - errorResponse("Faktura #" . ($i + 1) . ": název dodavatele je příliš dlouhý"); - } - - $amount = (float) ($meta['amount'] ?? 0); - if ($amount <= 0) { - errorResponse("Faktura #" . ($i + 1) . ": částka musí být větší než 0"); - } - - $currency = trim($meta['currency'] ?? 'CZK'); - if (!in_array($currency, $validCurrencies)) { - errorResponse("Faktura #" . ($i + 1) . ": neplatná měna"); - } - - $vatRate = (float) ($meta['vat_rate'] ?? 21); - if (!in_array((int) $vatRate, $validVatRates)) { - errorResponse("Faktura #" . ($i + 1) . ": neplatná sazba DPH"); - } - - $vatAmount = round($amount * $vatRate / 100, 2); - $invoiceNumber = trim($meta['invoice_number'] ?? ''); - $description = trim($meta['description'] ?? ''); - $issueDate = trim($meta['issue_date'] ?? ''); - $dueDate = trim($meta['due_date'] ?? ''); - $notes = trim($meta['notes'] ?? ''); - - // Validace dat - if ($issueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $issueDate) || !strtotime($issueDate))) { - errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data vystavení"); - } - if ($dueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dueDate) || !strtotime($dueDate))) { - errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data splatnosti"); - } - - // Délkové limity - if (mb_strlen($invoiceNumber) > 100) { - errorResponse("Faktura #" . ($i + 1) . ": číslo faktury je příliš dlouhé"); - } - if (mb_strlen($description) > 500) { - errorResponse("Faktura #" . ($i + 1) . ": popis je příliš dlouhý"); - } - if (mb_strlen($notes) > 5000) { - errorResponse("Faktura #" . ($i + 1) . ": poznámka je příliš dlouhá"); - } - - // Určit month/year z issue_date nebo aktuální - if ($issueDate) { - $dt = new DateTime($issueDate); - $month = (int) $dt->format('n'); - $year = (int) $dt->format('Y'); - } else { - $month = (int) date('n'); - $year = (int) date('Y'); - } - - $fileData = file_get_contents($tmpName); - $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName)); - - $stmt->execute([ - $month, - $year, - $supplierName, - $invoiceNumber ?: null, - $description ?: null, - $amount, - $currency, - $vatRate, - $vatAmount, - $issueDate ?: null, - $dueDate ?: null, - 'unpaid', - $fileData, - $safeName, - $mime, - $fileSize, - $notes ?: null, - $authData['user_id'], - ]); - - $created[] = (int) $pdo->lastInsertId(); - } - - $pdo->commit(); - - AuditLog::logCreate('received_invoices', $created[0], [ - 'count' => count($created), - 'ids' => $created, - ], 'Nahráno ' . count($created) . ' přijatých faktur'); - - successResponse(['ids' => $created], 'Faktury byly nahrány'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -// --- Update --- - -function handleUpdateReceivedInvoice(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT * FROM received_invoices WHERE id = ?'); - $stmt->execute([$id]); - $invoice = $stmt->fetch(); - - if (!$invoice) { - errorResponse('Přijatá faktura nebyla nalezena', 404); - } - - $input = getJsonInput(); - - $updates = []; - $params = []; - - $stringFields = [ - 'supplier_name' => 255, - 'invoice_number' => 100, - 'description' => 500, - 'notes' => 5000, - ]; - foreach ($stringFields as $field => $maxLen) { - if (array_key_exists($field, $input)) { - $val = trim((string) $input[$field]); - if ($field === 'supplier_name' && $val === '') { - errorResponse('Dodavatel je povinný'); - } - if (mb_strlen($val) > $maxLen) { - errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)"); - } - $updates[] = "$field = ?"; - $params[] = $val ?: null; - } - } - - if (array_key_exists('amount', $input)) { - $amount = (float) $input['amount']; - if ($amount <= 0) { - errorResponse('Částka musí být větší než 0'); - } - $updates[] = 'amount = ?'; - $params[] = $amount; - } - - if (array_key_exists('currency', $input)) { - if (!in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { - errorResponse('Neplatná měna'); - } - $updates[] = 'currency = ?'; - $params[] = $input['currency']; - } - - if (array_key_exists('vat_rate', $input)) { - $vatRate = (float) $input['vat_rate']; - if (!in_array((int) $vatRate, [0, 10, 12, 15, 21])) { - errorResponse('Neplatná sazba DPH'); - } - $updates[] = 'vat_rate = ?'; - $params[] = $vatRate; - - $amount = (float) ($input['amount'] ?? $invoice['amount']); - $updates[] = 'vat_amount = ?'; - $params[] = round($amount * $vatRate / 100, 2); - } elseif (array_key_exists('amount', $input)) { - $vatRate = (float) ($input['vat_rate'] ?? $invoice['vat_rate']); - $updates[] = 'vat_amount = ?'; - $params[] = round((float) $input['amount'] * $vatRate / 100, 2); - } - - foreach (['issue_date', 'due_date'] as $dateField) { - if (array_key_exists($dateField, $input)) { - $val = trim((string) $input[$dateField]); - if ($val && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $val) || !strtotime($val))) { - errorResponse("Neplatný formát data: $dateField"); - } - $updates[] = "$dateField = ?"; - $params[] = $val ?: null; - } - } - - // Aktualizace month/year pokud se změní issue_date - if (array_key_exists('issue_date', $input) && $input['issue_date']) { - $dt = new DateTime($input['issue_date']); - $updates[] = 'month = ?'; - $params[] = (int) $dt->format('n'); - $updates[] = 'year = ?'; - $params[] = (int) $dt->format('Y'); - } - - // Změna stavu - pouze unpaid -> paid (jednosmerny prechod) - if (array_key_exists('status', $input)) { - $newStatus = $input['status']; - if (!in_array($newStatus, ['unpaid', 'paid'])) { - errorResponse('Neplatný stav'); - } - if ($invoice['status'] === 'paid' && $newStatus !== 'paid') { - errorResponse('Uhrazenou fakturu nelze vrátit do stavu neuhrazená'); - } - if ($newStatus !== $invoice['status']) { - $updates[] = 'status = ?'; - $params[] = $newStatus; - if ($newStatus === 'paid') { - $updates[] = 'paid_date = CURDATE()'; - } - } - } - - if (empty($updates)) { - errorResponse('Žádné změny k uložení'); - } - - $updates[] = 'modified_at = NOW()'; - $params[] = $id; - $sql = 'UPDATE received_invoices SET ' . implode(', ', $updates) . ' WHERE id = ?'; - $pdo->prepare($sql)->execute($params); - - AuditLog::logUpdate( - 'received_invoices', - $id, - ['status' => $invoice['status']], - ['status' => $input['status'] ?? $invoice['status']], - "Aktualizována přijatá faktura #{$id}" - ); - - successResponse(null, 'Faktura byla aktualizována'); -} - -// --- Delete --- - -function handleDeleteReceivedInvoice(PDO $pdo, int $id): void -{ - $stmt = $pdo->prepare('SELECT id, supplier_name, invoice_number FROM received_invoices WHERE id = ?'); - $stmt->execute([$id]); - $invoice = $stmt->fetch(); - - if (!$invoice) { - errorResponse('Přijatá faktura nebyla nalezena', 404); - } - - $pdo->prepare('DELETE FROM received_invoices WHERE id = ?')->execute([$id]); - - AuditLog::logDelete('received_invoices', $id, [ - 'supplier_name' => $invoice['supplier_name'], - 'invoice_number' => $invoice['invoice_number'], - ], "Smazána přijatá faktura #{$id}"); - - successResponse(null, 'Faktura byla smazána'); -} diff --git a/api/admin/roles.php b/api/admin/roles.php index e248367..cd44259 100644 --- a/api/admin/roles.php +++ b/api/admin/roles.php @@ -14,6 +14,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/roles-handlers.php'; // Set headers setCorsHeaders(); @@ -64,237 +65,3 @@ try { error_log('Roles API error: ' . $e->getMessage()); errorResponse('Chyba databáze', 500); } - -/** - * GET - List all roles with their permissions + all available permissions - */ -function handleGetRole(PDO $pdo): void -{ - // Get all roles with user count (LEFT JOIN instead of correlated subquery) - $stmt = $pdo->query(' - SELECT r.*, COUNT(u.id) as user_count - FROM roles r - LEFT JOIN users u ON u.role_id = r.id - GROUP BY r.id - ORDER BY r.id - '); - $roles = $stmt->fetchAll(); - - // Batch fetch all role-permission mappings in one query (was N+1) - $stmt = $pdo->query(' - SELECT rp.role_id, p.name - FROM role_permissions rp - JOIN permissions p ON p.id = rp.permission_id - '); - $allRolePerms = $stmt->fetchAll(); - - // Group permissions by role_id - $permsByRole = []; - foreach ($allRolePerms as $rp) { - $permsByRole[$rp['role_id']][] = $rp['name']; - } - - foreach ($roles as &$role) { - $role['permissions'] = $permsByRole[$role['id']] ?? []; - $role['permission_count'] = count($role['permissions']); - } - unset($role); - - // Get all available permissions grouped by module - $stmt = $pdo->query('SELECT id, name, display_name, description FROM permissions ORDER BY id'); - $allPermissions = $stmt->fetchAll(); - - $grouped = []; - foreach ($allPermissions as $perm) { - $parts = explode('.', $perm['name'], 2); - $module = $parts[0]; - if (!isset($grouped[$module])) { - $grouped[$module] = []; - } - $grouped[$module][] = $perm; - } - - successResponse([ - 'roles' => $roles, - 'permissions' => $allPermissions, - 'permission_groups' => $grouped, - ]); -} - -/** - * POST - Create new role - */ -function handleCreateRole(PDO $pdo): void -{ - $input = getJsonInput(); - - $name = trim($input['name'] ?? ''); - $displayName = trim($input['display_name'] ?? ''); - $description = trim($input['description'] ?? ''); - $permissions = $input['permissions'] ?? []; - - if (!$name) { - errorResponse('Název role je povinný'); - } - - if (!$displayName) { - errorResponse('Zobrazovaný název je povinný'); - } - - // Validate name format (slug) - if (!preg_match('/^[a-z0-9_-]+$/', $name)) { - errorResponse('Název role může obsahovat pouze malá písmena, čísla, pomlčky a podtržítka'); - } - - // Check uniqueness - $stmt = $pdo->prepare('SELECT id FROM roles WHERE name = ?'); - $stmt->execute([$name]); - if ($stmt->fetch()) { - errorResponse('Role s tímto názvem již existuje'); - } - - $pdo->beginTransaction(); - - try { - // Create role - $stmt = $pdo->prepare(' - INSERT INTO roles (name, display_name, description) - VALUES (?, ?, ?) - '); - $stmt->execute([$name, $displayName, $description ?: null]); - $newRoleId = (int)$pdo->lastInsertId(); - - // Assign permissions - if (!empty($permissions)) { - $stmt = $pdo->prepare(' - INSERT INTO role_permissions (role_id, permission_id) - SELECT ?, id FROM permissions WHERE name = ? - '); - foreach ($permissions as $permName) { - $stmt->execute([$newRoleId, $permName]); - } - } - - $pdo->commit(); - - AuditLog::logCreate('role', $newRoleId, [ - 'name' => $name, - 'display_name' => $displayName, - 'permissions' => $permissions, - ], "Vytvořena role '$displayName'"); - - successResponse(['id' => $newRoleId], 'Role byla vytvořena'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -/** - * PUT - Update role - */ -function handleUpdateRole(PDO $pdo, int $roleId): void -{ - // Get existing role - $stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); - $stmt->execute([$roleId]); - $role = $stmt->fetch(); - - if (!$role) { - errorResponse('Role nebyla nalezena', 404); - } - - // Block editing admin role name - if ($role['name'] === 'admin') { - errorResponse('Roli administrátora nelze upravovat'); - } - - $input = getJsonInput(); - - $displayName = trim($input['display_name'] ?? $role['display_name']); - $description = trim($input['description'] ?? $role['description'] ?? ''); - $permissions = $input['permissions'] ?? null; - - if (!$displayName) { - errorResponse('Zobrazovaný název je povinný'); - } - - $pdo->beginTransaction(); - - try { - // Update role - $stmt = $pdo->prepare(' - UPDATE roles SET display_name = ?, description = ? - WHERE id = ? - '); - $stmt->execute([$displayName, $description ?: null, $roleId]); - - // Update permissions if provided - if ($permissions !== null) { - // Remove existing permissions - $stmt = $pdo->prepare('DELETE FROM role_permissions WHERE role_id = ?'); - $stmt->execute([$roleId]); - - // Add new permissions - if (!empty($permissions)) { - $stmt = $pdo->prepare(' - INSERT INTO role_permissions (role_id, permission_id) - SELECT ?, id FROM permissions WHERE name = ? - '); - foreach ($permissions as $permName) { - $stmt->execute([$roleId, $permName]); - } - } - } - - $pdo->commit(); - - AuditLog::logUpdate('role', $roleId, [ - 'display_name' => $role['display_name'], - ], [ - 'display_name' => $displayName, - 'permissions' => $permissions, - ], "Upravena role '$displayName'"); - - successResponse(null, 'Role byla aktualizována'); - } catch (PDOException $e) { - $pdo->rollBack(); - throw $e; - } -} - -/** - * DELETE - Delete role - */ -function handleDeleteRole(PDO $pdo, int $roleId): void -{ - $stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); - $stmt->execute([$roleId]); - $role = $stmt->fetch(); - - if (!$role) { - errorResponse('Role nebyla nalezena', 404); - } - - // Block deleting admin role - if ($role['name'] === 'admin') { - errorResponse('Roli administrátora nelze smazat'); - } - - // Check if role has users - $stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role_id = ?'); - $stmt->execute([$roleId]); - $userCount = $stmt->fetchColumn(); - - if ($userCount > 0) { - errorResponse("Nelze smazat roli s {$userCount} přiřazenými uživateli. Nejprve změňte roli těmto uživatelům."); - } - - // Delete role (cascade deletes role_permissions) - $stmt = $pdo->prepare('DELETE FROM roles WHERE id = ?'); - $stmt->execute([$roleId]); - - AuditLog::logDelete('role', $roleId, $role, "Smazána role '{$role['display_name']}'"); - - successResponse(null, 'Role byla smazána'); -} diff --git a/api/admin/session.php b/api/admin/session.php index c40e160..012e94a 100644 --- a/api/admin/session.php +++ b/api/admin/session.php @@ -26,6 +26,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/RateLimiter.php'; +require_once __DIR__ . '/handlers/session-handlers.php'; setCorsHeaders(); setSecurityHeaders(); @@ -48,24 +49,6 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'])) { errorResponse('Metoda není povolena', 405); } -/** @return array */ -function get2FAInfo(PDO $pdo, int $userId): array -{ - try { - $stmt = $pdo->prepare("SELECT totp_enabled FROM users WHERE id = ?"); - $stmt->execute([$userId]); - $row = $stmt->fetch(); - - $r2fa = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1"); - return [ - 'totp_enabled' => (bool) ($row['totp_enabled'] ?? false), - 'require_2fa' => (bool) $r2fa->fetchColumn(), - ]; - } catch (PDOException $e) { - return ['totp_enabled' => false, 'require_2fa' => false]; - } -} - $authData = JWTAuth::optionalAuth(); if ($authData) { diff --git a/api/admin/sessions.php b/api/admin/sessions.php index d94ddcd..c58a6c1 100644 --- a/api/admin/sessions.php +++ b/api/admin/sessions.php @@ -14,6 +14,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; +require_once __DIR__ . '/handlers/sessions-handlers.php'; // Set headers setCorsHeaders(); @@ -64,180 +65,3 @@ try { errorResponse('Chyba databáze', 500); } } - -/** - * GET - List all active sessions for current user - */ -function handleGetSession(PDO $pdo, int $userId, ?string $currentTokenHash): void -{ - // Cleanup: expirované + rotované tokeny po grace period - $stmt = $pdo->prepare( - 'DELETE FROM refresh_tokens WHERE user_id = ? AND (expires_at < NOW()' - . ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL ' - . JWTAuth::getGracePeriod() . ' SECOND)))' - ); - $stmt->execute([$userId]); - - // Jen aktivní sessions (nereplacované) - $stmt = $pdo->prepare(' - SELECT - id, - ip_address, - user_agent, - created_at, - expires_at, - token_hash - FROM refresh_tokens - WHERE user_id = ? AND replaced_at IS NULL - ORDER BY created_at DESC - '); - $stmt->execute([$userId]); - $sessions = $stmt->fetchAll(); - - // Process sessions to add is_current flag and parse user agent - $processedSessions = array_map(function ($session) use ($currentTokenHash) { - return [ - 'id' => (int) $session['id'], - 'ip_address' => $session['ip_address'], - 'user_agent' => $session['user_agent'], - 'device_info' => parseUserAgent($session['user_agent']), - 'created_at' => $session['created_at'], - 'expires_at' => $session['expires_at'], - 'is_current' => $currentTokenHash && $session['token_hash'] === $currentTokenHash, - ]; - }, $sessions); - - successResponse([ - 'sessions' => $processedSessions, - 'total' => count($processedSessions), - ]); -} - -/** - * DELETE - Delete a specific session - */ -function handleDeleteSession(PDO $pdo, int $sessionId, int $userId, ?string $currentTokenHash): void -{ - // Verify the session belongs to the current user - $stmt = $pdo->prepare('SELECT token_hash FROM refresh_tokens WHERE id = ? AND user_id = ?'); - $stmt->execute([$sessionId, $userId]); - $session = $stmt->fetch(); - - if (!$session) { - errorResponse('Relace nebyla nalezena', 404); - } - - // Check if trying to delete current session - if ($currentTokenHash && $session['token_hash'] === $currentTokenHash) { - // Check if force parameter is set - $input = getJsonInput(); - if (!($input['force'] ?? false)) { - errorResponse('Nelze smazat aktuální relaci. Použijte tlačítko odhlášení.', 400); - } - } - - // Delete the session - $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?'); - $stmt->execute([$sessionId, $userId]); - - successResponse(null, 'Relace byla úspěšně ukončena'); -} - -/** - * DELETE - Delete all sessions except current - */ -function handleDeleteAllSessions(PDO $pdo, int $userId, ?string $currentTokenHash): void -{ - if (!$currentTokenHash) { - $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?'); - $stmt->execute([$userId]); - $deleted = $stmt->rowCount(); - } else { - // Ponechat aktuální session, smazat ostatní (včetně replaced) - $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND token_hash != ?'); - $stmt->execute([$userId, $currentTokenHash]); - $deleted = $stmt->rowCount(); - } - - successResponse([ - 'deleted' => $deleted, - ], $deleted > 0 ? 'Ostatní relace byly úspěšně ukončeny' : 'Žádné další relace k ukončení'); -} - -/** - * Parse user agent string to extract device/browser info - * - * @return array{browser: string, os: string} - */ -function parseUserAgent(?string $userAgent): array -{ - if (empty($userAgent)) { - return [ - 'browser' => 'Neznámý prohlížeč', - 'os' => 'Neznámý systém', - 'device' => 'Neznámé zařízení', - 'icon' => 'device', - ]; - } - - $browser = 'Neznámý prohlížeč'; - $os = 'Neznámý systém'; - $device = 'desktop'; - $icon = 'desktop'; - - // Detect browser - if (preg_match('/Edg(e|A|iOS)?\/[\d.]+/i', $userAgent)) { - $browser = 'Microsoft Edge'; - } elseif (preg_match('/OPR\/[\d.]+|Opera/i', $userAgent)) { - $browser = 'Opera'; - } elseif (preg_match('/Chrome\/[\d.]+/i', $userAgent) && !preg_match('/Chromium/i', $userAgent)) { - $browser = 'Google Chrome'; - } elseif (preg_match('/Firefox\/[\d.]+/i', $userAgent)) { - $browser = 'Mozilla Firefox'; - } elseif (preg_match('/Safari\/[\d.]+/i', $userAgent) && !preg_match('/Chrome/i', $userAgent)) { - $browser = 'Safari'; - } elseif (preg_match('/MSIE|Trident/i', $userAgent)) { - $browser = 'Internet Explorer'; - } - - // Detect OS - if (preg_match('/Windows NT 10/i', $userAgent)) { - $os = 'Windows 10/11'; - } elseif (preg_match('/Windows NT 6\.3/i', $userAgent)) { - $os = 'Windows 8.1'; - } elseif (preg_match('/Windows NT 6\.2/i', $userAgent)) { - $os = 'Windows 8'; - } elseif (preg_match('/Windows NT 6\.1/i', $userAgent)) { - $os = 'Windows 7'; - } elseif (preg_match('/Windows/i', $userAgent)) { - $os = 'Windows'; - } elseif (preg_match('/Macintosh|Mac OS X/i', $userAgent)) { - $os = 'macOS'; - } elseif (preg_match('/Linux/i', $userAgent) && !preg_match('/Android/i', $userAgent)) { - $os = 'Linux'; - } elseif (preg_match('/iPhone/i', $userAgent)) { - $os = 'iOS'; - $device = 'mobile'; - $icon = 'smartphone'; - } elseif (preg_match('/iPad/i', $userAgent)) { - $os = 'iPadOS'; - $device = 'tablet'; - $icon = 'tablet'; - } elseif (preg_match('/Android/i', $userAgent)) { - $os = 'Android'; - if (preg_match('/Mobile/i', $userAgent)) { - $device = 'mobile'; - $icon = 'smartphone'; - } else { - $device = 'tablet'; - $icon = 'tablet'; - } - } - - return [ - 'browser' => $browser, - 'os' => $os, - 'device' => $device, - 'icon' => $icon, - ]; -} diff --git a/api/admin/totp.php b/api/admin/totp.php index 5c11ca5..4c06f7f 100644 --- a/api/admin/totp.php +++ b/api/admin/totp.php @@ -18,7 +18,7 @@ require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/RateLimiter.php'; require_once dirname(__DIR__) . '/includes/Encryption.php'; -require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; +require_once __DIR__ . '/handlers/totp-handlers.php'; use RobThree\Auth\TwoFactorAuth; use RobThree\Auth\TwoFactorAuthException; @@ -32,16 +32,6 @@ header('Content-Type: application/json; charset=utf-8'); $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; -/** Lazy init - QRServerProvider dela externi HTTP, nepotrebujeme ho pro kazdy request */ -function getTfa(): TwoFactorAuth -{ - static $tfa = null; - if ($tfa === null) { - $tfa = new TwoFactorAuth(new QRServerProvider(), 'BOHA Automation'); - } - return $tfa; -} - try { $pdo = db(); @@ -80,409 +70,3 @@ try { error_log('TOTP error: ' . $e->getMessage()); errorResponse('Došlo k chybě', 500); } - -/** GET ?action=status */ -function handleStatus(PDO $pdo): void -{ - $authData = JWTAuth::requireAuth(); - AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); - $userId = $authData['user_id']; - - $stmt = $pdo->prepare('SELECT totp_enabled FROM users WHERE id = ?'); - $stmt->execute([$userId]); - $user = $stmt->fetch(); - - successResponse([ - 'totp_enabled' => (bool) ($user['totp_enabled'] ?? false), - ]); -} - -/** POST ?action=setup - vygenerovat secret + QR URI (jeste neaktivuje 2FA) */ -function handleSetup(PDO $pdo, TwoFactorAuth $tfa): void -{ - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda není povolena', 405); - } - - $authData = JWTAuth::requireAuth(); - AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); - $userId = $authData['user_id']; - - $stmt = $pdo->prepare('SELECT totp_enabled, username, email FROM users WHERE id = ?'); - $stmt->execute([$userId]); - $user = $stmt->fetch(); - - if ($user['totp_enabled']) { - errorResponse('2FA je již aktivní. Nejdříve ji deaktivujte.'); - } - - $secret = $tfa->createSecret(); - - $stmt = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?'); - $stmt->execute([Encryption::encrypt($secret), $userId]); - - $label = $user['email'] ?: $user['username']; - $qrUri = $tfa->getQRText($label, $secret); - - successResponse([ - 'secret' => $secret, - 'qr_uri' => $qrUri, - ]); -} - -/** POST ?action=enable { "code": "123456" } */ -function handleEnable(PDO $pdo, TwoFactorAuth $tfa): void -{ - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda není povolena', 405); - } - - $authData = JWTAuth::requireAuth(); - AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); - $userId = $authData['user_id']; - $input = getJsonInput(); - $code = trim($input['code'] ?? ''); - - if (empty($code)) { - errorResponse('Ověřovací kód je povinný'); - } - - $stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?'); - $stmt->execute([$userId]); - $user = $stmt->fetch(); - - if (!$user['totp_secret']) { - errorResponse('Nejprve vygenerujte tajný klíč (setup)'); - } - - if ($user['totp_enabled']) { - errorResponse('2FA je již aktivní'); - } - - $decryptedSecret = decryptTotpSecret($user['totp_secret']); - if (!$tfa->verifyCode($decryptedSecret, $code)) { - errorResponse('Neplatný ověřovací kód. Zkontrolujte čas na telefonu.'); - } - - $backupCodes = generateBackupCodes(); - $hashedCodes = array_map(fn ($c) => password_hash($c, PASSWORD_BCRYPT, ['cost' => 10]), $backupCodes); - - $stmt = $pdo->prepare('UPDATE users SET totp_enabled = 1, totp_backup_codes = ? WHERE id = ?'); - $stmt->execute([json_encode($hashedCodes), $userId]); - - AuditLog::logUpdate('user', $userId, ['totp_enabled' => 0], ['totp_enabled' => 1], 'Uživatel aktivoval 2FA'); - - successResponse([ - 'backup_codes' => $backupCodes, - ], '2FA bylo úspěšně aktivováno'); -} - -/** POST ?action=disable { "code": "123456" } */ -function handleDisable(PDO $pdo, TwoFactorAuth $tfa): void -{ - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda není povolena', 405); - } - - $authData = JWTAuth::requireAuth(); - AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); - $userId = $authData['user_id']; - $input = getJsonInput(); - $code = trim($input['code'] ?? ''); - - if (empty($code)) { - errorResponse('Ověřovací kód je povinný'); - } - - $stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?'); - $stmt->execute([$userId]); - $user = $stmt->fetch(); - - if (!$user['totp_enabled']) { - errorResponse('2FA není aktivní'); - } - - $decryptedSecret = decryptTotpSecret($user['totp_secret']); - if (!$tfa->verifyCode($decryptedSecret, $code)) { - errorResponse('Neplatný ověřovací kód'); - } - $stmt = $pdo->prepare( - 'UPDATE users SET totp_enabled = 0, totp_secret = NULL, - totp_backup_codes = NULL WHERE id = ?' - ); - $stmt->execute([$userId]); - - AuditLog::logUpdate('user', $userId, ['totp_enabled' => 1], ['totp_enabled' => 0], 'Uživatel deaktivoval 2FA'); - - successResponse(null, '2FA bylo deaktivováno'); -} - -/** - * POST ?action=verify - overeni TOTP kodu pri loginu (pre-auth) - * Body: { "login_token": "...", "code": "123456", "remember": false } - */ -function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void -{ - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda není povolena', 405); - } - - $rateLimiter = new RateLimiter(); - $rateLimiter->setFailClosed(); - $rateLimiter->enforce('totp_2fa', 5); - - $input = getJsonInput(); - $loginToken = $input['login_token'] ?? ''; - $code = trim($input['code'] ?? ''); - $remember = (bool) ($input['remember'] ?? false); - - if (empty($loginToken) || empty($code)) { - errorResponse('Přihlašovací token a ověřovací kód jsou povinné'); - } - - $tokenData = verifyLoginToken($pdo, $loginToken); - if (!$tokenData) { - errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401); - } - - $userId = $tokenData['user_id']; - - $stmt = $pdo->prepare(' - SELECT u.*, r.name as role_name, r.display_name as role_display_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - WHERE u.id = ? AND u.totp_enabled = 1 - '); - $stmt->execute([$userId]); - $user = $stmt->fetch(); - - if (!$user) { - errorResponse('Uživatel nenalezen nebo 2FA není aktivní', 401); - } - - $decryptedSecret = decryptTotpSecret($user['totp_secret']); - if (!$tfa->verifyCode($decryptedSecret, $code, 1)) { - errorResponse('Neplatný ověřovací kód'); - } - - deleteLoginToken($pdo, $loginToken); - completeLogin($pdo, $user, $remember); -} - -/** POST ?action=backup_verify { "login_token": "...", "code": "XXXXXXXX", "remember": false } */ -function handleBackupVerify(PDO $pdo): void -{ - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda není povolena', 405); - } - - $rateLimiter = new RateLimiter(); - $rateLimiter->setFailClosed(); - $rateLimiter->enforce('totp_2fa', 5); - - $input = getJsonInput(); - $loginToken = $input['login_token'] ?? ''; - $code = strtoupper(trim($input['code'] ?? '')); - $remember = (bool) ($input['remember'] ?? false); - - if (empty($loginToken) || empty($code)) { - errorResponse('Přihlašovací token a záložní kód jsou povinné'); - } - - $tokenData = verifyLoginToken($pdo, $loginToken); - if (!$tokenData) { - errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401); - } - - $userId = $tokenData['user_id']; - - $stmt = $pdo->prepare(' - SELECT u.*, r.name as role_name, r.display_name as role_display_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - WHERE u.id = ? AND u.totp_enabled = 1 - '); - $stmt->execute([$userId]); - $user = $stmt->fetch(); - - if (!$user || !$user['totp_backup_codes']) { - errorResponse('Uživatel nenalezen nebo nemá záložní kódy', 401); - } - - $hashedCodes = json_decode($user['totp_backup_codes'], true); - $matched = false; - $remainingCodes = []; - - foreach ($hashedCodes as $hashed) { - if (!$matched && password_verify($code, $hashed)) { - $matched = true; - } else { - $remainingCodes[] = $hashed; - } - } - - if (!$matched) { - errorResponse('Neplatný záložní kód'); - } - - $stmt = $pdo->prepare('UPDATE users SET totp_backup_codes = ? WHERE id = ?'); - $stmt->execute([json_encode($remainingCodes), $userId]); - - deleteLoginToken($pdo, $loginToken); - completeLogin($pdo, $user, $remember); -} - -/** GET ?action=get_required (admin only) */ -function handleGetRequired(PDO $pdo): void -{ - $authData = JWTAuth::requireAuth(); - AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); - requirePermission($authData, 'settings.security'); - - $stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1"); - - successResponse([ - 'require_2fa' => (bool) $stmt->fetchColumn(), - ]); -} - -/** POST ?action=set_required { "required": true/false } (admin only) */ -function handleSetRequired(PDO $pdo): void -{ - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda není povolena', 405); - } - - $authData = JWTAuth::requireAuth(); - AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown'); - requirePermission($authData, 'settings.security'); - - $input = getJsonInput(); - $required = (bool) ($input['required'] ?? false); - - $stmt = $pdo->prepare("UPDATE company_settings SET require_2fa = ? LIMIT 1"); - $stmt->execute([$required ? 1 : 0]); - - successResponse([ - 'require_2fa' => $required, - ], $required ? '2FA je nyní povinná pro všechny uživatele' : '2FA již není povinná'); -} - -// --- Helper functions --- - -/** Desifrovat TOTP secret z DB (zpetne kompatibilni s plaintextem pred migraci) */ -function decryptTotpSecret(string $value): string -{ - if (Encryption::isEncrypted($value)) { - return Encryption::decrypt($value); - } - return $value; -} - -/** - * Generovat 8 nahodnych backup kodu - * - * @return list - */ -function generateBackupCodes(int $count = 8): array -{ - $codes = []; - for ($i = 0; $i < $count; $i++) { - $codes[] = strtoupper(bin2hex(random_bytes(4))); // 8-char hex - } - return $codes; -} - -/** Docasny login token pro 2FA (5 min) */ -function createLoginToken(PDO $pdo, int $userId): string -{ - $token = bin2hex(random_bytes(32)); - $hashedToken = hash('sha256', $token); - $expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes - - $stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()'); - $stmt->execute([$userId]); - - $stmt = $pdo->prepare(' - INSERT INTO totp_login_tokens (user_id, token_hash, expires_at) - VALUES (?, ?, ?) - '); - $stmt->execute([$userId, $hashedToken, $expiresAt]); - - return $token; -} - -/** - * Overit login token - * - * @return array|null - */ -function verifyLoginToken(PDO $pdo, string $token): ?array -{ - $hashedToken = hash('sha256', $token); - - $stmt = $pdo->prepare(' - SELECT * FROM totp_login_tokens - WHERE token_hash = ? AND expires_at > NOW() - '); - $stmt->execute([$hashedToken]); - return $stmt->fetch() ?: null; -} - -/** Smazat login token po pouziti */ -function deleteLoginToken(PDO $pdo, string $token): void -{ - $hashedToken = hash('sha256', $token); - $stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE token_hash = ?'); - $stmt->execute([$hashedToken]); -} - -/** - * Dokoncit login po uspesnem 2FA - vydat JWT + refresh token - * - * @param array $user - */ -function completeLogin(PDO $pdo, array $user, bool $remember): void -{ - $stmt = $pdo->prepare(' - UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = NOW() - WHERE id = ? - '); - $stmt->execute([$user['id']]); - - $userData = [ - 'id' => $user['id'], - 'username' => $user['username'], - 'email' => $user['email'], - 'first_name' => $user['first_name'], - 'last_name' => $user['last_name'], - 'role' => $user['role_name'] ?? null, - 'role_display' => $user['role_display_name'] ?? $user['role_name'] ?? null, - 'is_admin' => ($user['role_name'] ?? '') === 'admin', - ]; - - $accessToken = JWTAuth::generateAccessToken($userData); - JWTAuth::generateRefreshToken($user['id'], $remember); - - AuditLog::logLogin($user['id'], $user['username']); - - $stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1"); - $require2FA = (bool) $stmt->fetchColumn(); - - successResponse([ - 'access_token' => $accessToken, - 'expires_in' => JWTAuth::getAccessTokenExpiry(), - 'user' => [ - 'id' => $userData['id'], - 'username' => $userData['username'], - 'email' => $userData['email'], - 'full_name' => trim($userData['first_name'] . ' ' . $userData['last_name']), - 'role' => $userData['role'], - 'role_display' => $userData['role_display'], - 'is_admin' => $userData['is_admin'], - 'permissions' => JWTAuth::getUserPermissions($user['id']), - 'totp_enabled' => true, - 'require_2fa' => $require2FA, - ], - ], 'Přihlášení úspěšné'); -} diff --git a/api/admin/trips.php b/api/admin/trips.php index f1e9b4a..fb62153 100644 --- a/api/admin/trips.php +++ b/api/admin/trips.php @@ -24,6 +24,7 @@ require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php'; +require_once __DIR__ . '/handlers/trips-handlers.php'; // Set headers setCorsHeaders(); @@ -129,660 +130,3 @@ try { // ============================================================================ // Helper Functions // ============================================================================ - -function getLastKmForVehicle(PDO $pdo, int $vehicleId): int -{ - $stmt = $pdo->prepare(' - SELECT COALESCE( - (SELECT MAX(end_km) FROM trips WHERE vehicle_id = ?), - (SELECT initial_km FROM vehicles WHERE id = ?), - 0 - ) as last_km - '); - $stmt->execute([$vehicleId, $vehicleId]); - $result = $stmt->fetch(); - - return $result ? (int)$result['last_km'] : 0; -} - -function formatKm(int $km): string -{ - return number_format($km, 0, ',', ' ') . ' km'; -} - -// ============================================================================ -// GET Handlers -// ============================================================================ - -/** - * GET - Current month trips (filtered to current user) - */ -function handleGetCurrent(PDO $pdo, int $userId): void -{ - $month = validateMonth(); - $vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null; - $startDate = "{$month}-01"; - $endDate = date('Y-m-t', strtotime($startDate)); - - $sql = " - SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, - CONCAT(u.first_name, ' ', u.last_name) as driver_name - FROM trips t - JOIN vehicles v ON t.vehicle_id = v.id - JOIN users u ON t.user_id = u.id - WHERE t.trip_date BETWEEN ? AND ? - AND t.user_id = ? - "; - $params = [$startDate, $endDate, $userId]; - - if ($vehicleId) { - $sql .= ' AND t.vehicle_id = ?'; - $params[] = $vehicleId; - } - - $sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC'; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $trips = $stmt->fetchAll(); - - // Get active vehicles for selection - $stmt = $pdo->query('SELECT id, spz, name, brand, model FROM vehicles WHERE is_active = 1 ORDER BY name'); - $vehicles = $stmt->fetchAll(); - - // Calculate totals - $totalDistance = 0; - $businessDistance = 0; - $privateDistance = 0; - - foreach ($trips as $trip) { - $totalDistance += $trip['distance']; - if ($trip['is_business']) { - $businessDistance += $trip['distance']; - } else { - $privateDistance += $trip['distance']; - } - } - - successResponse([ - 'trips' => $trips, - 'vehicles' => $vehicles, - 'month' => $month, - 'totals' => [ - 'total' => $totalDistance, - 'business' => $businessDistance, - 'private' => $privateDistance, - 'count' => count($trips), - ], - ]); -} - -/** - * GET - Trip history with filters (filtered to current user) - */ -function handleGetHistory(PDO $pdo, int $userId): void -{ - $month = validateMonth(); - $vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null; - - $startDate = "{$month}-01"; - $endDate = date('Y-m-t', strtotime($startDate)); - - $sql = " - SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, - CONCAT(u.first_name, ' ', u.last_name) as driver_name - FROM trips t - JOIN vehicles v ON t.vehicle_id = v.id - JOIN users u ON t.user_id = u.id - WHERE t.trip_date BETWEEN ? AND ? - AND t.user_id = ? - "; - $params = [$startDate, $endDate, $userId]; - - if ($vehicleId) { - $sql .= ' AND t.vehicle_id = ?'; - $params[] = $vehicleId; - } - - $sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC'; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $trips = $stmt->fetchAll(); - - // Get vehicles for filter - $stmt = $pdo->query('SELECT id, spz, name FROM vehicles WHERE is_active = 1 ORDER BY name'); - $vehicles = $stmt->fetchAll(); - - // Calculate totals - $totalDistance = 0; - $businessDistance = 0; - - foreach ($trips as $trip) { - $totalDistance += $trip['distance']; - if ($trip['is_business']) { - $businessDistance += $trip['distance']; - } - } - - successResponse([ - 'trips' => $trips, - 'vehicles' => $vehicles, - 'month' => $month, - 'totals' => [ - 'total' => $totalDistance, - 'business' => $businessDistance, - 'count' => count($trips), - ], - ]); -} - -/** - * GET - Admin view of all trips - */ -function handleGetAdmin(PDO $pdo): void -{ - $dateFrom = $_GET['date_from'] ?? null; - $dateTo = $_GET['date_to'] ?? null; - $vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null; - $filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null; - - // Default to current month if no dates provided - if (!$dateFrom || !$dateTo) { - $month = date('Y-m'); - $startDate = "{$month}-01"; - $endDate = date('Y-m-t', strtotime($startDate)); - } else { - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) { - errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)'); - } - $startDate = $dateFrom; - $endDate = $dateTo; - } - - $sql = " - SELECT t.*, v.spz, v.name as vehicle_name, - CONCAT(u.first_name, ' ', u.last_name) as driver_name - FROM trips t - JOIN vehicles v ON t.vehicle_id = v.id - JOIN users u ON t.user_id = u.id - WHERE t.trip_date BETWEEN ? AND ? - "; - $params = [$startDate, $endDate]; - - if ($vehicleId) { - $sql .= ' AND t.vehicle_id = ?'; - $params[] = $vehicleId; - } - - if ($filterUserId) { - $sql .= ' AND t.user_id = ?'; - $params[] = $filterUserId; - } - - $sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC'; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $trips = $stmt->fetchAll(); - - // Get vehicles for filter - $stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name'); - $vehicles = $stmt->fetchAll(); - - // Get users for filter - $stmt = $pdo->query( - "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" - ); - $users = $stmt->fetchAll(); - - // Calculate totals - $totalDistance = 0; - $businessDistance = 0; - - foreach ($trips as $trip) { - $totalDistance += $trip['distance']; - if ($trip['is_business']) { - $businessDistance += $trip['distance']; - } - } - - successResponse([ - 'trips' => $trips, - 'vehicles' => $vehicles, - 'users' => $users, - 'date_from' => $startDate, - 'date_to' => $endDate, - 'totals' => [ - 'total' => $totalDistance, - 'business' => $businessDistance, - 'count' => count($trips), - ], - ]); -} - -/** - * GET - All vehicles (admin) - */ -function handleGetVehicles(PDO $pdo): void -{ - $stmt = $pdo->query(' - SELECT v.*, COUNT(t.id) as trip_count, - COALESCE(MAX(t.end_km), v.initial_km) as current_km - FROM vehicles v - LEFT JOIN trips t ON t.vehicle_id = v.id - GROUP BY v.id - ORDER BY v.is_active DESC, v.name - '); - $vehicles = $stmt->fetchAll(); - - successResponse(['vehicles' => $vehicles]); -} - -/** - * GET - Active vehicles for selection - */ -function handleGetActiveVehicles(PDO $pdo): void -{ - $stmt = $pdo->query(' - SELECT v.id, v.spz, v.name, v.brand, v.model, - COALESCE(MAX(t.end_km), v.initial_km) as current_km - FROM vehicles v - LEFT JOIN trips t ON t.vehicle_id = v.id - WHERE v.is_active = 1 - GROUP BY v.id - ORDER BY v.name - '); - $vehicles = $stmt->fetchAll(); - - successResponse(['vehicles' => $vehicles]); -} - -/** - * GET - Last km for vehicle - */ -function handleGetLastKm(PDO $pdo): void -{ - $vehicleId = (int)($_GET['vehicle_id'] ?? 0); - if (!$vehicleId) { - errorResponse('Vehicle ID je povinné'); - } - - $lastKm = getLastKmForVehicle($pdo, $vehicleId); - successResponse(['last_km' => $lastKm]); -} - -// ============================================================================ -// POST Handlers -// ============================================================================ - -/** - * POST - Create trip - */ -function handleCreateTrip(PDO $pdo, int $userId): void -{ - $input = getJsonInput(); - - $vehicleId = (int)($input['vehicle_id'] ?? 0); - $tripDate = $input['trip_date'] ?? ''; - $startKm = (int)($input['start_km'] ?? 0); - $endKm = (int)($input['end_km'] ?? 0); - $routeFrom = trim($input['route_from'] ?? ''); - $routeTo = trim($input['route_to'] ?? ''); - $isBusiness = (int)($input['is_business'] ?? 1); - $notes = trim($input['notes'] ?? ''); - - // Validation - if (!$vehicleId) { - errorResponse('Vyberte vozidlo'); - } - if (!$tripDate) { - errorResponse('Datum jízdy je povinné'); - } - if (!$startKm) { - errorResponse('Počáteční stav km je povinný'); - } - if (!$endKm) { - errorResponse('Konečný stav km je povinný'); - } - if (!$routeFrom) { - errorResponse('Místo odjezdu je povinné'); - } - if (!$routeTo) { - errorResponse('Místo příjezdu je povinné'); - } - if ($endKm <= $startKm) { - errorResponse('Konečný stav km musí být větší než počáteční'); - } - - // Check vehicle exists - $stmt = $pdo->prepare('SELECT id FROM vehicles WHERE id = ? AND is_active = 1'); - $stmt->execute([$vehicleId]); - if (!$stmt->fetch()) { - errorResponse('Vozidlo neexistuje nebo není aktivní'); - } - - $stmt = $pdo->prepare(' - INSERT INTO trips (vehicle_id, user_id, trip_date, start_km, end_km, route_from, route_to, is_business, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - '); - $stmt->execute([ - $vehicleId, $userId, $tripDate, $startKm, $endKm, - $routeFrom, $routeTo, $isBusiness, $notes ?: null, - ]); - - $newId = (int)$pdo->lastInsertId(); - AuditLog::logCreate('trips', $newId, $input, 'Vytvořen záznam jízdy'); - - successResponse(['id' => $newId], 'Jízda byla zaznamenána'); -} - -/** - * POST - Create/update vehicle (admin) - */ -function handleVehicle(PDO $pdo): void -{ - $input = getJsonInput(); - - $id = (int)($input['id'] ?? 0); - $spz = strtoupper(trim($input['spz'] ?? '')); - $name = trim($input['name'] ?? ''); - $brand = trim($input['brand'] ?? ''); - $model = trim($input['model'] ?? ''); - $initialKm = (int)($input['initial_km'] ?? 0); - $isActive = isset($input['is_active']) ? (int)$input['is_active'] : 1; - - if (!$spz) { - errorResponse('SPZ je povinná'); - } - if (!$name) { - errorResponse('Název je povinný'); - } - - if ($id) { - // Update - $stmt = $pdo->prepare(' - UPDATE vehicles - SET spz = ?, name = ?, brand = ?, model = ?, initial_km = ?, is_active = ? - WHERE id = ? - '); - $stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive, $id]); - - AuditLog::logUpdate('vehicles', $id, [], $input, 'Upraveno vozidlo'); - successResponse(null, 'Vozidlo bylo aktualizováno'); - } else { - // Create - $stmt = $pdo->prepare(' - INSERT INTO vehicles (spz, name, brand, model, initial_km, is_active) - VALUES (?, ?, ?, ?, ?, ?) - '); - - try { - $stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive]); - $newId = (int)$pdo->lastInsertId(); - - AuditLog::logCreate('vehicles', $newId, $input, 'Vytvořeno vozidlo'); - successResponse(['id' => $newId], 'Vozidlo bylo vytvořeno'); - } catch (PDOException $e) { - if ($e->getCode() == 23000) { - errorResponse('Vozidlo s touto SPZ již existuje'); - } - throw $e; - } - } -} - -// ============================================================================ -// PUT Handler -// ============================================================================ - -/** - * PUT - Update trip - * - * @param array $authData - */ -function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void -{ - $stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?'); - $stmt->execute([$id]); - $trip = $stmt->fetch(); - - if (!$trip) { - errorResponse('Záznam nebyl nalezen', 404); - } - - // Check permission - own trips or trips.admin - if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) { - errorResponse('Nemáte oprávnění upravit tento záznam', 403); - } - - $input = getJsonInput(); - - $vehicleId = (int)($input['vehicle_id'] ?? $trip['vehicle_id']); - $tripDate = $input['trip_date'] ?? $trip['trip_date']; - $startKm = (int)($input['start_km'] ?? $trip['start_km']); - $endKm = (int)($input['end_km'] ?? $trip['end_km']); - $routeFrom = trim($input['route_from'] ?? $trip['route_from']); - $routeTo = trim($input['route_to'] ?? $trip['route_to']); - $isBusiness = isset($input['is_business']) ? (int)$input['is_business'] : $trip['is_business']; - $notes = trim($input['notes'] ?? $trip['notes'] ?? ''); - - if ($endKm <= $startKm) { - errorResponse('Konečný stav km musí být větší než počáteční'); - } - - $stmt = $pdo->prepare(' - UPDATE trips - SET vehicle_id = ?, trip_date = ?, start_km = ?, end_km = ?, - route_from = ?, route_to = ?, is_business = ?, notes = ? - WHERE id = ? - '); - $stmt->execute([$vehicleId, $tripDate, $startKm, $endKm, $routeFrom, $routeTo, $isBusiness, $notes ?: null, $id]); - - AuditLog::logUpdate('trips', $id, $trip, $input, 'Upraven záznam jízdy'); - - successResponse(null, 'Záznam byl aktualizován'); -} - -// ============================================================================ -// DELETE Handlers -// ============================================================================ - -/** - * DELETE - Delete trip - * - * @param array $authData - */ -function handleDeleteTrip(PDO $pdo, int $id, int $userId, array $authData): void -{ - $stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?'); - $stmt->execute([$id]); - $trip = $stmt->fetch(); - - if (!$trip) { - errorResponse('Záznam nebyl nalezen', 404); - } - - // Check permission - own trips or trips.admin - if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) { - errorResponse('Nemáte oprávnění smazat tento záznam', 403); - } - - $stmt = $pdo->prepare('DELETE FROM trips WHERE id = ?'); - $stmt->execute([$id]); - - AuditLog::logDelete('trips', $id, $trip, 'Smazán záznam jízdy'); - - successResponse(null, 'Záznam byl smazán'); -} - -/** - * DELETE - Delete vehicle (admin) - */ -function handleDeleteVehicle(PDO $pdo, int $id): void -{ - if (!$id) { - errorResponse('ID je povinné'); - } - - $stmt = $pdo->prepare('SELECT * FROM vehicles WHERE id = ?'); - $stmt->execute([$id]); - $vehicle = $stmt->fetch(); - - if (!$vehicle) { - errorResponse('Vozidlo nebylo nalezeno', 404); - } - - // Check if vehicle has trips - $stmt = $pdo->prepare('SELECT COUNT(*) FROM trips WHERE vehicle_id = ?'); - $stmt->execute([$id]); - $tripCount = $stmt->fetchColumn(); - - if ($tripCount > 0) { - errorResponse( - "Nelze smazat vozidlo s {$tripCount} záznamy jízd. Nejprve smažte záznamy jízd nebo deaktivujte vozidlo." - ); - } - - $stmt = $pdo->prepare('DELETE FROM vehicles WHERE id = ?'); - $stmt->execute([$id]); - - AuditLog::logDelete('vehicles', $id, $vehicle, 'Smazáno vozidlo'); - - successResponse(null, 'Vozidlo bylo smazáno'); -} - -// ============================================================================ -// Print Handler -// ============================================================================ - -/** - * Format date range for display - */ -function formatPeriodName(string $startDate, string $endDate): string -{ - $start = new DateTime($startDate); - $end = new DateTime($endDate); - - // If same month - if ($start->format('Y-m') === $end->format('Y-m')) { - return getCzechMonthName((int)$start->format('n')) . ' ' . $start->format('Y'); - } - - // If same year - if ($start->format('Y') === $end->format('Y')) { - return $start->format('j.n.') . ' - ' . $end->format('j.n.Y'); - } - - // Different years - return $start->format('j.n.Y') . ' - ' . $end->format('j.n.Y'); -} - -/** - * GET - Print data for trips (admin) - */ -function handleGetPrint(PDO $pdo): void -{ - $dateFrom = $_GET['date_from'] ?? null; - $dateTo = $_GET['date_to'] ?? null; - $vehicleId = isset($_GET['vehicle_id']) && $_GET['vehicle_id'] !== '' ? (int)$_GET['vehicle_id'] : null; - $filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null; - - // Default to current month if no dates provided - if (!$dateFrom || !$dateTo) { - $startDate = date('Y-m-01'); - $endDate = date('Y-m-t'); - } else { - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) { - errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)'); - } - $startDate = $dateFrom; - $endDate = $dateTo; - } - - $sql = " - SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, - CONCAT(u.first_name, ' ', u.last_name) as driver_name - FROM trips t - JOIN vehicles v ON t.vehicle_id = v.id - JOIN users u ON t.user_id = u.id - WHERE t.trip_date BETWEEN ? AND ? - "; - $params = [$startDate, $endDate]; - - if ($vehicleId) { - $sql .= ' AND t.vehicle_id = ?'; - $params[] = $vehicleId; - } - - if ($filterUserId) { - $sql .= ' AND t.user_id = ?'; - $params[] = $filterUserId; - } - - $sql .= ' ORDER BY t.trip_date ASC, t.start_km ASC'; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $trips = $stmt->fetchAll(); - - // Get vehicles for filter - $stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name'); - $vehicles = $stmt->fetchAll(); - - // Get users for filter - $stmt = $pdo->query( - "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" - ); - $users = $stmt->fetchAll(); - - // Calculate totals - $totalDistance = 0; - $businessDistance = 0; - $privateDistance = 0; - - foreach ($trips as $trip) { - $totalDistance += $trip['distance']; - if ($trip['is_business']) { - $businessDistance += $trip['distance']; - } else { - $privateDistance += $trip['distance']; - } - } - - // Get selected vehicle/user names for header - $selectedVehicleName = ''; - if ($vehicleId) { - $stmt = $pdo->prepare("SELECT CONCAT(spz, ' - ', name) as name FROM vehicles WHERE id = ?"); - $stmt->execute([$vehicleId]); - $v = $stmt->fetch(); - $selectedVehicleName = $v ? $v['name'] : ''; - } - - $selectedUserName = ''; - if ($filterUserId) { - $stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?"); - $stmt->execute([$filterUserId]); - $u = $stmt->fetch(); - $selectedUserName = $u ? $u['name'] : ''; - } - - successResponse([ - 'trips' => $trips, - 'vehicles' => $vehicles, - 'users' => $users, - 'date_from' => $startDate, - 'date_to' => $endDate, - 'period_name' => formatPeriodName($startDate, $endDate), - 'selected_vehicle' => $vehicleId, - 'selected_vehicle_name' => $selectedVehicleName, - 'selected_user' => $filterUserId, - 'selected_user_name' => $selectedUserName, - 'totals' => [ - 'total' => $totalDistance, - 'business' => $businessDistance, - 'private' => $privateDistance, - 'count' => count($trips), - ], - ]); -} diff --git a/api/admin/users.php b/api/admin/users.php index 8a73751..4edbea5 100644 --- a/api/admin/users.php +++ b/api/admin/users.php @@ -14,6 +14,7 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php'; +require_once __DIR__ . '/handlers/users-handlers.php'; // Set headers setCorsHeaders(); @@ -70,273 +71,3 @@ try { errorResponse('Chyba databáze', 500); } } - -/** - * GET - List all users - */ -function handleGetUser(PDO $pdo): void -{ - $stmt = $pdo->query(' - SELECT - u.id, - u.username, - u.email, - u.first_name, - u.last_name, - u.role_id, - u.is_active, - u.last_login, - u.created_at, - r.name as role_name, - r.display_name as role_display_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - ORDER BY u.created_at DESC - '); - $users = $stmt->fetchAll(); - - // Get roles for dropdown - $stmt = $pdo->query('SELECT id, name, display_name FROM roles ORDER BY id'); - $roles = $stmt->fetchAll(); - - successResponse([ - 'users' => $users, - 'roles' => $roles, - ]); -} - -/** - * POST - Create new user - * - * @param array $authData - */ -function handleCreateUser(PDO $pdo, array $authData): void -{ - $input = getJsonInput(); - - // Validate required fields - $requiredFields = [ - 'username' => 'Uživatelské jméno', - 'email' => 'E-mail', - 'password' => 'Heslo', - 'first_name' => 'Jméno', - 'last_name' => 'Příjmení', - 'role_id' => 'Role', - ]; - foreach ($requiredFields as $field => $label) { - if (empty($input[$field])) { - errorResponse("$label je povinné"); - } - } - - $username = sanitize($input['username']); - $email = sanitize($input['email']); - $password = $input['password']; - $firstName = sanitize($input['first_name']); - $lastName = sanitize($input['last_name']); - $roleId = (int) $input['role_id']; - $isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : 1; - - // Non-admin nesmí přiřadit admin roli - if (!($authData['user']['is_admin'] ?? false)) { - $stmt = $pdo->prepare('SELECT name FROM roles WHERE id = ?'); - $stmt->execute([$roleId]); - $targetRole = $stmt->fetch(); - if ($targetRole && $targetRole['name'] === 'admin') { - errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403); - } - } - - // Validate email format - if (!isValidEmail($email)) { - errorResponse('Neplatný formát e-mailu'); - } - - // Validate password length - if (strlen($password) < 8) { - errorResponse('Heslo musí mít alespoň 8 znaků'); - } - - // Check username uniqueness - $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?'); - $stmt->execute([$username]); - if ($stmt->fetch()) { - errorResponse('Uživatelské jméno již existuje'); - } - - // Check email uniqueness - $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?'); - $stmt->execute([$email]); - if ($stmt->fetch()) { - errorResponse('E-mail již existuje'); - } - - // Validate role exists - $stmt = $pdo->prepare('SELECT id FROM roles WHERE id = ?'); - $stmt->execute([$roleId]); - if (!$stmt->fetch()) { - errorResponse('Neplatná role'); - } - - // Hash password - $passwordHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]); - - // Insert user - $stmt = $pdo->prepare(' - INSERT INTO users (username, email, password_hash, first_name, last_name, role_id, is_active) - VALUES (?, ?, ?, ?, ?, ?, ?) - '); - $stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive]); - - $newUserId = (int)$pdo->lastInsertId(); - - // Audit log - AuditLog::logCreate('user', $newUserId, [ - 'username' => $username, - 'email' => $email, - 'first_name' => $firstName, - 'last_name' => $lastName, - 'role_id' => $roleId, - 'is_active' => $isActive, - ], "Vytvořen uživatel '$username'"); - - successResponse(['id' => $newUserId], 'Uživatel byl úspěšně vytvořen'); -} - -/** - * PUT - Update user - * - * @param array $authData - */ -function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void -{ - // Get existing user - $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); - $stmt->execute([$userId]); - $existingUser = $stmt->fetch(); - - if (!$existingUser) { - errorResponse('Uživatel nebyl nalezen', 404); - } - - $input = getJsonInput(); - - $username = isset($input['username']) ? sanitize($input['username']) : $existingUser['username']; - $email = isset($input['email']) ? sanitize($input['email']) : $existingUser['email']; - $firstName = isset($input['first_name']) ? sanitize($input['first_name']) : $existingUser['first_name']; - $lastName = isset($input['last_name']) ? sanitize($input['last_name']) : $existingUser['last_name']; - $roleId = isset($input['role_id']) ? (int) $input['role_id'] : $existingUser['role_id']; - $isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : $existingUser['is_active']; - - // Validate email format - if (!isValidEmail($email)) { - errorResponse('Neplatný formát e-mailu'); - } - - // Check username uniqueness (excluding current user) - $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ? AND id != ?'); - $stmt->execute([$username, $userId]); - if ($stmt->fetch()) { - errorResponse('Uživatelské jméno již existuje'); - } - - // Check email uniqueness (excluding current user) - $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND id != ?'); - $stmt->execute([$email, $userId]); - if ($stmt->fetch()) { - errorResponse('E-mail již existuje'); - } - - // Validate role exists - $stmt = $pdo->prepare('SELECT id, name FROM roles WHERE id = ?'); - $stmt->execute([$roleId]); - $targetRole = $stmt->fetch(); - if (!$targetRole) { - errorResponse('Neplatná role'); - } - - // Non-admin nesmí přiřadit admin roli - if (!($authData['user']['is_admin'] ?? false) && $targetRole['name'] === 'admin') { - errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403); - } - - // Update user - if (!empty($input['password'])) { - // Validate password length - if (strlen($input['password']) < 8) { - errorResponse('Heslo musí mít alespoň 8 znaků'); - } - - $passwordHash = password_hash($input['password'], PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]); - - $stmt = $pdo->prepare(' - UPDATE users - SET username = ?, email = ?, password_hash = ?, - first_name = ?, last_name = ?, role_id = ?, - is_active = ?, password_changed_at = NOW() - WHERE id = ? - '); - $stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive, $userId]); - } else { - $stmt = $pdo->prepare(' - UPDATE users - SET username = ?, email = ?, first_name = ?, last_name = ?, role_id = ?, is_active = ? - WHERE id = ? - '); - $stmt->execute([$username, $email, $firstName, $lastName, $roleId, $isActive, $userId]); - } - - // Note: With JWT, user data is in the token - no session to update - - // Audit log - AuditLog::logUpdate('user', $userId, [ - 'username' => $existingUser['username'], - 'email' => $existingUser['email'], - 'first_name' => $existingUser['first_name'], - 'last_name' => $existingUser['last_name'], - 'role_id' => $existingUser['role_id'], - 'is_active' => $existingUser['is_active'], - ], [ - 'username' => $username, - 'email' => $email, - 'first_name' => $firstName, - 'last_name' => $lastName, - 'role_id' => $roleId, - 'is_active' => $isActive, - ], "Upraven uživatel '$username'"); - - successResponse(null, 'Uživatel byl úspěšně aktualizován'); -} - -/** - * DELETE - Delete user - */ -function handleDeleteUser(PDO $pdo, int $userId, int $currentUserId): void -{ - // Prevent self-deletion - if ($userId === $currentUserId) { - errorResponse('Nemůžete smazat svůj vlastní účet'); - } - - // Get user for audit log - $stmt = $pdo->prepare('SELECT username FROM users WHERE id = ?'); - $stmt->execute([$userId]); - $user = $stmt->fetch(); - - if (!$user) { - errorResponse('Uživatel nebyl nalezen', 404); - } - - // Delete related records first (refresh tokens for JWT auth) - $stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?'); - $stmt->execute([$userId]); - - // Delete user - $stmt = $pdo->prepare('DELETE FROM users WHERE id = ?'); - $stmt->execute([$userId]); - - // Audit log - AuditLog::logDelete('user', $userId, ['username' => $user['username']], "Smazán uživatel '{$user['username']}'"); - - successResponse(null, 'Uživatel byl úspěšně smazán'); -} diff --git a/api/config.php b/api/config.php index 22a01cd..a3b242f 100644 --- a/api/config.php +++ b/api/config.php @@ -1,60 +1,20 @@ PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, - PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . DB_CHARSET, - ]; - - try { - $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); - } catch (PDOException $e) { - if (DEBUG_MODE) { - throw $e; - } - error_log('Database connection failed: ' . $e->getMessage()); - throw new PDOException('Database connection failed'); - } - } - - return $pdo; -} - -/** - * Set CORS headers for API responses - */ -function setCorsHeaders(): void -{ - $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; - - if (in_array($origin, CORS_ALLOWED_ORIGINS)) { - header("Access-Control-Allow-Origin: $origin"); - header('Access-Control-Allow-Credentials: true'); - } elseif (DEBUG_MODE && str_starts_with($origin, 'http://127.0.0.1:')) { - header("Access-Control-Allow-Origin: $origin"); - header('Access-Control-Allow-Credentials: true'); - } - // Neznamy origin = zadny CORS header - header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); - header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); - header('Access-Control-Max-Age: 86400'); - - // Handle preflight requests - if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - http_response_code(200); - exit(); - } -} - -/** - * Send JSON response and exit - * - * @param mixed $data Data to send - * @param int $statusCode HTTP status code - */ -function jsonResponse($data, int $statusCode = 200): void -{ - http_response_code($statusCode); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode($data, JSON_UNESCAPED_UNICODE); - exit(); -} - -/** - * Send error response - * - * @param string $message Error message - * @param int $statusCode HTTP status code - */ -function errorResponse(string $message, int $statusCode = 400): void -{ - jsonResponse(['success' => false, 'error' => $message], $statusCode); -} - -/** - * Send success response - * - * @param mixed $data Data to include - * @param string $message Optional message - */ -function successResponse($data = null, string $message = ''): void -{ - $response = ['success' => true]; - if ($message) { - $response['message'] = $message; - } - if ($data !== null) { - $response['data'] = $data; - } - jsonResponse($response); -} - -/** - * Get JSON request body - * - * @return array Decoded JSON data - */ -function getJsonInput(): array -{ - $input = file_get_contents('php://input'); - $data = json_decode($input, true); - return is_array($data) ? $data : []; -} - -/** - * Sanitize string input - * - * @param string $input Input string - * @return string Sanitized string - */ -function sanitize(string $input): string -{ - return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8'); -} - -/** - * Validate email format - * - * @param string $email Email to validate - * @return bool True if valid - */ -function isValidEmail(string $email): bool -{ - return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; -} - -/** - * Validate and sanitize month parameter (YYYY-MM format) - */ -function validateMonth(string $param = 'month'): string -{ - $month = $_GET[$param] ?? date('Y-m'); - if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) { - $month = date('Y-m'); - } - return $month; -} - - -/** - * Get client IP address - * - * Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP). - * If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future, - * update this function to trust specific proxy headers only from known proxy IPs. - * - * @return string IP address - */ -function getClientIp(): string -{ - return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; -} - -/** - * Set security headers for API responses - * - * Sets standard security headers to protect against common web vulnerabilities: - * - X-Content-Type-Options: Prevents MIME type sniffing - * - X-Frame-Options: Prevents clickjacking attacks - * - X-XSS-Protection: Enables browser XSS filter - * - Referrer-Policy: Controls referrer information sent with requests - * - * Note: Content-Security-Policy is not set here as it may interfere with the React frontend - */ -function setSecurityHeaders(): void -{ - header('X-Content-Type-Options: nosniff'); - header('X-Frame-Options: DENY'); - header('Referrer-Policy: strict-origin-when-cross-origin'); - if (!DEBUG_MODE && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { - header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); - } -} - -/** - * Set no-cache headers - * - * Prevents browser caching for sensitive endpoints - */ -function setNoCacheHeaders(): void -{ - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); -} - -/** - * Sdilene generovani cisel pro objednavky a projekty (spolecny ciselny prostor) - */ -function generateSharedNumber(PDO $pdo): string -{ - $yy = date('y'); - - $settings = $pdo->query('SELECT order_type_code FROM company_settings LIMIT 1')->fetch(); - $typeCode = ($settings && !empty($settings['order_type_code'])) ? $settings['order_type_code'] : '71'; - - $prefix = $yy . $typeCode; - $prefixLen = strlen($prefix); - $likePattern = $prefix . '%'; - - $stmt = $pdo->prepare(' - SELECT COALESCE(MAX(seq), 0) FROM ( - SELECT CAST(SUBSTRING(order_number, ? + 1) AS UNSIGNED) AS seq - FROM orders WHERE order_number LIKE ? - UNION ALL - SELECT CAST(SUBSTRING(project_number, ? + 1) AS UNSIGNED) AS seq - FROM projects WHERE project_number LIKE ? - ) combined - '); - $stmt->execute([$prefixLen, $likePattern, $prefixLen, $likePattern]); - $max = (int) $stmt->fetchColumn(); - - return sprintf('%s%s%04d', $yy, $typeCode, $max + 1); -} - -/** - * Get permissions for a user by their ID - * Cached per-request via static variable - * - * @return list - */ -function getUserPermissions(int $userId): array -{ - static $cache = []; - - if (isset($cache[$userId])) { - return $cache[$userId]; - } - - try { - $pdo = db(); - - // Check if user has admin role (superuser bypass) - $stmt = $pdo->prepare(' - SELECT r.name FROM users u - JOIN roles r ON u.role_id = r.id - WHERE u.id = ? - '); - $stmt->execute([$userId]); - $role = $stmt->fetch(); - - if ($role && $role['name'] === 'admin') { - // Admin gets all permissions - $stmt = $pdo->query('SELECT name FROM permissions'); - $cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN); - return $cache[$userId]; - } - - // Regular user: get permissions via role_permissions - $stmt = $pdo->prepare(' - SELECT p.name - FROM permissions p - JOIN role_permissions rp ON p.id = rp.permission_id - JOIN users u ON u.role_id = rp.role_id - WHERE u.id = ? - '); - $stmt->execute([$userId]); - $cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN); - return $cache[$userId]; - } catch (PDOException $e) { - error_log('getUserPermissions error: ' . $e->getMessage()); - return []; - } -} - -/** - * Require a specific permission, return 403 if denied - * - * @param array $authData - */ -function requirePermission(array $authData, string $permission): void -{ - // Admin superuser bypass - if ($authData['user']['is_admin'] ?? false) { - return; - } - - $permissions = getUserPermissions($authData['user_id']); - if (!in_array($permission, $permissions)) { - errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403); - } -} - -/** - * Check if user has a specific permission (returns bool) - * - * @param array $authData - */ -function hasPermission(array $authData, string $permission): bool -{ - if ($authData['user']['is_admin'] ?? false) { - return true; - } - - $permissions = getUserPermissions($authData['user_id']); - return in_array($permission, $permissions); -} diff --git a/api/includes/AuditLog.php b/api/includes/AuditLog.php index b98998f..44d8fdb 100644 --- a/api/includes/AuditLog.php +++ b/api/includes/AuditLog.php @@ -8,8 +8,6 @@ declare(strict_types=1); -require_once dirname(__DIR__) . '/config.php'; - class AuditLog { // Action types diff --git a/api/includes/JWTAuth.php b/api/includes/JWTAuth.php index f38d92a..6c48dba 100644 --- a/api/includes/JWTAuth.php +++ b/api/includes/JWTAuth.php @@ -13,9 +13,6 @@ declare(strict_types=1); -require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; -require_once dirname(__DIR__) . '/config.php'; - use Firebase\JWT\JWT; use Firebase\JWT\Key; use Firebase\JWT\ExpiredException; diff --git a/api/includes/LeaveNotification.php b/api/includes/LeaveNotification.php index 81d3b1b..046395a 100644 --- a/api/includes/LeaveNotification.php +++ b/api/includes/LeaveNotification.php @@ -8,8 +8,6 @@ declare(strict_types=1); -require_once __DIR__ . '/Mailer.php'; - class LeaveNotification { /** @var array */ diff --git a/api/includes/constants.php b/api/includes/constants.php new file mode 100644 index 0000000..323541e --- /dev/null +++ b/api/includes/constants.php @@ -0,0 +1,39 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . DB_CHARSET, + ]; + + try { + $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); + } catch (PDOException $e) { + if (DEBUG_MODE) { + throw $e; + } + error_log('Database connection failed: ' . $e->getMessage()); + throw new PDOException('Database connection failed'); + } + } + + return $pdo; +} + +/** + * Set CORS headers for API responses + */ +function setCorsHeaders(): void +{ + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + + if (in_array($origin, CORS_ALLOWED_ORIGINS)) { + header("Access-Control-Allow-Origin: $origin"); + header('Access-Control-Allow-Credentials: true'); + } elseif (DEBUG_MODE && str_starts_with($origin, 'http://127.0.0.1:')) { + header("Access-Control-Allow-Origin: $origin"); + header('Access-Control-Allow-Credentials: true'); + } + // Neznamy origin = zadny CORS header + header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); + header('Access-Control-Max-Age: 86400'); + + // Handle preflight requests + if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(200); + exit(); + } +} + +/** + * Send JSON response and exit + * + * @param mixed $data Data to send + * @param int $statusCode HTTP status code + */ +function jsonResponse($data, int $statusCode = 200): void +{ + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit(); +} + +/** + * Send error response + * + * @param string $message Error message + * @param int $statusCode HTTP status code + */ +function errorResponse(string $message, int $statusCode = 400): void +{ + jsonResponse(['success' => false, 'error' => $message], $statusCode); +} + +/** + * Send success response + * + * @param mixed $data Data to include + * @param string $message Optional message + */ +function successResponse($data = null, string $message = ''): void +{ + $response = ['success' => true]; + if ($message) { + $response['message'] = $message; + } + if ($data !== null) { + $response['data'] = $data; + } + jsonResponse($response); +} + +/** + * Get JSON request body + * + * @return array Decoded JSON data + */ +function getJsonInput(): array +{ + $input = file_get_contents('php://input'); + $data = json_decode($input, true); + return is_array($data) ? $data : []; +} + +/** + * Sanitize string input + * + * @param string $input Input string + * @return string Sanitized string + */ +function sanitize(string $input): string +{ + return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8'); +} + +/** + * Validate email format + * + * @param string $email Email to validate + * @return bool True if valid + */ +function isValidEmail(string $email): bool +{ + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; +} + +/** + * Validate and sanitize month parameter (YYYY-MM format) + */ +function validateMonth(string $param = 'month'): string +{ + $month = $_GET[$param] ?? date('Y-m'); + if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) { + $month = date('Y-m'); + } + return $month; +} + +/** + * Get client IP address + * + * Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP). + * If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future, + * update this function to trust specific proxy headers only from known proxy IPs. + * + * @return string IP address + */ +function getClientIp(): string +{ + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; +} + +/** + * Set security headers for API responses + * + * Sets standard security headers to protect against common web vulnerabilities: + * - X-Content-Type-Options: Prevents MIME type sniffing + * - X-Frame-Options: Prevents clickjacking attacks + * - X-XSS-Protection: Enables browser XSS filter + * - Referrer-Policy: Controls referrer information sent with requests + * + * Note: Content-Security-Policy is not set here as it may interfere with the React frontend + */ +function setSecurityHeaders(): void +{ + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: DENY'); + header('Referrer-Policy: strict-origin-when-cross-origin'); + if (!DEBUG_MODE && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + } +} + +/** + * Set no-cache headers + * + * Prevents browser caching for sensitive endpoints + */ +function setNoCacheHeaders(): void +{ + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); +} + +/** + * Sdilene generovani cisel pro objednavky a projekty (spolecny ciselny prostor) + */ +function generateSharedNumber(PDO $pdo): string +{ + $yy = date('y'); + + $settings = $pdo->query('SELECT order_type_code FROM company_settings LIMIT 1')->fetch(); + $typeCode = ($settings && !empty($settings['order_type_code'])) ? $settings['order_type_code'] : '71'; + + $prefix = $yy . $typeCode; + $prefixLen = strlen($prefix); + $likePattern = $prefix . '%'; + + $stmt = $pdo->prepare(' + SELECT COALESCE(MAX(seq), 0) FROM ( + SELECT CAST(SUBSTRING(order_number, ? + 1) AS UNSIGNED) AS seq + FROM orders WHERE order_number LIKE ? + UNION ALL + SELECT CAST(SUBSTRING(project_number, ? + 1) AS UNSIGNED) AS seq + FROM projects WHERE project_number LIKE ? + ) combined + '); + $stmt->execute([$prefixLen, $likePattern, $prefixLen, $likePattern]); + $max = (int) $stmt->fetchColumn(); + + return sprintf('%s%s%04d', $yy, $typeCode, $max + 1); +} + +/** + * Get permissions for a user by their ID + * Cached per-request via static variable + * + * @return list + */ +function getUserPermissions(int $userId): array +{ + static $cache = []; + + if (isset($cache[$userId])) { + return $cache[$userId]; + } + + try { + $pdo = db(); + + $stmt = $pdo->prepare(' + SELECT r.name FROM users u + JOIN roles r ON u.role_id = r.id + WHERE u.id = ? + '); + $stmt->execute([$userId]); + $role = $stmt->fetch(); + + if ($role && $role['name'] === 'admin') { + $stmt = $pdo->query('SELECT name FROM permissions'); + $cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN); + return $cache[$userId]; + } + + $stmt = $pdo->prepare(' + SELECT p.name + FROM permissions p + JOIN role_permissions rp ON p.id = rp.permission_id + JOIN users u ON u.role_id = rp.role_id + WHERE u.id = ? + '); + $stmt->execute([$userId]); + $cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN); + return $cache[$userId]; + } catch (PDOException $e) { + error_log('getUserPermissions error: ' . $e->getMessage()); + return []; + } +} + +/** + * Require a specific permission, return 403 if denied + * + * @param array $authData + */ +function requirePermission(array $authData, string $permission): void +{ + if ($authData['user']['is_admin'] ?? false) { + return; + } + + $permissions = getUserPermissions($authData['user_id']); + if (!in_array($permission, $permissions)) { + errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403); + } +} + +/** + * Check if user has a specific permission (returns bool) + * + * @param array $authData + */ +function hasPermission(array $authData, string $permission): bool +{ + if ($authData['user']['is_admin'] ?? false) { + return true; + } + + $permissions = getUserPermissions($authData['user_id']); + return in_array($permission, $permissions); +} diff --git a/phpstan.neon b/phpstan.neon index bc3e3c1..5b851ae 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,3 +4,12 @@ parameters: - api excludePaths: - vendor + bootstrapFiles: + - vendor/autoload.php + - api/includes/helpers.php + - api/includes/constants.php + scanDirectories: + - api/admin/handlers + dynamicConstantNames: + - DEBUG_MODE + - APP_ENV diff --git a/vite.config.js b/vite.config.js index c1fbebd..1f6f7cb 100644 --- a/vite.config.js +++ b/vite.config.js @@ -74,16 +74,16 @@ import { defineConfig } from 'vite' } }, server: { - port: 3000, - host: true, - proxy: { - '/api': { - target: 'http://localhost:80', - changeOrigin: true, - secure: false, - cookieDomainRewrite: 'localhost', - cookiePathRewrite: '/' - } + port: 3000, + host: true, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + secure: false, + cookieDomainRewrite: 'localhost', + cookiePathRewrite: '/' } } + } }) \ No newline at end of file