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