commit f733dee856ef9a092d274b2aeaa61b2e30c97086 Author: Simon Date: Thu Mar 12 12:43:56 2026 +0100 Initial commit diff --git a/.claude/hooks/lint.cjs b/.claude/hooks/lint.cjs new file mode 100644 index 0000000..f1a5bd7 --- /dev/null +++ b/.claude/hooks/lint.cjs @@ -0,0 +1,47 @@ +const { execSync } = require('child_process') +const path = require('path') + +const PROJECT_ROOT = path.resolve(__dirname, '../..') + +let input = '' +process.stdin.on('data', chunk => { input += chunk }) +process.stdin.on('end', () => { + try { + const data = JSON.parse(input) + const filePath = data.tool_input?.file_path + if (!filePath) process.exit(0) + + const ext = path.extname(filePath).toLowerCase() + + if (ext === '.php') { + try { + const result = execSync( + `php vendor/bin/phpstan analyse "${filePath}" --level=5 --no-progress --error-format=raw`, + { cwd: PROJECT_ROOT, encoding: 'utf8', timeout: 30000 } + ) + if (result.trim()) { + process.stderr.write('[PHPStan]\n' + result.trim() + '\n') + } + } catch (e) { + if (e.stdout?.trim()) { + process.stderr.write('[PHPStan]\n' + e.stdout.trim() + '\n') + } + } + } + + if (['.js', '.jsx'].includes(ext) && filePath.includes('src')) { + try { + execSync( + `npx eslint "${filePath}" --max-warnings 0`, + { cwd: PROJECT_ROOT, encoding: 'utf8', timeout: 30000 } + ) + } catch (e) { + if (e.stdout?.trim()) { + process.stderr.write('[ESLint]\n' + e.stdout.trim() + '\n') + } + } + } + } catch { + // JSON parse error - ignore + } +}) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b82dbf1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true, + "code-review@claude-plugins-official": true + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3b2ae90 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run:*)", + "Bash(rm:*)", + "Bash(cp:*)", + "Bash(mv:*)", + "Bash(mkdir:*)", + "Bash(diff:*)", + "Bash(mysql:*)", + "Bash(sed:*)", + "Bash(npx eslint:*)", + "Bash(php:*)", + "Bash(npm install:*)", + "Bash(grep:*)", + "Bash(composer require:*)", + "Bash(vendor/bin/phpcs:*)", + "Bash(vendor/bin/phpcbf:*)", + "Bash(vendor/bin/phpstan analyse:*)", + "Bash(wc:*)", + "Read(//tmp/**)", + "Bash(read f:*)", + "Bash(find D:\\\\Weby\\\\BOHA Website\\\\New\\\\api:*)", + "Bash(find:*)" + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bd35b59 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.php] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f2ffbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Environment +.env +.env.local +.env.production + +# Dependencies +node_modules/ +vendor/ + +# Build +dist/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Backups +*.bak +*.sql +*.backup \ No newline at end of file diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache new file mode 100644 index 0000000..c588bb6 --- /dev/null +++ b/.php-cs-fixer.cache @@ -0,0 +1 @@ +{"php":"8.4.15","version":"3.94.2:v3.94.2#7787ceff91365ba7d623ec410b8f429cdebb4f63","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"no_empty_statement":true,"single_quote":true,"trailing_comma_in_multiline":{"elements":["arrays"]}},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"api\\admin\\login.php":"3979469a999ef34b6df439df5bbc1bb3","api\\includes\\OffersPdfGenerator.tcpdf-backup.php":"1d76702b36b5f5f1db967b655a62411b","api\\includes\\RateLimiter.php":"10243ce7b0b42c25813d9fa2e9b6f8f5","api\\admin\\offers.php":"a98b9beaa27534508895769c867fc1e1","api\\admin\\orders.php":"eee8d4c4c2b63124c08a0fc6f3a8d368","api\\admin\\profile.php":"9c453a6962e46d1b5639b2cceba4bc74","api\\admin\\projects.php":"da8b0b50977119c6e4d0e86d85c87fd5","api\\admin\\refresh.php":"b20ff88926456edbafc9ab40ed8b4c31","api\\admin\\roles.php":"f1f07af1349ab67c5a0797a27c31fb46","api\\admin\\session.php":"7d7d748892c437e8f0040b0f975920db","api\\admin\\sessions.php":"d843a68e4a653efef3ddbe087eea282f","api\\admin\\trips.php":"9d3dfb75d4773ddb37894270e9765fc6","api\\admin\\users.php":"51fefe82fef721c8c76090134e21f3cb","api\\config.php":"5badaab63cdc8335d64c822b69895cde","api\\includes\\AttendanceAdmin.php":"3bf5dd1b87c390b02239cfa2ead01888","api\\includes\\AttendanceHelpers.php":"13ca0c2ba5310f3b2509fa5011718682","api\\includes\\CzechHolidays.php":"5cf3d4e05cc5088db93ddcbee6a4277f","api\\includes\\JWTAuth.php":"5c8977f996e813130c03049e218999ad","api\\includes\\LeaveNotification.php":"e0c5135cb93768fb059e37586e0f089b","api\\includes\\Mailer.php":"bdb9da436f2c402b07bf32049889fa66","api\\admin\\attendance.php":"9ae1363975b4b3d3de7a929ef23278e3","api\\admin\\bank-accounts.php":"75fb057e892bb4abdcc30c91f30cca4b","api\\admin\\invoices-pdf.php":"ad93849f5514e4439955a84fd5515b22","api\\admin\\invoices.php":"f4d8b6d509545d7351da03549141ff9e","api\\admin\\leave-requests.php":"10560ab58b58199401a0820e33463524","api\\admin\\logout.php":"9d57b4165809dee5b4828d539248d6e4","api\\admin\\offers-pdf.php":"be36b56af5e3bde82a5de1e06fc99d51","api\\admin\\offers-customers.php":"0040dc81195fc7de921f7385d7e082ed","api\\admin\\offers-settings.php":"4e03d895677905eca142ccd71837ebb5","api\\admin\\offers-templates.php":"9c20521501b24924772caa76bd778d56","api\\admin\\totp.php":"4bb4761069b11e492c56c29137843f7b","api\\includes\\AuditLog.php":"bc3faf90df5743433a46d029cf1c0634"}} \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..8d6516e --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,19 @@ +in(__DIR__ . '/api') + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'no_trailing_whitespace' => true, + 'no_empty_statement' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'no_whitespace_in_blank_line' => true, + ]) + ->setFinder($finder) + ->setRiskyAllowed(false); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a777263 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +## 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 + +Č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 + +## 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 +- 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) +- 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 +- 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í) + +### Data konvence +- Všechny response klíče **snake_case** (DB sloupce, PHP proměnné, JSON klíče) +- `$authData['user_id']` (ne `userId`) +- camelCase jen tam, kde to vyžaduje React/JS ekosystém (handlery, hooky, props, state) +- Stabilní React keys: `_key` property, nikdy array index + +### Error handling +- API success: `{ "success": true, "data": {...} }` +- 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 + +### 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 +- 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 (-) +- Č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) +- 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í) +3. Build musí projít bez chyb + +## Pravidla + +- Nikdy nečti `.env` soubory +- Chrome používej pouze na výslovnou žádost 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()` diff --git a/api/admin/attendance.php b/api/admin/attendance.php new file mode 100644 index 0000000..45e5818 --- /dev/null +++ b/api/admin/attendance.php @@ -0,0 +1,737 @@ +getMessage()); + errorResponse('Chyba databáze', 500); +} + +// ============================================================================ +// 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 new file mode 100644 index 0000000..9401765 --- /dev/null +++ b/api/admin/bank-accounts.php @@ -0,0 +1,232 @@ +getMessage()); + if (DEBUG_MODE) { + errorResponse('Chyba databáze: ' . $e->getMessage(), 500); + } else { + 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 new file mode 100644 index 0000000..53f8904 --- /dev/null +++ b/api/admin/company-settings.php @@ -0,0 +1,314 @@ +getMessage()); + if (DEBUG_MODE) { + errorResponse('Chyba databáze: ' . $e->getMessage(), 500); + } else { + 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 new file mode 100644 index 0000000..41589e1 --- /dev/null +++ b/api/admin/customers.php @@ -0,0 +1,350 @@ +getMessage()); + if (DEBUG_MODE) { + errorResponse('Chyba databáze: ' . $e->getMessage(), 500); + } else { + 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/dashboard.php b/api/admin/dashboard.php new file mode 100644 index 0000000..3bb75a6 --- /dev/null +++ b/api/admin/dashboard.php @@ -0,0 +1,281 @@ +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]); + $result['my_shift'] = [ + 'has_ongoing' => (bool) $stmt->fetch(), + ]; +} + +// --- Docházka dnes (attendance.admin) --- +if (hasPermission($authData, 'attendance.admin')) { + // Poslední pracovní záznam per uživatel (vyloučit ty co mají leave dnes) + $stmt = $pdo->query(" + SELECT u.id, CONCAT(u.first_name, ' ', u.last_name) as name, + CONCAT(LEFT(u.first_name, 1), LEFT(u.last_name, 1)) as initials, + a.arrival_time, a.departure_time, a.break_start, a.break_end + FROM users u + LEFT JOIN ( + SELECT a1.* + FROM attendance a1 + INNER JOIN ( + SELECT user_id, MAX(id) as max_id + FROM attendance + WHERE shift_date = CURDATE() + AND (leave_type IS NULL OR leave_type = 'work') + GROUP BY user_id + ) a2 ON a1.id = a2.max_id + ) a ON u.id = a.user_id + WHERE u.is_active = 1 + AND u.id NOT IN ( + SELECT user_id FROM attendance + WHERE shift_date = CURDATE() AND leave_type IN ('vacation', 'sick', 'holiday', 'unpaid') + ) + ORDER BY a.arrival_time IS NULL, a.arrival_time ASC + "); + $users = $stmt->fetchAll(); + + $present = 0; + $away = 0; + $attendanceUsers = []; + + foreach ($users as $u) { + $status = 'out'; + $arrivedAt = null; + + if ($u['arrival_time'] !== null) { + if ($u['departure_time'] !== null) { + $status = 'out'; + } elseif ($u['break_start'] !== null && $u['break_end'] === null) { + $status = 'away'; + $away++; + } else { + $status = 'in'; + $present++; + } + $arrivedAt = date('H:i', strtotime($u['arrival_time'])); + } + + $attendanceUsers[] = [ + 'name' => $u['name'], + 'initials' => $u['initials'], + 'status' => $status, + 'arrived_at' => $arrivedAt, + ]; + } + + // Dnes na dovolene/nemocenske + $stmtLeave = $pdo->query(" + SELECT CONCAT(u.first_name, ' ', u.last_name) as name, + CONCAT(LEFT(u.first_name, 1), LEFT(u.last_name, 1)) as initials, + a.leave_type + FROM attendance a + JOIN users u ON a.user_id = u.id + WHERE a.shift_date = CURDATE() AND a.leave_type IN ('vacation', 'sick', 'holiday', 'unpaid') + "); + $onLeave = $stmtLeave->fetchAll(); + + foreach ($onLeave as $leave) { + $attendanceUsers[] = [ + 'name' => $leave['name'], + 'initials' => $leave['initials'], + 'status' => 'leave', + 'arrived_at' => null, + 'leave_type' => $leave['leave_type'], + ]; + } + + $result['attendance'] = [ + 'present_today' => $present, + 'away_today' => $away, + 'total_active' => count($users), + 'on_leave' => count($onLeave), + 'users' => $attendanceUsers, + ]; +} + +// --- Nabídky (offers.view) --- +if (hasPermission($authData, 'offers.view')) { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total, + SUM(CASE WHEN q.order_id IS NULL + AND (q.valid_until IS NULL OR q.valid_until >= CURDATE()) + THEN 1 ELSE 0 END) as open_count, + SUM(CASE WHEN q.order_id IS NULL + AND q.valid_until < CURDATE() + THEN 1 ELSE 0 END) as expired_count, + SUM(CASE WHEN q.order_id IS NOT NULL + THEN 1 ELSE 0 END) as converted_count + FROM quotations q + "); + $counts = $stmt->fetch(); + + $stmtMonth = $pdo->query(" + SELECT COUNT(*) as count FROM quotations + WHERE YEAR(created_at) = YEAR(CURDATE()) AND MONTH(created_at) = MONTH(CURDATE()) + "); + $monthData = $stmtMonth->fetch(); + + $result['offers'] = [ + 'total' => (int) $counts['total'], + 'open_count' => (int) $counts['open_count'], + 'expired_count' => (int) $counts['expired_count'], + 'converted_count' => (int) $counts['converted_count'], + 'created_this_month' => (int) $monthData['count'], + ]; +} + +// --- Projekty (projects.view) --- +if (hasPermission($authData, 'projects.view')) { + $stmt = $pdo->query(" + SELECT p.id, p.name, p.status, c.name as customer_name + FROM projects p + LEFT JOIN customers c ON p.customer_id = c.id + WHERE p.status = 'aktivni' + ORDER BY p.modified_at DESC + LIMIT 5 + "); + $activeProjects = $stmt->fetchAll(); + + $stmtCounts = $pdo->query(" + SELECT + SUM(CASE WHEN status = 'aktivni' THEN 1 ELSE 0 END) as active_count, + SUM(CASE WHEN status = 'dokonceny' THEN 1 ELSE 0 END) as completed_count + FROM projects WHERE status != 'deleted' + "); + $projectCounts = $stmtCounts->fetch(); + + $result['projects'] = [ + 'active_count' => (int) ($projectCounts['active_count'] ?? 0), + 'completed_count' => (int) ($projectCounts['completed_count'] ?? 0), + 'active_projects' => $activeProjects, + ]; +} + +// --- Faktury (invoices.view) --- +if (hasPermission($authData, 'invoices.view')) { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total, + SUM(CASE WHEN i.status = 'paid' + AND YEAR(i.paid_date) = YEAR(CURDATE()) + AND MONTH(i.paid_date) = MONTH(CURDATE()) + THEN 1 ELSE 0 END) as paid_this_month, + SUM(CASE WHEN i.status IN ('issued', 'overdue') + THEN 1 ELSE 0 END) as unpaid_count + FROM invoices i + "); + $invCounts = $stmt->fetch(); + + // Tržby tento měsíc per faktura (pro kurz k datu vystaveni) + $stmtRevenue = $pdo->query(" + SELECT i.id, i.currency, i.issue_date, + COALESCE(SUM(ii.quantity * ii.unit_price), 0) as revenue + FROM invoices i + JOIN invoice_items ii ON i.id = ii.invoice_id + WHERE i.status = 'paid' + AND YEAR(i.paid_date) = YEAR(CURDATE()) + AND MONTH(i.paid_date) = MONTH(CURDATE()) + GROUP BY i.id, i.currency, i.issue_date + ORDER BY revenue DESC + "); + + $revByCurrency = []; + $revCzkItems = []; + foreach ($stmtRevenue->fetchAll() as $row) { + $cur = $row['currency']; + $amt = (float) $row['revenue']; + $revByCurrency[$cur] = ($revByCurrency[$cur] ?? 0) + $amt; + $revCzkItems[] = [ + 'amount' => $amt, + 'currency' => $cur, + 'date' => $row['issue_date'], + ]; + } + + $revenueByCurrency = []; + foreach ($revByCurrency as $cur => $total) { + $revenueByCurrency[] = [ + 'currency' => $cur, + 'amount' => round($total, 2), + ]; + } + + $cnb = CnbRates::getInstance(); + + $result['invoices'] = [ + 'total' => (int) $invCounts['total'], + 'paid_this_month' => (int) $invCounts['paid_this_month'], + 'unpaid_count' => (int) $invCounts['unpaid_count'], + 'revenue_this_month' => $revenueByCurrency, + 'revenue_czk' => $cnb->sumToCzk($revCzkItems), + ]; +} + +// --- Čekající žádosti (attendance.approve) --- +if (hasPermission($authData, 'attendance.approve')) { + $stmt = $pdo->query(" + SELECT COUNT(*) as count FROM leave_requests WHERE status = 'pending' + "); + $pending = $stmt->fetch(); + + $result['leave_pending'] = [ + 'count' => (int) $pending['count'], + ]; +} + +// --- Poslední aktivita (settings.roles = admin přehled) --- +if (hasPermission($authData, 'settings.roles')) { + $stmt = $pdo->query(" + SELECT username, action, entity_type, description, created_at + FROM audit_logs + WHERE action IN ('create', 'update', 'delete', 'login') + ORDER BY created_at DESC + LIMIT 8 + "); + $result['recent_activity'] = $stmt->fetchAll(); +} + +jsonResponse($result); diff --git a/api/admin/invoices-pdf.php b/api/admin/invoices-pdf.php new file mode 100644 index 0000000..aeb941a --- /dev/null +++ b/api/admin/invoices-pdf.php @@ -0,0 +1,1096 @@ +prepare('SELECT * FROM invoices WHERE id = ?'); + $stmt->execute([$id]); + $invoice = $stmt->fetch(); + if (!$invoice) { + header('Content-Type: application/json; charset=utf-8'); + errorResponse('Faktura nebyla nalezena', 404); + } + + // Polozky + $stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position'); + $stmt->execute([$id]); + $items = $stmt->fetchAll(); + + // Zakaznik + $customer = null; + if ($invoice['customer_id']) { + $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); + $stmt->execute([$invoice['customer_id']]); + $customer = $stmt->fetch(); + } + + // Firemni udaje + $stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1'); + $settings = $stmt->fetch(); + + // Logo + $logoBase64 = ''; + $logoMime = 'image/png'; + if (!empty($settings['logo_data'])) { + $logoBase64 = base64_encode($settings['logo_data']); + $finfo = new finfo(FILEINFO_MIME_TYPE); + $detected = $finfo->buffer($settings['logo_data']); + $allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; + if ($detected && in_array($detected, $allowedMimes)) { + $logoMime = $detected; + } + } + + // Helpery + $esc = function ($str) { + return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8'); + }; + + $formatNum = function ($number, $decimals = 2) { + return number_format((float)$number, $decimals, ',', "\xC2\xA0"); + }; + + $formatDate = function ($dateStr) { + if (!$dateStr) { + return ''; + } + $d = strtotime($dateStr); + return $d !== false ? date('d.m.Y', $d) : $dateStr; + }; + + // Preklady statickych textu + $translations = [ + 'cs' => [ + 'title' => 'Faktura', + 'heading' => 'FAKTURA - DAŇOVÝ DOKLAD č.', + 'supplier' => 'Dodavatel', + 'customer' => 'Odběratel', + 'bank' => 'Banka:', + 'swift' => 'SWIFT:', + 'iban' => 'IBAN:', + 'account_no' => 'Číslo účtu:', + 'var_symbol' => 'Variabilní s.:', + 'const_symbol' => 'Konstantní s.:', + 'order_no' => 'Objednávka č.:', + 'issue_date' => 'Datum vystavení:', + 'due_date' => 'Datum splatnosti:', + 'tax_date' => 'Datum uskutečnění plnění:', + 'payment_method' => 'Forma úhrady:', + 'billing' => 'Fakturujeme Vám za:', + 'col_no' => 'Č.', + 'col_desc' => 'Popis', + 'col_qty' => 'Množství', + 'col_unit_price' => 'Jedn. cena', + 'col_price' => 'Cena', + 'col_vat_pct' => '%DPH', + 'col_vat' => 'DPH', + 'col_total' => 'Celkem', + 'subtotal' => 'Mezisoučet:', + 'vat_label' => 'DPH', + 'total' => 'Celkem k úhradě', + 'amounts_in' => 'Částky jsou uvedeny v', + 'notes' => 'Poznámky', + 'issued_by' => 'Vystavil:', + 'notice' => 'Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti' + . ' uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp.' + . ' zákonné výši a smluvní pokutu (byla-li sjednána).', + 'vat_recap' => 'Rekapitulace DPH v Kč:', + 'vat_base' => 'Základ v Kč', + 'vat_rate' => 'Sazba', + 'vat_amount' => 'DPH v Kč', + 'vat_with_total' => 'Celkem s DPH v Kč', + 'received_by' => 'Převzal:', + 'stamp' => 'Razítko:', + 'ico' => 'IČ: ', + 'dic' => 'DIČ: ', + ], + 'en' => [ + 'title' => 'Invoice', + 'heading' => 'INVOICE - TAX DOCUMENT No.', + 'supplier' => 'Supplier', + 'customer' => 'Customer', + 'bank' => 'Bank:', + 'swift' => 'SWIFT:', + 'iban' => 'IBAN:', + 'account_no' => 'Account No.:', + 'var_symbol' => 'Variable symbol:', + 'const_symbol' => 'Constant symbol:', + 'order_no' => 'Order No.:', + 'issue_date' => 'Issue date:', + 'due_date' => 'Due date:', + 'tax_date' => 'Tax point date:', + 'payment_method' => 'Payment method:', + 'billing' => 'We invoice you for:', + 'col_no' => 'No.', + 'col_desc' => 'Description', + 'col_qty' => 'Quantity', + 'col_unit_price' => 'Unit price', + 'col_price' => 'Price', + 'col_vat_pct' => 'VAT%', + 'col_vat' => 'VAT', + 'col_total' => 'Total', + 'subtotal' => 'Subtotal:', + 'vat_label' => 'VAT', + 'total' => 'Total to pay', + 'amounts_in' => 'Amounts are in', + 'notes' => 'Notes', + 'issued_by' => 'Issued by:', + 'notice' => 'Please note that in case of late payment, we will charge default interest' + . ' at the agreed or statutory rate and a contractual penalty (if agreed).', + 'vat_recap' => 'VAT recapitulation in CZK:', + 'vat_base' => 'Tax base in CZK', + 'vat_rate' => 'Rate', + 'vat_amount' => 'VAT in CZK', + 'vat_with_total' => 'Total incl. VAT in CZK', + 'received_by' => 'Received by:', + 'stamp' => 'Stamp:', + 'ico' => 'Reg. No.: ', + 'dic' => 'Tax ID: ', + ], + ]; + $t = $translations[$lang]; + + $currency = $invoice['currency'] ?? 'CZK'; + $applyVat = !empty($invoice['apply_vat']); + + // Vypocty + $vatSummary = []; + $subtotal = 0; + + foreach ($items as $item) { + $lineSubtotal = (float)$item['quantity'] * (float)$item['unit_price']; + $subtotal += $lineSubtotal; + $rate = (float)$item['vat_rate']; + + if (!isset($vatSummary[(string)$rate])) { + $vatSummary[(string)$rate] = ['base' => 0, 'vat' => 0]; + } + $vatSummary[(string)$rate]['base'] += $lineSubtotal; + if ($applyVat) { + $vatSummary[(string)$rate]['vat'] += $lineSubtotal * $rate / 100; + } + } + + $totalVat = 0; + foreach ($vatSummary as $data) { + $totalVat += $data['vat']; + } + $totalToPay = $subtotal + $totalVat; + + // Rekapitulace DPH - vzdy v CZK (cesky danovy doklad) + $isForeign = strtoupper($currency) !== 'CZK'; + $cnbRate = 1.0; + if ($isForeign) { + $cnb = CnbRates::getInstance(); + $issueDate = $invoice['issue_date'] ?? ''; + // Kurz CNB k datu vystaveni + $cnbRate = $cnb->toCzk(1.0, $currency, $issueDate); + } + + $vatRates = [21, 12, 0]; + $vatRecap = []; + foreach ($vatRates as $rate) { + $key = (string)(float)$rate; + $base = $vatSummary[$key]['base'] ?? 0; + $vat = $vatSummary[$key]['vat'] ?? 0; + $vatRecap[] = [ + 'rate' => $rate, + 'base' => round($base * $cnbRate, 2), + 'vat' => round($vat * $cnbRate, 2), + 'total' => round(($base + $vat) * $cnbRate, 2), + ]; + } + + // QR kod - SPAYD + $spaydParts = [ + 'SPD*1.0', + 'ACC:' . str_replace(' ', '', $invoice['bank_iban']), + 'AM:' . number_format($totalToPay, 2, '.', ''), + 'CC:' . $currency, + 'X-VS:' . $invoice['invoice_number'], + 'X-KS:' . ($invoice['constant_symbol'] ?: '0308'), + 'MSG:' . $t['title'] . ' ' . $invoice['invoice_number'], + ]; + $spaydString = implode('*', $spaydParts); + + $qrSvg = ''; + try { + $qrOptions = new QROptions([ + 'outputType' => QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => QRCode::ECC_M, + 'scale' => 3, + 'addQuietzone' => true, + 'svgUseCssProperties' => false, + ]); + $qrCode = new QRCode($qrOptions); + $qrSvg = $qrCode->render($spaydString); + } catch (Exception $e) { + error_log('QR code generation failed: ' . $e->getMessage()); + } + + // Sanitizace HTML z Rich editoru + $cleanQuillHtml = function (string $html): string { + $allowedTags = '