Initial commit
47
.claude/hooks/lint.cjs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
6
.claude/settings.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"frontend-design@claude-plugins-official": true,
|
||||||
|
"code-review@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
27
.claude/settings.local.json
Normal file
@@ -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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.editorconfig
Normal file
@@ -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
|
||||||
27
.gitignore
vendored
Normal file
@@ -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
|
||||||
1
.php-cs-fixer.cache
Normal file
@@ -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"}}
|
||||||
19
.php-cs-fixer.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$finder = PhpCsFixer\Finder::create()
|
||||||
|
->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);
|
||||||
88
CLAUDE.md
Normal file
@@ -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()` + `<Forbidden />` guard, backend `requirePermission()`
|
||||||
737
api/admin/attendance.php
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Attendance API
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* GET /api/admin/attendance.php - Get current shift status and today's shifts
|
||||||
|
* GET /api/admin/attendance.php?action=history - Get attendance history for month
|
||||||
|
* GET /api/admin/attendance.php?action=admin - Get all attendance records (admin)
|
||||||
|
* GET /api/admin/attendance.php?action=balances - Get leave balances (admin)
|
||||||
|
* GET /api/admin/attendance.php?action=workfund&year=YYYY - Get work fund overview (admin)
|
||||||
|
* GET /api/admin/attendance.php?action=location&id=X - Get location for record (admin)
|
||||||
|
* GET /api/admin/attendance.php?action=print - Get print data for attendance (admin)
|
||||||
|
* GET /api/admin/attendance.php?action=projects - Get active projects list
|
||||||
|
* GET /api/admin/attendance.php?action=project_report&month=YYYY-MM - Get project hours report (admin)
|
||||||
|
* GET /api/admin/attendance.php?action=project_logs&attendance_id=X - Get project logs for a shift
|
||||||
|
* POST /api/admin/attendance.php - Clock in/out/break actions
|
||||||
|
* POST /api/admin/attendance.php?action=update_address - Doplnit adresu k poslednimu zaznamu
|
||||||
|
* POST /api/admin/attendance.php?action=notes - Save notes for current shift
|
||||||
|
* POST /api/admin/attendance.php?action=switch_project - Switch active project on current shift
|
||||||
|
* POST /api/admin/attendance.php?action=leave - Add leave record
|
||||||
|
* POST /api/admin/attendance.php?action=save_project_logs - Save project logs for record (admin)
|
||||||
|
* POST /api/admin/attendance.php?action=create - Create attendance record (admin)
|
||||||
|
* POST /api/admin/attendance.php?action=bulk_attendance - Bulk add attendance for month (admin)
|
||||||
|
* POST /api/admin/attendance.php?action=balances - Update leave balance (admin)
|
||||||
|
* PUT /api/admin/attendance.php?id=X - Update attendance record (admin)
|
||||||
|
* DELETE /api/admin/attendance.php?id=X - Delete attendance record (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/CzechHolidays.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AttendanceAdmin.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$recordId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
$isAdmin = $authData['user']['is_admin'] ?? false;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
if ($action === 'history') {
|
||||||
|
requirePermission($authData, 'attendance.history');
|
||||||
|
handleGetHistory($pdo, $userId);
|
||||||
|
} elseif ($action === 'admin') {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
handleGetAdmin($pdo);
|
||||||
|
} elseif ($action === 'balances') {
|
||||||
|
requirePermission($authData, 'attendance.balances');
|
||||||
|
handleGetBalances($pdo);
|
||||||
|
} elseif ($action === 'location' && $recordId) {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
handleGetLocation($pdo, $recordId);
|
||||||
|
} elseif ($action === 'users') {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
handleGetUsers($pdo);
|
||||||
|
} elseif ($action === 'print') {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
handleGetPrint($pdo);
|
||||||
|
} elseif ($action === 'workfund') {
|
||||||
|
requirePermission($authData, 'attendance.balances');
|
||||||
|
handleGetWorkFund($pdo);
|
||||||
|
} elseif ($action === 'projects') {
|
||||||
|
handleGetProjects();
|
||||||
|
} elseif ($action === 'project_report') {
|
||||||
|
if (!hasPermission($authData, 'attendance.admin') && !hasPermission($authData, 'attendance.balances')) {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
}
|
||||||
|
handleGetProjectReport($pdo);
|
||||||
|
} elseif ($action === 'project_logs') {
|
||||||
|
handleGetProjectLogs($pdo, $userId, $authData);
|
||||||
|
} else {
|
||||||
|
requirePermission($authData, 'attendance.record');
|
||||||
|
handleGetCurrent($pdo, $userId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if ($action === 'leave') {
|
||||||
|
requirePermission($authData, 'attendance.record');
|
||||||
|
handleAddLeave($pdo, $userId);
|
||||||
|
} elseif ($action === 'notes') {
|
||||||
|
requirePermission($authData, 'attendance.record');
|
||||||
|
handleSaveNotes($pdo, $userId);
|
||||||
|
} elseif ($action === 'switch_project') {
|
||||||
|
requirePermission($authData, 'attendance.record');
|
||||||
|
handleSwitchProject($pdo, $userId);
|
||||||
|
} elseif ($action === 'save_project_logs') {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
handleSaveProjectLogs($pdo);
|
||||||
|
} elseif ($action === 'create') {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
handleCreateAttendance($pdo);
|
||||||
|
} elseif ($action === 'bulk_attendance') {
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
handleBulkAttendance($pdo);
|
||||||
|
} elseif ($action === 'balances') {
|
||||||
|
requirePermission($authData, 'attendance.balances');
|
||||||
|
handleUpdateBalance($pdo);
|
||||||
|
} elseif ($action === 'update_address') {
|
||||||
|
requirePermission($authData, 'attendance.record');
|
||||||
|
handleUpdateAddress($pdo, $userId);
|
||||||
|
} else {
|
||||||
|
requirePermission($authData, 'attendance.record');
|
||||||
|
handlePunch($pdo, $userId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
if (!$recordId) {
|
||||||
|
errorResponse('ID záznamu je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateAttendance($pdo, $recordId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'attendance.admin');
|
||||||
|
if (!$recordId) {
|
||||||
|
errorResponse('ID záznamu je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteAttendance($pdo, $recordId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Attendance API error: ' . $e->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<string, mixed> $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');
|
||||||
|
}
|
||||||
232
api/admin/bank-accounts.php
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Bank Accounts API
|
||||||
|
*
|
||||||
|
* GET /api/admin/bank-accounts.php - Seznam bankovnich uctu
|
||||||
|
* POST /api/admin/bank-accounts.php - Vytvoreni uctu
|
||||||
|
* PUT /api/admin/bank-accounts.php?id=X - Uprava uctu
|
||||||
|
* DELETE /api/admin/bank-accounts.php?id=X - Smazani uctu
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
handleGetBankAccountList($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
handleCreateBankAccount($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID účtu je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateBankAccount($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID účtu je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteBankAccount($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Bank Accounts API error: ' . $e->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');
|
||||||
|
}
|
||||||
314
api/admin/company-settings.php
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Company Settings API
|
||||||
|
*
|
||||||
|
* GET /api/admin/company-settings.php - Get company settings
|
||||||
|
* PUT /api/admin/company-settings.php - Update company settings
|
||||||
|
* POST /api/admin/company-settings.php?action=logo - Upload logo
|
||||||
|
* GET /api/admin/company-settings.php?action=logo - Get logo image
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
if (!($method === 'GET' && $action === 'logo')) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
if ($action === 'logo') {
|
||||||
|
requirePermission($authData, 'offers.view');
|
||||||
|
handleGetLogo($pdo);
|
||||||
|
} else {
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
handleGetOffersSettings($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
handleUpdateOffersSettings($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if ($action === 'logo') {
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
handleUploadLogo($pdo);
|
||||||
|
} else {
|
||||||
|
errorResponse('Neplatná akce', 400);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Offers Settings API error: ' . $e->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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<mixed>|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<mixed>|null $currentRaw */
|
||||||
|
$currentRaw = !empty($settings['custom_fields'])
|
||||||
|
? json_decode($settings['custom_fields'], true)
|
||||||
|
: null;
|
||||||
|
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $stored */
|
||||||
|
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
||||||
|
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $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();
|
||||||
|
}
|
||||||
350
api/admin/customers.php
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Customers API
|
||||||
|
*
|
||||||
|
* GET /api/admin/customers.php - List customers
|
||||||
|
* GET /api/admin/customers.php?id=X - Get single customer
|
||||||
|
* GET /api/admin/customers.php?action=search&q= - Search customers
|
||||||
|
* POST /api/admin/customers.php - Create customer
|
||||||
|
* PUT /api/admin/customers.php?id=X - Update customer
|
||||||
|
* DELETE /api/admin/customers.php?id=X - Delete customer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$customerId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'offers.view');
|
||||||
|
if ($action === 'search') {
|
||||||
|
handleSearch($pdo);
|
||||||
|
} elseif ($customerId) {
|
||||||
|
handleGetOne($pdo, $customerId);
|
||||||
|
} else {
|
||||||
|
handleGetAll($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
requirePermission($authData, 'offers.create');
|
||||||
|
handleCreateCustomer($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'offers.edit');
|
||||||
|
if (!$customerId) {
|
||||||
|
errorResponse('ID zákazníka je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateCustomer($pdo, $customerId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'offers.delete');
|
||||||
|
if (!$customerId) {
|
||||||
|
errorResponse('ID zákazníka je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteCustomer($pdo, $customerId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Customers API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $customer */
|
||||||
|
function parseCustomerCustomFields(array &$customer): void
|
||||||
|
{
|
||||||
|
/** @var array<mixed>|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<string, mixed> $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<mixed>|null $currentRaw */
|
||||||
|
$currentRaw = !empty($existingJson) ? json_decode($existingJson, true) : null;
|
||||||
|
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $stored */
|
||||||
|
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
||||||
|
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $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');
|
||||||
|
}
|
||||||
281
api/admin/dashboard.php
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard API - agregovaná data pro dashboard
|
||||||
|
*
|
||||||
|
* GET /api/admin/dashboard.php
|
||||||
|
* Vrací sekce dle oprávnění přihlášeného uživatele.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/CnbRates.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
// --- Stav smeny aktualniho uzivatele (attendance.record) ---
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
if (hasPermission($authData, 'attendance.record')) {
|
||||||
|
$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]);
|
||||||
|
$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);
|
||||||
1096
api/admin/invoices-pdf.php
Normal file
803
api/admin/invoices.php
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Invoices CRUD API
|
||||||
|
*
|
||||||
|
* GET /api/admin/invoices.php - Seznam faktur
|
||||||
|
* GET /api/admin/invoices.php?action=detail&id=X - Detail faktury
|
||||||
|
* GET /api/admin/invoices.php?action=next_number - Dalsi cislo faktury
|
||||||
|
* GET /api/admin/invoices.php?action=order_data&id=X - Data objednavky pro pre-fill
|
||||||
|
* GET /api/admin/invoices.php?action=stats - KPI statistiky (month, year)
|
||||||
|
* POST /api/admin/invoices.php - Vytvoreni faktury
|
||||||
|
* PUT /api/admin/invoices.php?id=X - Uprava faktury / zmena stavu
|
||||||
|
* DELETE /api/admin/invoices.php?id=X - Smazani faktury
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/CnbRates.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'invoices.view');
|
||||||
|
switch ($action) {
|
||||||
|
case 'detail':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID faktury je povinné');
|
||||||
|
}
|
||||||
|
handleGetDetail($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'next_number':
|
||||||
|
requirePermission($authData, 'invoices.create');
|
||||||
|
handleGetNextNumber($pdo);
|
||||||
|
break;
|
||||||
|
case 'order_data':
|
||||||
|
requirePermission($authData, 'invoices.create');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID objednávky je povinné');
|
||||||
|
}
|
||||||
|
handleGetOrderData($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'stats':
|
||||||
|
requirePermission($authData, 'invoices.view');
|
||||||
|
handleGetStats($pdo);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handleGetList($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
requirePermission($authData, 'invoices.create');
|
||||||
|
handleCreateInvoice($pdo, $authData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'invoices.edit');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID faktury je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateInvoice($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'invoices.delete');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID faktury je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteInvoice($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Invoices API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Status transitions ---
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
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<int, string|int|float> $params
|
||||||
|
* @return array{amounts: array<int, array{amount: float, currency: string}>, 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<string, mixed> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
537
api/admin/leave-requests.php
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Leave Requests API
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* GET /api/admin/leave-requests.php - Get own leave requests
|
||||||
|
* GET /api/admin/leave-requests.php?action=pending - Get all pending requests (approver)
|
||||||
|
* GET /api/admin/leave-requests.php?action=all - Get all requests with filters (approver)
|
||||||
|
* POST /api/admin/leave-requests.php - Submit new leave request
|
||||||
|
* POST /api/admin/leave-requests.php?action=cancel - Cancel own pending request
|
||||||
|
* POST /api/admin/leave-requests.php?action=approve - Approve a request (approver)
|
||||||
|
* POST /api/admin/leave-requests.php?action=reject - Reject a request (approver)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/LeaveNotification.php';
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
if ($action === 'pending') {
|
||||||
|
requirePermission($authData, 'attendance.approve');
|
||||||
|
handleGetPending($pdo);
|
||||||
|
} elseif ($action === 'all') {
|
||||||
|
requirePermission($authData, 'attendance.approve');
|
||||||
|
handleGetAll($pdo);
|
||||||
|
} else {
|
||||||
|
handleGetMyRequests($pdo, $userId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if ($action === 'cancel') {
|
||||||
|
handleCancelRequest($pdo, $userId);
|
||||||
|
} elseif ($action === 'approve') {
|
||||||
|
requirePermission($authData, 'attendance.approve');
|
||||||
|
handleApproveRequest($pdo, $userId, $authData);
|
||||||
|
} elseif ($action === 'reject') {
|
||||||
|
requirePermission($authData, 'attendance.approve');
|
||||||
|
handleRejectRequest($pdo, $userId, $authData);
|
||||||
|
} else {
|
||||||
|
handleSubmitRequest($pdo, $userId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Nepodporovaná metoda', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Leave requests API error: ' . $e->getMessage());
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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');
|
||||||
|
}
|
||||||
180
api/admin/login.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Admin Login API (JWT)
|
||||||
|
*
|
||||||
|
* POST /api/admin/login.php
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* {
|
||||||
|
* "username": "string",
|
||||||
|
* "password": "string",
|
||||||
|
* "remember": boolean (optional)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* "success": boolean,
|
||||||
|
* "data": { "access_token", "expires_in", "user" } | null,
|
||||||
|
* "error": "string" | null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$rateLimiter = new RateLimiter();
|
||||||
|
$rateLimiter->setFailClosed();
|
||||||
|
$rateLimiter->enforce('login', 10);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$username = trim($input['username'] ?? '');
|
||||||
|
$password = $input['password'] ?? '';
|
||||||
|
$remember = (bool) ($input['remember'] ?? false);
|
||||||
|
|
||||||
|
if (empty($username)) {
|
||||||
|
errorResponse('Uživatelské jméno je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($password)) {
|
||||||
|
errorResponse('Heslo je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||||
|
u.role_id, u.failed_login_attempts, u.locked_until, u.is_active, u.totp_enabled,
|
||||||
|
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.username = ? OR u.email = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$username, $username]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
AuditLog::logLoginFailed($username, 'invalid_credentials');
|
||||||
|
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user['is_active']) {
|
||||||
|
AuditLog::logLoginFailed($username, 'account_deactivated');
|
||||||
|
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user['locked_until'] && strtotime($user['locked_until']) > time()) {
|
||||||
|
AuditLog::logLoginFailed($username, 'account_locked');
|
||||||
|
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password_verify($password, $user['password_hash'])) {
|
||||||
|
$attempts = $user['failed_login_attempts'] + 1;
|
||||||
|
$lockUntil = null;
|
||||||
|
|
||||||
|
if ($attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||||
|
$lockUntil = date('Y-m-d H:i:s', time() + (LOCKOUT_MINUTES * 60));
|
||||||
|
$attempts = 0; // Reset after lockout
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE users SET failed_login_attempts = ?, locked_until = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$attempts, $lockUntil, $user['id']]);
|
||||||
|
|
||||||
|
AuditLog::logLoginFailed($username, 'invalid_credentials');
|
||||||
|
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = ['name' => $user['role_name'], 'display_name' => $user['role_display_name']];
|
||||||
|
|
||||||
|
// 2FA - neresit failed_attempts, az po overeni
|
||||||
|
if ($user['totp_enabled']) {
|
||||||
|
$loginToken = bin2hex(random_bytes(32));
|
||||||
|
$hashedLoginToken = hash('sha256', $loginToken);
|
||||||
|
$loginTokenExpiry = date('Y-m-d H:i:s', time() + 300);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()');
|
||||||
|
$stmt->execute([$user['id']]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO totp_login_tokens (user_id, token_hash, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$user['id'], $hashedLoginToken, $loginTokenExpiry]);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'requires_2fa' => true,
|
||||||
|
'login_token' => $loginToken,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bez 2FA - reset failed attempts a pokracovat
|
||||||
|
$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' => $role['name'] ?? null,
|
||||||
|
'role_display' => $role['display_name'] ?? $role['name'] ?? null,
|
||||||
|
'is_admin' => ($role['name'] ?? '') === 'admin',
|
||||||
|
];
|
||||||
|
|
||||||
|
$accessToken = JWTAuth::generateAccessToken($userData);
|
||||||
|
JWTAuth::generateRefreshToken($user['id'], $remember);
|
||||||
|
AuditLog::logLogin($user['id'], $user['username']);
|
||||||
|
$require2FA = false;
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||||
|
$require2FA = (bool) $stmt->fetchColumn();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = JWTAuth::getUserPermissions($user['id']);
|
||||||
|
|
||||||
|
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' => $permissions,
|
||||||
|
'totp_enabled' => false,
|
||||||
|
'require_2fa' => $require2FA,
|
||||||
|
],
|
||||||
|
], 'Přihlášení úspěšné');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Login PDO error: ' . $e->getMessage());
|
||||||
|
errorResponse('Došlo k systémové chybě. Zkuste to prosím později.', 500);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('Login error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
errorResponse('Došlo k systémové chybě. Zkuste to prosím později.', 500);
|
||||||
|
}
|
||||||
51
api/admin/logout.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Admin Logout API (JWT)
|
||||||
|
*
|
||||||
|
* POST /api/admin/logout.php
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "message": "Logged out successfully"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Rate limiting (30 requests/minute)
|
||||||
|
$rateLimiter = new RateLimiter();
|
||||||
|
$rateLimiter->enforce('logout', 30);
|
||||||
|
|
||||||
|
// Only accept POST
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from access token if available (for audit logging)
|
||||||
|
$authData = JWTAuth::optionalAuth();
|
||||||
|
|
||||||
|
// Log logout before revoking tokens
|
||||||
|
if ($authData) {
|
||||||
|
AuditLog::logLogout($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke refresh token (from cookie)
|
||||||
|
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||||
|
if ($refreshToken) {
|
||||||
|
JWTAuth::revokeRefreshToken($refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(null, 'Odhlášení úspěšné');
|
||||||
858
api/admin/offers-pdf.php
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Offers PDF Export (Print-ready HTML)
|
||||||
|
*
|
||||||
|
* Returns a self-contained HTML page that auto-triggers window.print().
|
||||||
|
* The browser's "Save as PDF" produces the final PDF.
|
||||||
|
*
|
||||||
|
* GET /api/admin/offers-pdf.php?id=X - Returns print-ready HTML
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
requirePermission($authData, 'offers.export');
|
||||||
|
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
if (!$id) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
errorResponse('ID nabídky je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$quotation = $stmt->fetch();
|
||||||
|
if (!$quotation) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
errorResponse('Nabídka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = null;
|
||||||
|
if ($quotation['customer_id']) {
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||||
|
$stmt->execute([$quotation['customer_id']]);
|
||||||
|
$customer = $stmt->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$items = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$sections = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1');
|
||||||
|
$settings = $stmt->fetch();
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$isCzech = ($quotation['language'] ?? 'EN') === 'CZ';
|
||||||
|
$currency = $quotation['currency'] ?? 'EUR';
|
||||||
|
|
||||||
|
$translations = [
|
||||||
|
'title' => ['EN' => 'PRICE QUOTATION', 'CZ' => 'CENOVÁ NABÍDKA'],
|
||||||
|
'scope_title' => ['EN' => 'SCOPE OF THE PROJECT', 'CZ' => 'ROZSAH PROJEKTU'],
|
||||||
|
'valid_until' => ['EN' => 'Valid until', 'CZ' => 'Platnost do'],
|
||||||
|
'customer' => ['EN' => 'Customer', 'CZ' => 'Zákazník'],
|
||||||
|
'supplier' => ['EN' => 'Supplier', 'CZ' => 'Dodavatel'],
|
||||||
|
'no' => ['EN' => 'N.', 'CZ' => 'Č.'],
|
||||||
|
'description' => ['EN' => 'Description', 'CZ' => 'Popis'],
|
||||||
|
'qty' => ['EN' => 'Qty', 'CZ' => 'Mn.'],
|
||||||
|
'unit_price' => ['EN' => 'Unit Price', 'CZ' => 'Jedn. cena'],
|
||||||
|
'included' => ['EN' => 'Included', 'CZ' => 'Zahrnuto'],
|
||||||
|
'total' => ['EN' => 'Total', 'CZ' => 'Celkem'],
|
||||||
|
'subtotal' => ['EN' => 'Subtotal', 'CZ' => 'Mezisoučet'],
|
||||||
|
'vat' => ['EN' => 'VAT', 'CZ' => 'DPH'],
|
||||||
|
'total_to_pay' => ['EN' => 'Total to pay', 'CZ' => 'Celkem k úhradě'],
|
||||||
|
'exchange_rate' => ['EN' => 'Exchange rate', 'CZ' => 'Směnný kurz'],
|
||||||
|
'ico' => ['EN' => 'ID', 'CZ' => 'IČO'],
|
||||||
|
'dic' => ['EN' => 'VAT ID', 'CZ' => 'DIČ'],
|
||||||
|
'yes' => ['EN' => 'Yes', 'CZ' => 'Ano'],
|
||||||
|
'no_val' => ['EN' => 'No', 'CZ' => 'Ne'],
|
||||||
|
'page' => ['EN' => 'Page', 'CZ' => 'Strana'],
|
||||||
|
'of' => ['EN' => 'of', 'CZ' => 'z'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$lang = $isCzech ? 'CZ' : 'EN';
|
||||||
|
$t = function ($key) use ($translations, $lang) {
|
||||||
|
return $translations[$key][$lang] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
$formatNum = function ($number, $decimals, $decSep = ',', $thousandSep = "\xC2\xA0") {
|
||||||
|
$fixed = number_format(abs($number), $decimals, '.', '');
|
||||||
|
$parts = explode('.', $fixed);
|
||||||
|
$intPart = preg_replace('/\B(?=(\d{3})+(?!\d))/', $thousandSep, $parts[0]);
|
||||||
|
$result = isset($parts[1]) ? $intPart . $decSep . $parts[1] : $intPart;
|
||||||
|
return $number < 0 ? '-' . $result : $result;
|
||||||
|
};
|
||||||
|
|
||||||
|
$formatCurrency = function ($amount) use ($currency, $formatNum) {
|
||||||
|
$n = floatval($amount);
|
||||||
|
switch ($currency) {
|
||||||
|
case 'EUR':
|
||||||
|
return $formatNum($n, 2, ',', "\xC2\xA0") . ' €';
|
||||||
|
case 'USD':
|
||||||
|
return '$' . $formatNum($n, 2, '.', ',');
|
||||||
|
case 'CZK':
|
||||||
|
return $formatNum($n, 2, ',', "\xC2\xA0") . ' Kč';
|
||||||
|
case 'GBP':
|
||||||
|
return '£' . $formatNum($n, 2, '.', ',');
|
||||||
|
default:
|
||||||
|
return $formatNum($n, 2, ',', "\xC2\xA0") . ' ' . $currency;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$formatDate = function ($dateStr) {
|
||||||
|
if (!$dateStr) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$d = strtotime($dateStr);
|
||||||
|
if ($d === false) {
|
||||||
|
return $dateStr;
|
||||||
|
}
|
||||||
|
return date('d.m.Y', $d);
|
||||||
|
};
|
||||||
|
|
||||||
|
$esc = function ($str) {
|
||||||
|
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
|
$buildAddressLines = function ($entity, $isSupplier) use ($t) {
|
||||||
|
if (!$entity) {
|
||||||
|
return ['name' => '', 'lines' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameKey = $isSupplier ? 'company_name' : 'name';
|
||||||
|
$name = $entity[$nameKey] ?? '';
|
||||||
|
|
||||||
|
$cfData = [];
|
||||||
|
$fieldOrder = null;
|
||||||
|
$raw = $entity['custom_fields'] ?? null;
|
||||||
|
if ($raw) {
|
||||||
|
$parsed = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||||
|
if (isset($parsed['fields'])) {
|
||||||
|
$cfData = $parsed['fields'] ?? [];
|
||||||
|
$fieldOrder = $parsed['field_order'] ?? $parsed['fieldOrder'] ?? null;
|
||||||
|
} elseif (is_array($parsed) && isset($parsed[0])) {
|
||||||
|
$cfData = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zpetna kompatibilita - stare DB zaznamy maji PascalCase klice
|
||||||
|
if (is_array($fieldOrder)) {
|
||||||
|
$legacyMap = [
|
||||||
|
'Name' => 'name', 'CompanyName' => 'company_name',
|
||||||
|
'Street' => 'street', 'CityPostal' => 'city_postal',
|
||||||
|
'Country' => 'country', 'CompanyId' => 'company_id', 'VatId' => 'vat_id',
|
||||||
|
];
|
||||||
|
$fieldOrder = array_map(fn ($k) => $legacyMap[$k] ?? $k, $fieldOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldMap = [];
|
||||||
|
if ($name) {
|
||||||
|
$fieldMap[$nameKey] = $name;
|
||||||
|
}
|
||||||
|
if (!empty($entity['street'])) {
|
||||||
|
$fieldMap['street'] = $entity['street'];
|
||||||
|
}
|
||||||
|
$cityParts = array_filter([$entity['city'] ?? '', $entity['postal_code'] ?? '']);
|
||||||
|
$cityPostal = trim(implode(' ', $cityParts));
|
||||||
|
if ($cityPostal) {
|
||||||
|
$fieldMap['city_postal'] = $cityPostal;
|
||||||
|
}
|
||||||
|
if (!empty($entity['country'])) {
|
||||||
|
$fieldMap['country'] = $entity['country'];
|
||||||
|
}
|
||||||
|
if (!empty($entity['company_id'])) {
|
||||||
|
$fieldMap['company_id'] = $t('ico') . ': ' . $entity['company_id'];
|
||||||
|
}
|
||||||
|
if (!empty($entity['vat_id'])) {
|
||||||
|
$fieldMap['vat_id'] = $t('dic') . ': ' . $entity['vat_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($cfData as $i => $cf) {
|
||||||
|
$cfName = trim($cf['name'] ?? '');
|
||||||
|
$cfValue = trim($cf['value'] ?? '');
|
||||||
|
$showLabel = $cf['showLabel'] ?? true;
|
||||||
|
if ($cfValue) {
|
||||||
|
$fieldMap["custom_{$i}"] = ($showLabel && $cfName) ? "{$cfName}: {$cfValue}" : $cfValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
if (is_array($fieldOrder) && count($fieldOrder)) {
|
||||||
|
foreach ($fieldOrder as $key) {
|
||||||
|
if ($key === $nameKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($fieldMap[$key])) {
|
||||||
|
$lines[] = $fieldMap[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($fieldMap as $key => $line) {
|
||||||
|
if ($key === $nameKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!in_array($key, $fieldOrder)) {
|
||||||
|
$lines[] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($fieldMap as $key => $line) {
|
||||||
|
if ($key === $nameKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$lines[] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['name' => $name, 'lines' => $lines];
|
||||||
|
};
|
||||||
|
|
||||||
|
$logoImg = '';
|
||||||
|
if ($logoBase64) {
|
||||||
|
$logoImg = '<img src="data:' . $esc($logoMime) . ';base64,' . $logoBase64 . '" class="logo" />';
|
||||||
|
}
|
||||||
|
|
||||||
|
$subtotal = 0;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$included = $item['is_included_in_total'] ?? 1;
|
||||||
|
if ($included) {
|
||||||
|
$subtotal += (floatval($item['quantity']) ?: 0) * (floatval($item['unit_price']) ?: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$applyVat = !empty($quotation['apply_vat']);
|
||||||
|
$vatRate = floatval($quotation['vat_rate'] ?? 21) ?: 21;
|
||||||
|
$vatAmount = $applyVat ? $subtotal * ($vatRate / 100) : 0;
|
||||||
|
$totalToPay = $subtotal + $vatAmount;
|
||||||
|
$exchangeRate = floatval($quotation['exchange_rate'] ?? 0);
|
||||||
|
$vatMode = $applyVat ? 'standard' : 'exempt';
|
||||||
|
|
||||||
|
$hasScopeContent = false;
|
||||||
|
foreach ($sections as $s) {
|
||||||
|
if (trim($s['content'] ?? '') || trim($s['title'] ?? '')) {
|
||||||
|
$hasScopeContent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sectionTitle = function ($section) use ($isCzech) {
|
||||||
|
if ($isCzech && !empty(trim($section['title_cz'] ?? ''))) {
|
||||||
|
return $section['title_cz'];
|
||||||
|
}
|
||||||
|
return $section['title'] ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge adjacent <span> tags with identical attributes produced by Quill.
|
||||||
|
* Quill sometimes splits a single word across multiple <span> elements
|
||||||
|
* when cursor operations occur mid-word, creating spurious line-break
|
||||||
|
* opportunities in the PDF renderer.
|
||||||
|
*/
|
||||||
|
$cleanQuillHtml = function (string $html): string {
|
||||||
|
// Sanitizace - povolit jen bezpecne HTML tagy z Quill editoru
|
||||||
|
$allowedTags = '<p><br><strong><em><u><s><ul><ol><li>'
|
||||||
|
. '<span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>';
|
||||||
|
$html = strip_tags($html, $allowedTags);
|
||||||
|
|
||||||
|
// Odstranit event handlery (quoted i unquoted hodnoty)
|
||||||
|
$html = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
|
||||||
|
$html = preg_replace('/\s+on\w+\s*=\s*[^\s>]*/i', '', $html);
|
||||||
|
// Odstranit javascript: v href a jinych atributech
|
||||||
|
$html = preg_replace('/href\s*=\s*["\']?\s*javascript\s*:[^"\'>\s]*/i', 'href="#"', $html);
|
||||||
|
$html = preg_replace('/\s+javascript\s*:/i', '', $html);
|
||||||
|
|
||||||
|
// Replace with regular spaces in text content (not inside tags)
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'/(<[^>]*>)|( )/u',
|
||||||
|
function ($m) {
|
||||||
|
if (!empty($m[1])) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return ' ';
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge adjacent spans with the same attributes
|
||||||
|
$prev = null;
|
||||||
|
while ($prev !== $html) {
|
||||||
|
$prev = $html;
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'/<span([^>]*)>(.*?)<\/span>\s*<span(\1)>/su',
|
||||||
|
fn ($m) => '<span' . $m[1] . '>' . $m[2],
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $html;
|
||||||
|
};
|
||||||
|
|
||||||
|
$cust = $buildAddressLines($customer, false);
|
||||||
|
$supp = $buildAddressLines($settings, true);
|
||||||
|
|
||||||
|
$indentCSS = '';
|
||||||
|
for ($n = 1; $n <= 9; $n++) {
|
||||||
|
$pad = $n * 3;
|
||||||
|
$liPad = $n * 3 + 1.5;
|
||||||
|
$indentCSS .= " .ql-indent-{$n} { padding-left: {$pad}em; }\n";
|
||||||
|
$indentCSS .= " li.ql-indent-{$n} { padding-left: {$liPad}em; }\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemsHtml = '';
|
||||||
|
foreach ($items as $i => $item) {
|
||||||
|
$lineTotal = (floatval($item['quantity']) ?: 0) * (floatval($item['unit_price']) ?: 0);
|
||||||
|
$subDesc = $item['item_description'] ?? '';
|
||||||
|
$rowNum = $i + 1;
|
||||||
|
$evenClass = ($i % 2 === 1) ? ' class="even"' : '';
|
||||||
|
$itemsHtml .= '<tr' . $evenClass . '>
|
||||||
|
<td class="row-num">' . $rowNum . '</td>
|
||||||
|
<td class="desc">' . $esc($item['description'] ?? '')
|
||||||
|
. ($subDesc ? '<div class="item-subdesc">' . $esc($subDesc) . '</div>' : '') . '</td>
|
||||||
|
<td class="center">' . $formatNum(floatval($item['quantity']) ?: 1, 0)
|
||||||
|
. (!empty(trim($item['unit'] ?? '')) ? ' / ' . $esc(trim($item['unit'])) : '') . '</td>
|
||||||
|
<td class="right">' . $formatCurrency($item['unit_price'] ?? 0) . '</td>
|
||||||
|
<td class="right total-cell">' . $formatCurrency($lineTotal) . '</td>
|
||||||
|
</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalsHtml = '';
|
||||||
|
if ($vatMode === 'standard') {
|
||||||
|
$totalsHtml .= '<div class="detail-rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">' . $esc($t('subtotal')) . ':</span>
|
||||||
|
<span class="value">' . $formatCurrency($subtotal) . '</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">' . $esc($t('vat')) . ' (' . intval($vatRate) . '%):</span>
|
||||||
|
<span class="value">' . $formatCurrency($vatAmount) . '</span>
|
||||||
|
</div>
|
||||||
|
</div>';
|
||||||
|
}
|
||||||
|
$totalsHtml .= '<div class="grand">
|
||||||
|
<span class="label">' . $esc($t('total_to_pay')) . '</span>
|
||||||
|
<span class="value">' . $formatCurrency($totalToPay) . '</span>
|
||||||
|
</div>';
|
||||||
|
if ($exchangeRate > 0) {
|
||||||
|
$totalsHtml .= '<div class="exchange-rate">'
|
||||||
|
. $esc($t('exchange_rate')) . ': ' . $formatNum($exchangeRate, 4) . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeHtml = '';
|
||||||
|
if ($hasScopeContent) {
|
||||||
|
$scopeHtml .= '<div class="scope-page">';
|
||||||
|
$scopeHtml .= '<div class="page-header">
|
||||||
|
<div class="left">
|
||||||
|
<div class="page-title">' . $esc($t('scope_title')) . '</div>';
|
||||||
|
if (!empty($quotation['scope_title'])) {
|
||||||
|
$scopeHtml .= '<div class="scope-subtitle">' . $esc($quotation['scope_title']) . '</div>';
|
||||||
|
}
|
||||||
|
if (!empty($quotation['scope_description'])) {
|
||||||
|
$scopeHtml .= '<div class="scope-description">' . $esc($quotation['scope_description']) . '</div>';
|
||||||
|
}
|
||||||
|
$scopeHtml .= '</div>';
|
||||||
|
if ($logoImg) {
|
||||||
|
$scopeHtml .= '<div class="right"><div class="logo-header">' . $logoImg . '</div></div>';
|
||||||
|
}
|
||||||
|
$scopeHtml .= '</div>
|
||||||
|
<hr class="separator" />';
|
||||||
|
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
$title = $sectionTitle($section);
|
||||||
|
$content = trim($section['content'] ?? '');
|
||||||
|
if (!$title && !$content) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$scopeHtml .= '<div class="scope-section">';
|
||||||
|
if ($title) {
|
||||||
|
$scopeHtml .= '<div class="scope-section-title">' . $esc($title) . '</div>';
|
||||||
|
}
|
||||||
|
if ($content) {
|
||||||
|
$scopeHtml .= '<div class="section-content">' . $cleanQuillHtml($content) . '</div>';
|
||||||
|
}
|
||||||
|
$scopeHtml .= '</div>';
|
||||||
|
}
|
||||||
|
$scopeHtml .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$custLinesHtml = '';
|
||||||
|
foreach ($cust['lines'] as $line) {
|
||||||
|
$custLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$suppLinesHtml = '';
|
||||||
|
foreach ($supp['lines'] as $line) {
|
||||||
|
$suppLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageLabel = $t('page');
|
||||||
|
$ofLabel = $t('of');
|
||||||
|
$quotationNumber = $esc($quotation['quotation_number'] ?? '');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build final HTML
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$html = '<!DOCTYPE html>
|
||||||
|
<html lang="' . ($isCzech ? 'cs' : 'en') . '">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>' . $quotationNumber . '</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico">
|
||||||
|
<style>
|
||||||
|
/* ---- Base ---- */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm 15mm 25mm 15mm;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
width: 180mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent any element from exceeding content width */
|
||||||
|
img, table, pre, code { max-width: 100%; }
|
||||||
|
|
||||||
|
/* ---- Quill font classes ---- */
|
||||||
|
.ql-font-arial { font-family: Arial, sans-serif; }
|
||||||
|
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
|
||||||
|
.ql-font-verdana { font-family: Verdana, sans-serif; }
|
||||||
|
.ql-font-georgia { font-family: Georgia, serif; }
|
||||||
|
.ql-font-times-new-roman { font-family: "Times New Roman", serif; }
|
||||||
|
.ql-font-courier-new { font-family: "Courier New", monospace; }
|
||||||
|
.ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; }
|
||||||
|
.ql-font-impact { font-family: Impact, sans-serif; }
|
||||||
|
.ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; }
|
||||||
|
.ql-font-lucida-console { font-family: "Lucida Console", monospace; }
|
||||||
|
.ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; }
|
||||||
|
.ql-font-garamond { font-family: Garamond, serif; }
|
||||||
|
|
||||||
|
/* ---- Quill alignment ---- */
|
||||||
|
.ql-align-center { text-align: center; }
|
||||||
|
.ql-align-right { text-align: right; }
|
||||||
|
.ql-align-justify { text-align: justify; }
|
||||||
|
|
||||||
|
/* ---- Quill indentation ---- */
|
||||||
|
' . $indentCSS . '
|
||||||
|
|
||||||
|
/* ---- Page header ---- */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
}
|
||||||
|
.page-header .left { flex: 1; }
|
||||||
|
.page-header .right { flex-shrink: 0; margin-left: 10mm; }
|
||||||
|
.logo { max-width: 42mm; max-height: 22mm; object-fit: contain; }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.scope-page .page-title { font-size: 16pt; }
|
||||||
|
.quotation-number {
|
||||||
|
font-size: 12pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 1mm 0;
|
||||||
|
}
|
||||||
|
.project-code {
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #646464;
|
||||||
|
}
|
||||||
|
.valid-until {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #646464;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
.scope-subtitle {
|
||||||
|
font-size: 11pt;
|
||||||
|
color: #646464;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
.scope-description {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #646464;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5pt solid #e0e0e0;
|
||||||
|
margin: 3mm 0 5mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Addresses ---- */
|
||||||
|
.addresses {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8mm;
|
||||||
|
}
|
||||||
|
.address-block { width: 48%; }
|
||||||
|
.address-block.right { text-align: right; }
|
||||||
|
.address-label {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #646464;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.address-name {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.address-line {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #646464;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Items table ---- */
|
||||||
|
table.items {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 9pt;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
table.items thead th {
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #646464;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1pt solid #1a1a1a;
|
||||||
|
}
|
||||||
|
table.items thead th.center { text-align: center; }
|
||||||
|
table.items thead th.right { text-align: right; }
|
||||||
|
|
||||||
|
table.items tbody td {
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-bottom: 0.5pt solid #e0e0e0;
|
||||||
|
vertical-align: middle;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
|
||||||
|
table.items tbody td.center { text-align: center; white-space: nowrap; }
|
||||||
|
table.items tbody td.right { text-align: right; }
|
||||||
|
table.items tbody td.row-num {
|
||||||
|
text-align: center;
|
||||||
|
color: #969696;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
table.items tbody td.desc {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
table.items tbody td.total-cell {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.item-subdesc {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #646464;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Totals ---- */
|
||||||
|
.totals-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
break-inside: avoid;
|
||||||
|
margin-top: 8mm;
|
||||||
|
}
|
||||||
|
.totals {
|
||||||
|
width: 80mm;
|
||||||
|
}
|
||||||
|
.totals .detail-rows {
|
||||||
|
margin-bottom: 3mm;
|
||||||
|
}
|
||||||
|
.totals .row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #646464;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
.totals .row:last-child { margin-bottom: 0; }
|
||||||
|
.totals .row .value {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
.totals .grand {
|
||||||
|
border-top: 0.5pt solid #e0e0e0;
|
||||||
|
padding-top: 4mm;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.totals .grand .label {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #646464;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.totals .grand .value {
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border-bottom: 2.5pt solid #de3a3a;
|
||||||
|
padding-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.totals .exchange-rate {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: #969696;
|
||||||
|
margin-top: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scope sections ---- */
|
||||||
|
.scope-page {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
.scope-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 3mm;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
.scope-section-title {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
.section-content {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.section-content p { margin: 0 0 0.4em 0; }
|
||||||
|
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||||||
|
.section-content li { margin-bottom: 0.2em; }
|
||||||
|
|
||||||
|
/* ---- Repeating page header ---- */
|
||||||
|
table.page-layout {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.page-layout > thead > tr > td,
|
||||||
|
table.page-layout > tbody > tr > td {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.logo-header {
|
||||||
|
text-align: right;
|
||||||
|
padding-bottom: 4mm;
|
||||||
|
}
|
||||||
|
.first-content {
|
||||||
|
margin-top: -26mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Page break helpers ---- */
|
||||||
|
table.page-layout thead { display: table-header-group; }
|
||||||
|
table.items tbody tr { break-inside: avoid; }
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
@bottom-center {
|
||||||
|
content: "' . $esc($pageLabel) . ' " counter(page) " ' . $esc($ofLabel) . ' " counter(pages);
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #969696;
|
||||||
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Screen-only: A4 page preview ---- */
|
||||||
|
@media screen {
|
||||||
|
html {
|
||||||
|
background: #525659;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
width: 100vw !important;
|
||||||
|
margin: 0;
|
||||||
|
padding: 30px 0;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.quotation-page, .scope-page {
|
||||||
|
width: 210mm;
|
||||||
|
min-height: 297mm;
|
||||||
|
padding: 15mm;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
/* On screen: neutralize the table layout used for print repeating headers (quotation page only) */
|
||||||
|
table.page-layout,
|
||||||
|
table.page-layout > thead,
|
||||||
|
table.page-layout > thead > tr,
|
||||||
|
table.page-layout > thead > tr > td,
|
||||||
|
table.page-layout > tbody,
|
||||||
|
table.page-layout > tbody > tr,
|
||||||
|
table.page-layout > tbody > tr > td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* On screen: undo print-specific hacks */
|
||||||
|
.first-content {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
/* On screen: show logo-header normally as right-aligned block */
|
||||||
|
.logo-header {
|
||||||
|
text-align: right;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin-bottom: -18mm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
|
||||||
|
<div class="quotation-page">
|
||||||
|
<table class="page-layout">
|
||||||
|
<thead>
|
||||||
|
<tr><td>
|
||||||
|
<div class="logo-header">' . $logoImg . '</div>
|
||||||
|
</td></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>
|
||||||
|
<div class="first-content">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="left">
|
||||||
|
<div class="page-title">' . $esc($t('title')) . '</div>
|
||||||
|
<div class="quotation-number">' . $quotationNumber . '</div>
|
||||||
|
' . (!empty($quotation['project_code'])
|
||||||
|
? '<div class="project-code">' . $esc($quotation['project_code']) . '</div>'
|
||||||
|
: '') . '
|
||||||
|
<div class="valid-until">' . $esc($t('valid_until')) . ': '
|
||||||
|
. $esc($formatDate($quotation['valid_until'] ?? '')) . '</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="separator" />
|
||||||
|
|
||||||
|
<div class="addresses">
|
||||||
|
<div class="address-block left">
|
||||||
|
<div class="address-label">' . $esc($t('customer')) . '</div>
|
||||||
|
<div class="address-name">' . $esc($cust['name']) . '</div>
|
||||||
|
' . $custLinesHtml . '
|
||||||
|
</div>
|
||||||
|
<div class="address-block right">
|
||||||
|
<div class="address-label">' . $esc($t('supplier')) . '</div>
|
||||||
|
<div class="address-name">' . $esc($supp['name']) . '</div>
|
||||||
|
' . $suppLinesHtml . '
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="center" style="width:5%">' . $esc($t('no')) . '</th>
|
||||||
|
<th style="width:44%">' . $esc($t('description')) . '</th>
|
||||||
|
<th class="center" style="width:13%">' . $esc($t('qty')) . '</th>
|
||||||
|
<th class="right" style="width:18%">' . $esc($t('unit_price')) . '</th>
|
||||||
|
<th class="right" style="width:20%">' . $esc($t('total')) . '</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
' . $itemsHtml . '
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="totals-wrapper">
|
||||||
|
<div class="totals">
|
||||||
|
' . $totalsHtml . '
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
' . $scopeHtml . '
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>';
|
||||||
|
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
echo $html;
|
||||||
|
exit();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Offers PDF API error: ' . $e->getMessage());
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba generování PDF', 500);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('Offers PDF generation error: ' . $e->getMessage());
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba PDF: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba generování PDF', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
360
api/admin/offers-templates.php
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Offers Templates API
|
||||||
|
*
|
||||||
|
* GET ?action=items - List item templates
|
||||||
|
* GET ?action=scopes - List scope templates
|
||||||
|
* GET ?action=scope_detail&id=X - Get scope template with sections
|
||||||
|
* POST ?action=item - Create/update item template
|
||||||
|
* POST ?action=scope - Create/update scope template
|
||||||
|
* DELETE ?action=item&id=X - Delete item template
|
||||||
|
* DELETE ?action=scope&id=X - Delete scope template
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'offers.view');
|
||||||
|
switch ($action) {
|
||||||
|
case 'items':
|
||||||
|
handleGetItemTemplates($pdo);
|
||||||
|
break;
|
||||||
|
case 'scopes':
|
||||||
|
handleGetScopeTemplates($pdo);
|
||||||
|
break;
|
||||||
|
case 'scope_detail':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID šablony je povinné');
|
||||||
|
}
|
||||||
|
handleGetScopeDetail($pdo, $id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorResponse('Neplatná akce');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
switch ($action) {
|
||||||
|
case 'item':
|
||||||
|
handleSaveItemTemplate($pdo);
|
||||||
|
break;
|
||||||
|
case 'scope':
|
||||||
|
handleSaveScopeTemplate($pdo);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorResponse('Neplatná akce');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'offers.settings');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID šablony je povinné');
|
||||||
|
}
|
||||||
|
switch ($action) {
|
||||||
|
case 'item':
|
||||||
|
handleDeleteItemTemplate($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'scope':
|
||||||
|
handleDeleteScopeTemplate($pdo, $id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorResponse('Neplatná akce');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Offers Templates API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
679
api/admin/offers.php
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Quotations CRUD API
|
||||||
|
*
|
||||||
|
* GET /api/admin/offers.php - List quotations
|
||||||
|
* GET /api/admin/offers.php?action=detail&id=X - Get quotation detail
|
||||||
|
* GET /api/admin/offers.php?action=next_number - Get next quotation number
|
||||||
|
* POST /api/admin/offers.php - Create quotation
|
||||||
|
* POST /api/admin/offers.php?action=duplicate&id=X - Duplicate quotation
|
||||||
|
* POST /api/admin/offers.php?action=invalidate&id=X - Invalidate quotation
|
||||||
|
* PUT /api/admin/offers.php?id=X - Update quotation
|
||||||
|
* DELETE /api/admin/offers.php?id=X - Delete quotation
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'offers.view');
|
||||||
|
switch ($action) {
|
||||||
|
case 'detail':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID nabídky je povinné');
|
||||||
|
}
|
||||||
|
handleGetDetail($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'next_number':
|
||||||
|
handleGetNextNumber($pdo);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handleGetList($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if ($action === 'invalidate' && $id) {
|
||||||
|
requirePermission($authData, 'offers.edit');
|
||||||
|
handleInvalidateOffer($pdo, $id);
|
||||||
|
} elseif ($action === 'duplicate' && $id) {
|
||||||
|
requirePermission($authData, 'offers.create');
|
||||||
|
handleDuplicate($pdo, $id);
|
||||||
|
} else {
|
||||||
|
requirePermission($authData, 'offers.create');
|
||||||
|
handleCreateOffer($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'offers.edit');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID nabídky je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateOffer($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'offers.delete');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID nabídky je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteQuotation($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Offers API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
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<string, mixed> $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<array<string, mixed>> $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<array<string, mixed>> $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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
615
api/admin/orders.php
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Orders CRUD API
|
||||||
|
*
|
||||||
|
* GET /api/admin/orders.php - List orders
|
||||||
|
* GET /api/admin/orders.php?action=detail&id=X - Get order detail
|
||||||
|
* POST /api/admin/orders.php - Create order from quotation
|
||||||
|
* PUT /api/admin/orders.php?id=X - Update order status/notes
|
||||||
|
* DELETE /api/admin/orders.php?id=X - Delete order + project
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'orders.view');
|
||||||
|
switch ($action) {
|
||||||
|
case 'detail':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID objednávky je povinné');
|
||||||
|
}
|
||||||
|
handleGetDetail($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'attachment':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID objednávky je povinné');
|
||||||
|
}
|
||||||
|
handleGetAttachment($pdo, $id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handleGetList($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
requirePermission($authData, 'orders.create');
|
||||||
|
handleCreateOrder($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'orders.edit');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID objednávky je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateOrder($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'orders.delete');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID objednávky je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteOrder($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Orders API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Valid status transitions ---
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
api/admin/profile.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Profile API
|
||||||
|
*
|
||||||
|
* Allows any authenticated user to update their own profile
|
||||||
|
*
|
||||||
|
* PUT /api/admin/profile.php - Update own profile
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($method !== 'PUT') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
|
||||||
|
// 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'];
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = ?, password_changed_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $userId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?, email = ?, first_name = ?, last_name = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$username, $email, $firstName, $lastName, $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
AuditLog::logUpdate('user', $userId, [
|
||||||
|
'username' => $existingUser['username'],
|
||||||
|
'email' => $existingUser['email'],
|
||||||
|
'first_name' => $existingUser['first_name'],
|
||||||
|
'last_name' => $existingUser['last_name'],
|
||||||
|
], [
|
||||||
|
'username' => $username,
|
||||||
|
'email' => $email,
|
||||||
|
'first_name' => $firstName,
|
||||||
|
'last_name' => $lastName,
|
||||||
|
], 'Uživatel aktualizoval svůj profil');
|
||||||
|
|
||||||
|
successResponse(null, 'Profil byl úspěšně aktualizován');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Profile API error: ' . $e->getMessage());
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
533
api/admin/projects.php
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Projects API
|
||||||
|
*
|
||||||
|
* GET /api/admin/projects.php - List projects
|
||||||
|
* GET /api/admin/projects.php?action=detail&id=X - Get project detail
|
||||||
|
* GET /api/admin/projects.php?action=notes&id=X - Get project notes
|
||||||
|
* GET /api/admin/projects.php?action=next_number - Get next available project number
|
||||||
|
* POST /api/admin/projects.php - Create new project (manual)
|
||||||
|
* POST /api/admin/projects.php?action=add_note&id=X - Add note to project
|
||||||
|
* PUT /api/admin/projects.php?id=X - Update project
|
||||||
|
* DELETE /api/admin/projects.php?action=delete_note¬eId=X - Delete note (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'projects.view');
|
||||||
|
switch ($action) {
|
||||||
|
case 'detail':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID projektu je povinné');
|
||||||
|
}
|
||||||
|
handleGetDetail($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'notes':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID projektu je povinné');
|
||||||
|
}
|
||||||
|
handleGetNotes($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'next_number':
|
||||||
|
requirePermission($authData, 'projects.create');
|
||||||
|
handleGetNextNumber($pdo);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handleGetList($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if ($action === 'add_note') {
|
||||||
|
requirePermission($authData, 'projects.view');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID projektu je povinné');
|
||||||
|
}
|
||||||
|
handleAddNote($pdo, $id, $authData);
|
||||||
|
} elseif (!$action) {
|
||||||
|
requirePermission($authData, 'projects.create');
|
||||||
|
handleCreateProject($pdo);
|
||||||
|
} else {
|
||||||
|
errorResponse('Neznámá akce', 400);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'projects.edit');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID projektu je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateProject($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if ($action === 'delete_note') {
|
||||||
|
requirePermission($authData, 'projects.edit');
|
||||||
|
$noteId = isset($_GET['noteId']) ? (int) $_GET['noteId'] : null;
|
||||||
|
if (!$noteId) {
|
||||||
|
errorResponse('ID poznámky je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteNote($pdo, $noteId, $authData);
|
||||||
|
} elseif (!$action && $id) {
|
||||||
|
requirePermission($authData, 'projects.delete');
|
||||||
|
handleDeleteProject($pdo, $id);
|
||||||
|
} else {
|
||||||
|
errorResponse('Neznámá akce', 400);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Projects API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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<string, mixed> $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<string, mixed> $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');
|
||||||
|
}
|
||||||
597
api/admin/received-invoices.php
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Received Invoices API - přijaté faktury (upload, CRUD, stats)
|
||||||
|
*
|
||||||
|
* GET ?action=list&month=X&year=Y - Seznam přijatých faktur
|
||||||
|
* GET ?action=stats&month=X&year=Y - KPI statistiky
|
||||||
|
* GET ?action=detail&id=X - Detail záznamu (bez BLOB)
|
||||||
|
* GET ?action=file&id=X - Stažení/zobrazení souboru
|
||||||
|
* POST (FormData) - Bulk upload: files[] + invoices JSON
|
||||||
|
* PUT ?id=X - Update metadat / změna stavu
|
||||||
|
* DELETE ?id=X - Smazání záznamu
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/CnbRates.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'invoices.view');
|
||||||
|
switch ($action) {
|
||||||
|
case 'stats':
|
||||||
|
handleGetStats($pdo);
|
||||||
|
break;
|
||||||
|
case 'detail':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID je povinné');
|
||||||
|
}
|
||||||
|
handleGetDetail($pdo, $id);
|
||||||
|
break;
|
||||||
|
case 'file':
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID je povinné');
|
||||||
|
}
|
||||||
|
handleGetFile($pdo, $id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handleGetList($pdo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
requirePermission($authData, 'invoices.create');
|
||||||
|
handleBulkUpload($pdo, $authData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'invoices.edit');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateReceivedInvoice($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'invoices.delete');
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteReceivedInvoice($pdo, $id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Received Invoices API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Allowed MIME types ---
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
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<string, mixed> $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');
|
||||||
|
}
|
||||||
59
api/admin/refresh.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Token Refresh Endpoint
|
||||||
|
*
|
||||||
|
* Uses the httpOnly refresh_token cookie to issue a new access token.
|
||||||
|
* Called silently on page load and when access token expires.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateLimiter = new RateLimiter();
|
||||||
|
$rateLimiter->enforce('refresh', 30);
|
||||||
|
|
||||||
|
// Check for refresh token in cookie
|
||||||
|
if (!isset($_COOKIE['refresh_token'])) {
|
||||||
|
errorResponse('No refresh token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to refresh tokens
|
||||||
|
$result = JWTAuth::refreshTokens();
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
errorResponse('Invalid or expired refresh token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 2FA info to user data
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$stmt = $pdo->prepare('SELECT totp_enabled FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$result['user']['id']]);
|
||||||
|
$u = $stmt->fetch();
|
||||||
|
$result['user']['totp_enabled'] = (bool) ($u['totp_enabled'] ?? false);
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||||
|
$result['user']['require_2fa'] = (bool) $stmt->fetchColumn();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$result['user']['totp_enabled'] = false;
|
||||||
|
$result['user']['require_2fa'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'access_token' => $result['access_token'],
|
||||||
|
'expires_in' => $result['expires_in'],
|
||||||
|
'user' => $result['user'],
|
||||||
|
], 'Token refreshed');
|
||||||
300
api/admin/roles.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Roles API
|
||||||
|
*
|
||||||
|
* GET /api/admin/roles.php - List all roles with permissions
|
||||||
|
* POST /api/admin/roles.php - Create new role
|
||||||
|
* PUT /api/admin/roles.php?id=X - Update role
|
||||||
|
* DELETE /api/admin/roles.php?id=X - Delete role
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
// Require settings.roles permission
|
||||||
|
requirePermission($authData, 'settings.roles');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$roleId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
handleGetRole($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
handleCreateRole($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if (!$roleId) {
|
||||||
|
errorResponse('ID role je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateRole($pdo, $roleId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if (!$roleId) {
|
||||||
|
errorResponse('ID role je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteRole($pdo, $roleId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
111
api/admin/session.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Session Check API (JWT)
|
||||||
|
*
|
||||||
|
* GET /api/admin/session.php
|
||||||
|
*
|
||||||
|
* Checks if the user has a valid session by:
|
||||||
|
* 1. First checking the Authorization header for a valid access token
|
||||||
|
* 2. If no valid access token, tries to refresh using the refresh_token cookie
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "data": {
|
||||||
|
* "authenticated": boolean,
|
||||||
|
* "user": { ... } | null,
|
||||||
|
* "access_token": "string" | null,
|
||||||
|
* "expires_in": int | null
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// 200 req/min - vola se pri kazde zmene route
|
||||||
|
$rateLimiter = new RateLimiter();
|
||||||
|
$rateLimiter->enforce('session', 200);
|
||||||
|
|
||||||
|
// Cleanup expired refresh tokenu (0.1% sance)
|
||||||
|
if (rand(1, 1000) === 1) {
|
||||||
|
try {
|
||||||
|
JWTAuth::cleanupExpiredTokens();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'])) {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
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) {
|
||||||
|
$userData = $authData['user'];
|
||||||
|
$userData['permissions'] = JWTAuth::getUserPermissions($authData['user_id']);
|
||||||
|
|
||||||
|
$twoFA = get2FAInfo(db(), $authData['user_id']);
|
||||||
|
$userData['totp_enabled'] = $twoFA['totp_enabled'];
|
||||||
|
$userData['require_2fa'] = $twoFA['require_2fa'];
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'authenticated' => true,
|
||||||
|
'user' => $userData,
|
||||||
|
'access_token' => null,
|
||||||
|
'expires_in' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||||
|
|
||||||
|
if ($refreshToken) {
|
||||||
|
$result = JWTAuth::refreshTokens();
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$twoFA = get2FAInfo(db(), $result['user']['id']);
|
||||||
|
$result['user']['totp_enabled'] = $twoFA['totp_enabled'];
|
||||||
|
$result['user']['require_2fa'] = $twoFA['require_2fa'];
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'authenticated' => true,
|
||||||
|
'user' => $result['user'],
|
||||||
|
'access_token' => $result['access_token'],
|
||||||
|
'expires_in' => $result['expires_in'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'authenticated' => false,
|
||||||
|
'user' => null,
|
||||||
|
'access_token' => null,
|
||||||
|
'expires_in' => null,
|
||||||
|
]);
|
||||||
243
api/admin/sessions.php
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Sessions API
|
||||||
|
*
|
||||||
|
* Allows users to view and manage their active sessions (logged-in devices)
|
||||||
|
*
|
||||||
|
* GET /api/admin/sessions.php - List all active sessions for current user
|
||||||
|
* DELETE /api/admin/sessions.php?id=X - Delete a specific session
|
||||||
|
* DELETE /api/admin/sessions.php?action=all - Delete all sessions except current
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$sessionId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
$action = $_GET['action'] ?? null;
|
||||||
|
$currentUserId = $authData['user_id'];
|
||||||
|
|
||||||
|
// Get current refresh token hash for identifying current session
|
||||||
|
$currentTokenHash = null;
|
||||||
|
if (isset($_COOKIE['refresh_token'])) {
|
||||||
|
$currentTokenHash = hash('sha256', $_COOKIE['refresh_token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
handleGetSession($pdo, $currentUserId, $currentTokenHash);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if ($action === 'all') {
|
||||||
|
handleDeleteAllSessions($pdo, $currentUserId, $currentTokenHash);
|
||||||
|
} elseif ($sessionId) {
|
||||||
|
handleDeleteSession($pdo, $sessionId, $currentUserId, $currentTokenHash);
|
||||||
|
} else {
|
||||||
|
errorResponse('ID relace nebo akce je povinná');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Sessions API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
488
api/admin/totp.php
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - TOTP 2FA API
|
||||||
|
*
|
||||||
|
* GET ?action=status - 2FA status
|
||||||
|
* POST ?action=setup - generovat secret + QR
|
||||||
|
* POST ?action=enable - overit kod a aktivovat 2FA
|
||||||
|
* POST ?action=disable - deaktivovat 2FA
|
||||||
|
* POST ?action=verify - overit TOTP kod pri loginu (pre-auth)
|
||||||
|
* POST ?action=backup_verify - overit zalozhni kod pri loginu (pre-auth)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/Encryption.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use RobThree\Auth\TwoFactorAuth;
|
||||||
|
use RobThree\Auth\TwoFactorAuthException;
|
||||||
|
use RobThree\Auth\Providers\Qr\QRServerProvider;
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
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();
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'status':
|
||||||
|
handleStatus($pdo);
|
||||||
|
break;
|
||||||
|
case 'setup':
|
||||||
|
handleSetup($pdo, getTfa());
|
||||||
|
break;
|
||||||
|
case 'enable':
|
||||||
|
handleEnable($pdo, getTfa());
|
||||||
|
break;
|
||||||
|
case 'disable':
|
||||||
|
handleDisable($pdo, getTfa());
|
||||||
|
break;
|
||||||
|
case 'verify':
|
||||||
|
handleVerify($pdo, getTfa());
|
||||||
|
break;
|
||||||
|
case 'backup_verify':
|
||||||
|
handleBackupVerify($pdo);
|
||||||
|
break;
|
||||||
|
case 'get_required':
|
||||||
|
handleGetRequired($pdo);
|
||||||
|
break;
|
||||||
|
case 'set_required':
|
||||||
|
handleSetRequired($pdo);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorResponse('Neplatná akce', 400);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('TOTP API error: ' . $e->getMessage());
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<string, mixed>|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<string, mixed> $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é');
|
||||||
|
}
|
||||||
788
api/admin/trips.php
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trips API - Kniha jízd
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* GET /api/admin/trips.php - Get current user's trips for month
|
||||||
|
* GET /api/admin/trips.php?action=history - Get trip history with filters
|
||||||
|
* GET /api/admin/trips.php?action=admin - Get all trips (admin)
|
||||||
|
* GET /api/admin/trips.php?action=print - Get print data for trips (admin)
|
||||||
|
* GET /api/admin/trips.php?action=vehicles - Get all vehicles (admin)
|
||||||
|
* GET /api/admin/trips.php?action=active_vehicles - Get active vehicles
|
||||||
|
* GET /api/admin/trips.php?action=last_km&vehicle_id=X - Get last km for vehicle
|
||||||
|
* POST /api/admin/trips.php - Create trip
|
||||||
|
* POST /api/admin/trips.php?action=vehicle - Create/update vehicle (admin)
|
||||||
|
* PUT /api/admin/trips.php?id=X - Update trip
|
||||||
|
* DELETE /api/admin/trips.php?id=X - Delete trip
|
||||||
|
* DELETE /api/admin/trips.php?action=vehicle&id=X - Delete vehicle (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Handle preflight
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
$isAdmin = $authData['user']['is_admin'] ?? false;
|
||||||
|
$pdo = db();
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
// Route request
|
||||||
|
try {
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
$action = $_GET['action'] ?? 'current';
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'history':
|
||||||
|
requirePermission($authData, 'trips.history');
|
||||||
|
handleGetHistory($pdo, $userId);
|
||||||
|
break;
|
||||||
|
case 'admin':
|
||||||
|
requirePermission($authData, 'trips.admin');
|
||||||
|
handleGetAdmin($pdo);
|
||||||
|
break;
|
||||||
|
case 'print':
|
||||||
|
requirePermission($authData, 'trips.admin');
|
||||||
|
handleGetPrint($pdo);
|
||||||
|
break;
|
||||||
|
case 'vehicles':
|
||||||
|
requirePermission($authData, 'trips.vehicles');
|
||||||
|
handleGetVehicles($pdo);
|
||||||
|
break;
|
||||||
|
case 'active_vehicles':
|
||||||
|
requirePermission($authData, 'trips.record');
|
||||||
|
handleGetActiveVehicles($pdo);
|
||||||
|
break;
|
||||||
|
case 'last_km':
|
||||||
|
requirePermission($authData, 'trips.record');
|
||||||
|
handleGetLastKm($pdo);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
requirePermission($authData, 'trips.record');
|
||||||
|
handleGetCurrent($pdo, $userId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'vehicle') {
|
||||||
|
requirePermission($authData, 'trips.vehicles');
|
||||||
|
handleVehicle($pdo);
|
||||||
|
} else {
|
||||||
|
requirePermission($authData, 'trips.record');
|
||||||
|
handleCreateTrip($pdo, $userId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateTrip($pdo, $id, $userId, $authData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'vehicle') {
|
||||||
|
requirePermission($authData, 'trips.vehicles');
|
||||||
|
handleDeleteVehicle($pdo, $id);
|
||||||
|
} else {
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteTrip($pdo, $id, $userId, $authData);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Nepodporovaná metoda', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Trips API Error: ' . $e->getMessage());
|
||||||
|
errorResponse('Chyba databáze', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<string, mixed> $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<string, mixed> $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),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
342
api/admin/users.php
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Users API
|
||||||
|
*
|
||||||
|
* GET /api/admin/users.php - List all users
|
||||||
|
* POST /api/admin/users.php - Create new user
|
||||||
|
* PUT /api/admin/users.php?id=X - Update user
|
||||||
|
* DELETE /api/admin/users.php?id=X - Delete user
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$userId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||||
|
$currentUserId = $authData['user_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
requirePermission($authData, 'users.view');
|
||||||
|
handleGetUser($pdo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
requirePermission($authData, 'users.create');
|
||||||
|
handleCreateUser($pdo, $authData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
requirePermission($authData, 'users.edit');
|
||||||
|
if (!$userId) {
|
||||||
|
errorResponse('ID uživatele je povinné');
|
||||||
|
}
|
||||||
|
handleUpdateUser($pdo, $userId, $currentUserId, $authData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
requirePermission($authData, 'users.delete');
|
||||||
|
if (!$userId) {
|
||||||
|
errorResponse('ID uživatele je povinné');
|
||||||
|
}
|
||||||
|
handleDeleteUser($pdo, $userId, $currentUserId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Users API error: ' . $e->getMessage());
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||||
|
} else {
|
||||||
|
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<string, mixed> $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<string, mixed> $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');
|
||||||
|
}
|
||||||
405
api/config.php
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - API Configuration
|
||||||
|
*
|
||||||
|
* Database and application configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Load .env file
|
||||||
|
function loadEnv(string $path): bool
|
||||||
|
{
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (strpos(trim($line), '#') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('=', $line, 2);
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim($parts[0]);
|
||||||
|
$value = trim($parts[1]);
|
||||||
|
|
||||||
|
// Remove quotes if present
|
||||||
|
if (
|
||||||
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env from api directory
|
||||||
|
loadEnv(__DIR__ . '/.env');
|
||||||
|
|
||||||
|
// Helper function to get env value with default
|
||||||
|
function env(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $_ENV[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
define('APP_ENV', env('APP_ENV', 'production'));
|
||||||
|
define('DEBUG_MODE', APP_ENV === 'local');
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
} else {
|
||||||
|
error_reporting(0);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
define('DB_HOST', env('DB_HOST', 'localhost'));
|
||||||
|
define('DB_NAME', env('DB_NAME', ''));
|
||||||
|
define('DB_USER', env('DB_USER', ''));
|
||||||
|
define('DB_PASS', env('DB_PASS', ''));
|
||||||
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
|
// Security configuration
|
||||||
|
define('MAX_LOGIN_ATTEMPTS', 5);
|
||||||
|
define('LOCKOUT_MINUTES', 15);
|
||||||
|
define('BCRYPT_COST', 12);
|
||||||
|
|
||||||
|
// CORS - aktualizuj po nasazeni na subdomenu
|
||||||
|
define('CORS_ALLOWED_ORIGINS', [
|
||||||
|
'http://www.boha-automation.cz',
|
||||||
|
'https://www.boha-automation.cz',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
define('API_ROOT', __DIR__);
|
||||||
|
define('INCLUDES_PATH', API_ROOT . '/includes');
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
define('RATE_LIMIT_STORAGE_PATH', __DIR__ . '/rate_limits');
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get PDO database connection (singleton pattern)
|
||||||
|
*
|
||||||
|
* @return PDO Database connection instance
|
||||||
|
* @throws PDOException If connection fails
|
||||||
|
*/
|
||||||
|
function db(): PDO
|
||||||
|
{
|
||||||
|
static $pdo = null;
|
||||||
|
|
||||||
|
if ($pdo === null) {
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;dbname=%s;charset=%s',
|
||||||
|
DB_HOST,
|
||||||
|
DB_NAME,
|
||||||
|
DB_CHARSET
|
||||||
|
);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => 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<string, mixed> 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<string>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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);
|
||||||
|
}
|
||||||
968
api/includes/AttendanceAdmin.php
Normal file
@@ -0,0 +1,968 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attendance admin handler functions
|
||||||
|
* Requires: AttendanceHelpers.php, CzechHolidays.php, AuditLog.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function handleGetAdmin(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$month = validateMonth();
|
||||||
|
$filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
||||||
|
|
||||||
|
$year = (int)substr($month, 0, 4);
|
||||||
|
$startDate = "{$month}-01";
|
||||||
|
$endDate = date('Y-m-t', strtotime($startDate));
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.shift_date BETWEEN ? AND ?
|
||||||
|
";
|
||||||
|
$params = [$startDate, $endDate];
|
||||||
|
|
||||||
|
if ($filterUserId) {
|
||||||
|
$sql .= ' AND a.user_id = ?';
|
||||||
|
$params[] = $filterUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY a.shift_date DESC, a.arrival_time DESC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$records = $stmt->fetchAll();
|
||||||
|
|
||||||
|
enrichRecordsWithProjectLogs($pdo, $records);
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$userTotals = calculateUserTotals($records);
|
||||||
|
$leaveBalances = getLeaveBalancesBatch(
|
||||||
|
$pdo,
|
||||||
|
array_keys($userTotals),
|
||||||
|
$year
|
||||||
|
);
|
||||||
|
|
||||||
|
$monthNum = (int)substr($month, 5, 2);
|
||||||
|
addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'records' => $records,
|
||||||
|
'users' => $users,
|
||||||
|
'month' => $month,
|
||||||
|
'user_totals' => $userTotals,
|
||||||
|
'leave_balances' => $leaveBalances,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetBalances(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$year = (int)($_GET['year'] ?? date('Y'));
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$userIds = array_column($users, 'id');
|
||||||
|
$batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year);
|
||||||
|
|
||||||
|
$balances = [];
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$balances[$user['id']] = array_merge(
|
||||||
|
['name' => $user['name']],
|
||||||
|
$batchBalances[$user['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'users' => $users,
|
||||||
|
'balances' => $balances,
|
||||||
|
'year' => $year,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetWorkFund(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$year = (int)($_GET['year'] ?? date('Y'));
|
||||||
|
$currentYear = (int)date('Y');
|
||||||
|
$currentMonth = (int)date('m');
|
||||||
|
|
||||||
|
$maxMonth = ($year < $currentYear) ? 12 : (($year === $currentYear) ? $currentMonth : 0);
|
||||||
|
|
||||||
|
if ($maxMonth === 0) {
|
||||||
|
successResponse(['months' => [], 'holidays' => [], 'year' => $year]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$startDate = sprintf('%04d-01-01', $year);
|
||||||
|
$endDate = sprintf('%04d-%02d-%02d', $year, $maxMonth, cal_days_in_month(CAL_GREGORIAN, $maxMonth, $year));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE shift_date BETWEEN ? AND ? ORDER BY shift_date');
|
||||||
|
$stmt->execute([$startDate, $endDate]);
|
||||||
|
$allRecords = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$monthUserData = [];
|
||||||
|
foreach ($allRecords as $rec) {
|
||||||
|
$m = (int)date('m', strtotime($rec['shift_date']));
|
||||||
|
$uid = $rec['user_id'];
|
||||||
|
if (!isset($monthUserData[$m][$uid])) {
|
||||||
|
$monthUserData[$m][$uid] = ['minutes' => 0, 'vacation' => 0, 'sick' => 0, 'holiday' => 0, 'unpaid' => 0];
|
||||||
|
}
|
||||||
|
$lt = $rec['leave_type'] ?? 'work';
|
||||||
|
$lh = (float)($rec['leave_hours'] ?? 0);
|
||||||
|
if ($lt === 'work') {
|
||||||
|
if ($rec['departure_time']) {
|
||||||
|
$monthUserData[$m][$uid]['minutes'] += calculateWorkMinutes($rec);
|
||||||
|
}
|
||||||
|
} elseif ($lt === 'vacation') {
|
||||||
|
$monthUserData[$m][$uid]['vacation'] += $lh;
|
||||||
|
} elseif ($lt === 'sick') {
|
||||||
|
$monthUserData[$m][$uid]['sick'] += $lh;
|
||||||
|
} elseif ($lt === 'holiday') {
|
||||||
|
$monthUserData[$m][$uid]['holiday'] += $lh;
|
||||||
|
} elseif ($lt === 'unpaid') {
|
||||||
|
$monthUserData[$m][$uid]['unpaid'] += $lh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = [];
|
||||||
|
for ($m = 1; $m <= $maxMonth; $m++) {
|
||||||
|
$fund = CzechHolidays::getMonthlyWorkFund($year, $m);
|
||||||
|
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $m);
|
||||||
|
$monthName = getCzechMonthName($m);
|
||||||
|
|
||||||
|
$userStats = [];
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$uid = $user['id'];
|
||||||
|
$ud = $monthUserData[$m][$uid] ?? [
|
||||||
|
'minutes' => 0, 'vacation' => 0, 'sick' => 0,
|
||||||
|
'holiday' => 0, 'unpaid' => 0,
|
||||||
|
];
|
||||||
|
$worked = round($ud['minutes'] / 60, 1);
|
||||||
|
$leave = $ud['vacation'] + $ud['sick'];
|
||||||
|
$covered = $worked + $leave;
|
||||||
|
$missing = max(0, round($fund - $covered, 1));
|
||||||
|
$overtime = max(0, round($covered - $fund, 1));
|
||||||
|
|
||||||
|
$userStats[$uid] = [
|
||||||
|
'name' => $user['name'],
|
||||||
|
'worked' => $worked,
|
||||||
|
'vacation' => $ud['vacation'],
|
||||||
|
'sick' => $ud['sick'],
|
||||||
|
'holiday' => $ud['holiday'],
|
||||||
|
'unpaid' => $ud['unpaid'],
|
||||||
|
'leave' => $leave,
|
||||||
|
'covered' => $covered,
|
||||||
|
'missing' => $missing,
|
||||||
|
'overtime' => $overtime,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$months[$m] = [
|
||||||
|
'month' => $m,
|
||||||
|
'month_name' => $monthName,
|
||||||
|
'fund' => $fund,
|
||||||
|
'business_days' => $businessDays,
|
||||||
|
'users' => $userStats,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userIds = array_column($users, 'id');
|
||||||
|
$batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year);
|
||||||
|
$balances = [];
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$balances[$user['id']] = array_merge(
|
||||||
|
['name' => $user['name']],
|
||||||
|
$batchBalances[$user['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$holidays = CzechHolidays::getHolidays($year);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'months' => $months,
|
||||||
|
'balances' => $balances,
|
||||||
|
'holidays' => $holidays,
|
||||||
|
'users' => $users,
|
||||||
|
'year' => $year,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetLocation(PDO $pdo, int $recordId): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$recordId]);
|
||||||
|
$record = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
errorResponse('Záznam nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(['record' => $record]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetUsers(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$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();
|
||||||
|
successResponse(['users' => $users]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateAttendance(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$userId = (int)($input['user_id'] ?? 0);
|
||||||
|
$shiftDate = $input['shift_date'] ?? '';
|
||||||
|
$leaveType = $input['leave_type'] ?? 'work';
|
||||||
|
$leaveHours = $input['leave_hours'] ?? null;
|
||||||
|
$notes = $input['notes'] ?? null;
|
||||||
|
|
||||||
|
if (!$userId || !$shiftDate) {
|
||||||
|
errorResponse('Vyplňte zaměstnance a datum směny');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($leaveType !== 'work') {
|
||||||
|
$leaveHours = $leaveHours ?: 8;
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $shiftDate, $leaveType, $leaveHours, $notes]);
|
||||||
|
|
||||||
|
updateLeaveBalance($pdo, $userId, $shiftDate, $leaveType, (float)$leaveHours);
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$arrivalDate = $input['arrival_date'] ?? $shiftDate;
|
||||||
|
$arrivalTime = $input['arrival_time'] ?? null;
|
||||||
|
$breakStartDate = $input['break_start_date'] ?? null;
|
||||||
|
$breakStartTime = $input['break_start_time'] ?? null;
|
||||||
|
$breakEndDate = $input['break_end_date'] ?? null;
|
||||||
|
$breakEndTime = $input['break_end_time'] ?? null;
|
||||||
|
$departureDate = $input['departure_date'] ?? null;
|
||||||
|
$departureTime = $input['departure_time'] ?? null;
|
||||||
|
/** @var mixed $rawProjectId */
|
||||||
|
$rawProjectId = $input['project_id'] ?? null;
|
||||||
|
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||||
|
? (int)$rawProjectId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null;
|
||||||
|
$breakStart = ($breakStartDate && $breakStartTime) ? "{$breakStartDate} {$breakStartTime}:00" : null;
|
||||||
|
$breakEnd = ($breakEndDate && $breakEndTime) ? "{$breakEndDate} {$breakEndTime}:00" : null;
|
||||||
|
$departure = ($departureDate && $departureTime) ? "{$departureDate} {$departureTime}:00" : null;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO attendance
|
||||||
|
(user_id, shift_date, arrival_time, break_start,
|
||||||
|
break_end, departure_time, leave_type, notes, project_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'work', ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId, $shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
$projectLogs = $input['project_logs'] ?? [];
|
||||||
|
if (!empty($projectLogs) && $leaveType === 'work') {
|
||||||
|
$logStmt = $pdo->prepare(
|
||||||
|
'INSERT INTO attendance_project_logs
|
||||||
|
(attendance_id, project_id, hours, minutes)
|
||||||
|
VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($projectLogs as $log) {
|
||||||
|
$pid = (int)($log['project_id'] ?? 0);
|
||||||
|
if (!$pid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$h = (int)($log['hours'] ?? 0);
|
||||||
|
$m = (int)($log['minutes'] ?? 0);
|
||||||
|
if ($h === 0 && $m === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$logStmt->execute([$newId, $pid, $h, $m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::logCreate('attendance', $newId, $input, 'Admin vytvořil záznam docházky');
|
||||||
|
|
||||||
|
successResponse(['id' => $newId], 'Záznam byl vytvořen');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBulkAttendance(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$monthStr = $input['month'] ?? '';
|
||||||
|
$userIds = $input['user_ids'] ?? [];
|
||||||
|
$arrivalTime = trim($input['arrival_time'] ?? '08:00');
|
||||||
|
$departureTime = trim($input['departure_time'] ?? '16:30');
|
||||||
|
$breakStartTime = trim($input['break_start_time'] ?? '12:00');
|
||||||
|
$breakEndTime = trim($input['break_end_time'] ?? '12:30');
|
||||||
|
|
||||||
|
if (!$monthStr || !preg_match('/^\d{4}-\d{2}$/', $monthStr)) {
|
||||||
|
errorResponse('Měsíc je povinný (formát YYYY-MM)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($userIds) || !is_array($userIds)) {
|
||||||
|
errorResponse('Vyberte alespoň jednoho zaměstnance');
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int)substr($monthStr, 0, 4);
|
||||||
|
$month = (int)substr($monthStr, 5, 2);
|
||||||
|
|
||||||
|
$holidays = CzechHolidays::getHolidays($year);
|
||||||
|
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
|
||||||
|
|
||||||
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
// Batch fetch existing records (eliminates N*M queries)
|
||||||
|
$dateFrom = sprintf('%04d-%02d-01', $year, $month);
|
||||||
|
$dateTo = sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth);
|
||||||
|
$userIdInts = array_map('intval', $userIds);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($userIdInts), '?'));
|
||||||
|
$existStmt = $pdo->prepare("
|
||||||
|
SELECT user_id, shift_date FROM attendance
|
||||||
|
WHERE user_id IN ($placeholders) AND shift_date BETWEEN ? AND ?
|
||||||
|
");
|
||||||
|
$existParams = array_merge($userIdInts, [$dateFrom, $dateTo]);
|
||||||
|
$existStmt->execute($existParams);
|
||||||
|
$existingRecords = [];
|
||||||
|
foreach ($existStmt->fetchAll() as $row) {
|
||||||
|
$existingRecords[$row['user_id'] . ':' . $row['shift_date']] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$holidayStmt = $pdo->prepare("
|
||||||
|
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||||
|
VALUES (?, ?, 'holiday', 8, 'Státní svátek')
|
||||||
|
");
|
||||||
|
$workStmt = $pdo->prepare('
|
||||||
|
INSERT INTO attendance (user_id, shift_date, arrival_time, departure_time, break_start, break_end)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
foreach ($userIdInts as $userId) {
|
||||||
|
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||||
|
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
$dayOfWeek = (int)date('N', strtotime($date));
|
||||||
|
|
||||||
|
if ($dayOfWeek > 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isHoliday = in_array($date, $holidays, true);
|
||||||
|
|
||||||
|
if (isset($existingRecords[$userId . ':' . $date])) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isHoliday) {
|
||||||
|
$holidayStmt->execute([$userId, $date]);
|
||||||
|
} else {
|
||||||
|
$arrival = $date . ' ' . $arrivalTime . ':00';
|
||||||
|
$departure = $date . ' ' . $departureTime . ':00';
|
||||||
|
$breakStart = $date . ' ' . $breakStartTime . ':00';
|
||||||
|
$breakEnd = $date . ' ' . $breakEndTime . ':00';
|
||||||
|
$workStmt->execute([$userId, $date, $arrival, $departure, $breakStart, $breakEnd]);
|
||||||
|
}
|
||||||
|
$inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::logCreate('attendance', 0, [
|
||||||
|
'month' => $monthStr,
|
||||||
|
'user_ids' => $userIds,
|
||||||
|
'inserted' => $inserted,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
], "Admin hromadně vytvořil $inserted záznamů docházky pro měsíc $monthStr");
|
||||||
|
|
||||||
|
$msg = "Vytvořeno $inserted záznamů";
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$msg .= " ($skipped přeskočeno — již existují)";
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(['inserted' => $inserted, 'skipped' => $skipped], $msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateBalance(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$userId = (int)($input['user_id'] ?? 0);
|
||||||
|
$year = (int)($input['year'] ?? date('Y'));
|
||||||
|
$actionType = $input['action_type'] ?? 'edit';
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
errorResponse('ID uživatele je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||||
|
$stmt->execute([$userId, $year]);
|
||||||
|
$exists = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($actionType === 'reset') {
|
||||||
|
if ($exists) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'UPDATE leave_balances
|
||||||
|
SET vacation_used = 0, sick_used = 0
|
||||||
|
WHERE user_id = ? AND year = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId, $year]);
|
||||||
|
}
|
||||||
|
successResponse(null, 'Bilance byla resetována');
|
||||||
|
} else {
|
||||||
|
$vacationTotal = (float)($input['vacation_total'] ?? 160);
|
||||||
|
$vacationUsed = (float)($input['vacation_used'] ?? 0);
|
||||||
|
$sickUsed = (float)($input['sick_used'] ?? 0);
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'UPDATE leave_balances
|
||||||
|
SET vacation_total = ?, vacation_used = ?, sick_used = ?
|
||||||
|
WHERE user_id = ? AND year = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$vacationTotal, $vacationUsed, $sickUsed, $userId, $year]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO leave_balances
|
||||||
|
(user_id, year, vacation_total, vacation_used, sick_used)
|
||||||
|
VALUES (?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId, $year, $vacationTotal, $vacationUsed, $sickUsed]);
|
||||||
|
}
|
||||||
|
successResponse(null, 'Bilance byla aktualizována');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateAttendance(PDO $pdo, int $recordId): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||||
|
$stmt->execute([$recordId]);
|
||||||
|
$record = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
errorResponse('Záznam nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$shiftDate = $input['shift_date'] ?? $record['shift_date'];
|
||||||
|
$leaveType = $input['leave_type'] ?? 'work';
|
||||||
|
$leaveHours = $input['leave_hours'] ?? null;
|
||||||
|
$notes = $input['notes'] ?? null;
|
||||||
|
|
||||||
|
$oldLeaveType = $record['leave_type'] ?? 'work';
|
||||||
|
$oldLeaveHours = $record['leave_hours'] ?? 0;
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
if ($leaveType !== 'work') {
|
||||||
|
$leaveHours = $leaveHours ?: 8;
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE attendance
|
||||||
|
SET shift_date = ?, leave_type = ?, leave_hours = ?,
|
||||||
|
arrival_time = NULL, break_start = NULL,
|
||||||
|
break_end = NULL, departure_time = NULL, notes = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$shiftDate, $leaveType, $leaveHours, $notes, $recordId]);
|
||||||
|
|
||||||
|
if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) {
|
||||||
|
updateLeaveBalance(
|
||||||
|
$pdo,
|
||||||
|
(int)$record['user_id'],
|
||||||
|
$record['shift_date'],
|
||||||
|
$oldLeaveType,
|
||||||
|
-(float)$oldLeaveHours
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateLeaveBalance(
|
||||||
|
$pdo,
|
||||||
|
(int)$record['user_id'],
|
||||||
|
$shiftDate,
|
||||||
|
$leaveType,
|
||||||
|
(float)$leaveHours
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$arrivalDate = $input['arrival_date'] ?? $shiftDate;
|
||||||
|
$arrivalTime = $input['arrival_time'] ?? null;
|
||||||
|
$breakStartDate = $input['break_start_date'] ?? null;
|
||||||
|
$breakStartTime = $input['break_start_time'] ?? null;
|
||||||
|
$breakEndDate = $input['break_end_date'] ?? null;
|
||||||
|
$breakEndTime = $input['break_end_time'] ?? null;
|
||||||
|
$departureDate = $input['departure_date'] ?? null;
|
||||||
|
$departureTime = $input['departure_time'] ?? null;
|
||||||
|
/** @var mixed $rawProjectId */
|
||||||
|
$rawProjectId = $input['project_id'] ?? null;
|
||||||
|
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||||
|
? (int)$rawProjectId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null;
|
||||||
|
$breakStart = $breakStartTime ? "{$breakStartDate} {$breakStartTime}:00" : null;
|
||||||
|
$breakEnd = $breakEndTime ? "{$breakEndDate} {$breakEndTime}:00" : null;
|
||||||
|
$departure = $departureTime ? "{$departureDate} {$departureTime}:00" : null;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE attendance
|
||||||
|
SET shift_date = ?, arrival_time = ?, break_start = ?,
|
||||||
|
break_end = ?, departure_time = ?,
|
||||||
|
leave_type = 'work', leave_hours = NULL,
|
||||||
|
notes = ?, project_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId, $recordId]);
|
||||||
|
|
||||||
|
if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) {
|
||||||
|
updateLeaveBalance(
|
||||||
|
$pdo,
|
||||||
|
(int)$record['user_id'],
|
||||||
|
$record['shift_date'],
|
||||||
|
$oldLeaveType,
|
||||||
|
-(float)$oldLeaveHours
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectLogs = $input['project_logs'] ?? null;
|
||||||
|
if ($projectLogs !== null) {
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||||
|
$stmt->execute([$recordId]);
|
||||||
|
|
||||||
|
if (!empty($projectLogs) && ($input['leave_type'] ?? 'work') === 'work') {
|
||||||
|
$logStmt = $pdo->prepare(
|
||||||
|
'INSERT INTO attendance_project_logs
|
||||||
|
(attendance_id, project_id, hours, minutes)
|
||||||
|
VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($projectLogs as $log) {
|
||||||
|
$pid = (int)($log['project_id'] ?? 0);
|
||||||
|
if (!$pid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$h = (int)($log['hours'] ?? 0);
|
||||||
|
$m = (int)($log['minutes'] ?? 0);
|
||||||
|
if ($h === 0 && $m === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$logStmt->execute([$recordId, $pid, $h, $m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky');
|
||||||
|
|
||||||
|
successResponse(null, 'Záznam byl aktualizován');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteAttendance(PDO $pdo, int $recordId): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||||
|
$stmt->execute([$recordId]);
|
||||||
|
$record = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
errorResponse('Záznam nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaveType = $record['leave_type'] ?? 'work';
|
||||||
|
$leaveHours = $record['leave_hours'] ?? 0;
|
||||||
|
if ($leaveType !== 'work' && $leaveHours > 0) {
|
||||||
|
updateLeaveBalance($pdo, (int)$record['user_id'], $record['shift_date'], $leaveType, -(float)$leaveHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||||
|
$stmt->execute([$recordId]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?');
|
||||||
|
$stmt->execute([$recordId]);
|
||||||
|
|
||||||
|
AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky');
|
||||||
|
|
||||||
|
successResponse(null, 'Záznam byl smazán');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetProjectReport(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$yearParam = $_GET['year'] ?? null;
|
||||||
|
$monthParam = $_GET['month'] ?? null;
|
||||||
|
|
||||||
|
if ($yearParam) {
|
||||||
|
$yearInt = (int)$yearParam;
|
||||||
|
$currentYear = (int)date('Y');
|
||||||
|
$currentMonth = (int)date('m');
|
||||||
|
$maxMonth = ($yearInt < $currentYear) ? 12 : (($yearInt === $currentYear) ? $currentMonth : 0);
|
||||||
|
|
||||||
|
if ($maxMonth === 0) {
|
||||||
|
successResponse(['months' => [], 'year' => $yearInt]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = sprintf('%04d-01-01', $yearInt);
|
||||||
|
$lastDay = cal_days_in_month(CAL_GREGORIAN, $maxMonth, $yearInt);
|
||||||
|
$endDate = sprintf('%04d-%02d-%02d', $yearInt, $maxMonth, $lastDay);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT a.user_id, a.id as attendance_id, a.shift_date,
|
||||||
|
a.arrival_time, a.departure_time,
|
||||||
|
a.break_start, a.break_end,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.shift_date BETWEEN ? AND ?
|
||||||
|
AND a.departure_time IS NOT NULL
|
||||||
|
AND (a.leave_type IS NULL OR a.leave_type = 'work')
|
||||||
|
ORDER BY u.last_name ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$startDate, $endDate]);
|
||||||
|
$workRecords = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$totalWork = [];
|
||||||
|
$attendanceIds = [];
|
||||||
|
foreach ($workRecords as $rec) {
|
||||||
|
$m = (int)date('m', strtotime($rec['shift_date']));
|
||||||
|
$uid = $rec['user_id'];
|
||||||
|
$attendanceIds[] = $rec['attendance_id'];
|
||||||
|
if (!isset($totalWork[$m][$uid])) {
|
||||||
|
$totalWork[$m][$uid] = ['name' => $rec['user_name'], 'minutes' => 0];
|
||||||
|
}
|
||||||
|
$totalWork[$m][$uid]['minutes'] += calculateWorkMinutes($rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
$loggedMinutes = [];
|
||||||
|
$monthData = [];
|
||||||
|
$projectIds = [];
|
||||||
|
|
||||||
|
if (!empty($attendanceIds)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($attendanceIds), '?'));
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id, a.shift_date
|
||||||
|
FROM attendance_project_logs pl
|
||||||
|
JOIN attendance a ON pl.attendance_id = a.id
|
||||||
|
WHERE pl.attendance_id IN ($placeholders)
|
||||||
|
AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL)
|
||||||
|
");
|
||||||
|
$stmt->execute($attendanceIds);
|
||||||
|
$logs = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$m = (int)date('m', strtotime($log['shift_date']));
|
||||||
|
$pid = (int)$log['project_id'];
|
||||||
|
$uid = $log['user_id'];
|
||||||
|
$projectIds[$pid] = true;
|
||||||
|
if ($log['hours'] !== null) {
|
||||||
|
$minutes = (int)$log['hours'] * 60 + (int)$log['mins'];
|
||||||
|
} else {
|
||||||
|
$minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($monthData[$m][$pid][$uid])) {
|
||||||
|
$monthData[$m][$pid][$uid] = ['minutes' => 0];
|
||||||
|
}
|
||||||
|
$monthData[$m][$pid][$uid]['minutes'] += $minutes;
|
||||||
|
|
||||||
|
if (!isset($loggedMinutes[$m][$uid])) {
|
||||||
|
$loggedMinutes[$m][$uid] = 0;
|
||||||
|
}
|
||||||
|
$loggedMinutes[$m][$uid] += $minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Bez projektu" = total work - logged
|
||||||
|
foreach ($totalWork as $m => $users) {
|
||||||
|
foreach ($users as $uid => $ud) {
|
||||||
|
$logged = $loggedMinutes[$m][$uid] ?? 0;
|
||||||
|
$unlogged = $ud['minutes'] - $logged;
|
||||||
|
if ($unlogged > 1) {
|
||||||
|
if (!isset($monthData[$m][0][$uid])) {
|
||||||
|
$monthData[$m][0][$uid] = ['minutes' => 0];
|
||||||
|
}
|
||||||
|
$monthData[$m][0][$uid]['minutes'] += $unlogged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectMap = [];
|
||||||
|
if (!empty($projectIds)) {
|
||||||
|
try {
|
||||||
|
$offersPdo = db();
|
||||||
|
$ids = array_keys($projectIds);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$stmt2 = $offersPdo->prepare(
|
||||||
|
"SELECT id, project_number, name
|
||||||
|
FROM projects WHERE id IN ($placeholders)"
|
||||||
|
);
|
||||||
|
$stmt2->execute($ids);
|
||||||
|
foreach ($stmt2->fetchAll() as $p) {
|
||||||
|
$projectMap[$p['id']] = $p;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Failed to fetch project names for yearly report: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userNames = [];
|
||||||
|
foreach ($totalWork as $m => $users) {
|
||||||
|
foreach ($users as $uid => $ud) {
|
||||||
|
$userNames[$uid] = $ud['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = [];
|
||||||
|
for ($m = 1; $m <= $maxMonth; $m++) {
|
||||||
|
$projects = [];
|
||||||
|
if (isset($monthData[$m])) {
|
||||||
|
foreach ($monthData[$m] as $pid => $usersData) {
|
||||||
|
$proj = $pid ? ($projectMap[$pid] ?? null) : null;
|
||||||
|
$users = [];
|
||||||
|
$projectTotal = 0;
|
||||||
|
foreach ($usersData as $uid => $ud) {
|
||||||
|
$hours = round($ud['minutes'] / 60, 1);
|
||||||
|
$projectTotal += $hours;
|
||||||
|
$users[] = [
|
||||||
|
'user_id' => $uid,
|
||||||
|
'user_name' => $userNames[$uid] ?? "User #$uid",
|
||||||
|
'hours' => $hours,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($users, fn ($a, $b) => $b['hours'] <=> $a['hours']);
|
||||||
|
$projects[] = [
|
||||||
|
'project_id' => $pid ?: null,
|
||||||
|
'project_number' => $proj ? $proj['project_number'] : null,
|
||||||
|
'project_name' => $proj ? $proj['name'] : null,
|
||||||
|
'hours' => round($projectTotal, 1),
|
||||||
|
'users' => $users,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($projects, fn ($a, $b) => $b['hours'] <=> $a['hours']);
|
||||||
|
}
|
||||||
|
$months[$m] = [
|
||||||
|
'month' => $m,
|
||||||
|
'month_name' => getCzechMonthName($m),
|
||||||
|
'projects' => $projects,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(['months' => $months, 'year' => $yearInt]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single month mode
|
||||||
|
$month = $monthParam ?? date('Y-m');
|
||||||
|
$startDate = "{$month}-01";
|
||||||
|
$endDate = date('Y-m-t', strtotime($startDate));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT a.user_id, a.id as attendance_id, a.arrival_time, a.departure_time, a.break_start, a.break_end,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.shift_date BETWEEN ? AND ?
|
||||||
|
AND a.departure_time IS NOT NULL
|
||||||
|
AND (a.leave_type IS NULL OR a.leave_type = 'work')
|
||||||
|
ORDER BY u.last_name ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$startDate, $endDate]);
|
||||||
|
$workRecords = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$userTotalMinutes = [];
|
||||||
|
$userNames = [];
|
||||||
|
$attendanceIds = [];
|
||||||
|
foreach ($workRecords as $rec) {
|
||||||
|
$uid = $rec['user_id'];
|
||||||
|
$attendanceIds[] = $rec['attendance_id'];
|
||||||
|
$userNames[$uid] = $rec['user_name'];
|
||||||
|
if (!isset($userTotalMinutes[$uid])) {
|
||||||
|
$userTotalMinutes[$uid] = 0;
|
||||||
|
}
|
||||||
|
$userTotalMinutes[$uid] += calculateWorkMinutes($rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
$aggregated = [];
|
||||||
|
$projectIds = [];
|
||||||
|
$userLoggedMinutes = [];
|
||||||
|
|
||||||
|
if (!empty($attendanceIds)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($attendanceIds), '?'));
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id
|
||||||
|
FROM attendance_project_logs pl
|
||||||
|
JOIN attendance a ON pl.attendance_id = a.id
|
||||||
|
WHERE pl.attendance_id IN ($placeholders)
|
||||||
|
AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL)
|
||||||
|
");
|
||||||
|
$stmt->execute($attendanceIds);
|
||||||
|
$logs = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$uid = $log['user_id'];
|
||||||
|
$pid = (int)$log['project_id'];
|
||||||
|
$key = "{$uid}_{$pid}";
|
||||||
|
$projectIds[$pid] = true;
|
||||||
|
if ($log['hours'] !== null) {
|
||||||
|
$minutes = (int)$log['hours'] * 60 + (int)$log['mins'];
|
||||||
|
} else {
|
||||||
|
$minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($aggregated[$key])) {
|
||||||
|
$aggregated[$key] = ['user_id' => $uid, 'project_id' => $pid, 'minutes' => 0];
|
||||||
|
}
|
||||||
|
$aggregated[$key]['minutes'] += $minutes;
|
||||||
|
|
||||||
|
if (!isset($userLoggedMinutes[$uid])) {
|
||||||
|
$userLoggedMinutes[$uid] = 0;
|
||||||
|
}
|
||||||
|
$userLoggedMinutes[$uid] += $minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Bez projektu" per user
|
||||||
|
foreach ($userTotalMinutes as $uid => $total) {
|
||||||
|
$logged = $userLoggedMinutes[$uid] ?? 0;
|
||||||
|
$unlogged = $total - $logged;
|
||||||
|
if ($unlogged > 1) {
|
||||||
|
$key = "{$uid}_0";
|
||||||
|
if (!isset($aggregated[$key])) {
|
||||||
|
$aggregated[$key] = ['user_id' => $uid, 'project_id' => 0, 'minutes' => 0];
|
||||||
|
}
|
||||||
|
$aggregated[$key]['minutes'] += $unlogged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectMap = [];
|
||||||
|
if (!empty($projectIds)) {
|
||||||
|
try {
|
||||||
|
$offersPdo = db();
|
||||||
|
$ids = array_keys($projectIds);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$stmt = $offersPdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)");
|
||||||
|
$stmt->execute($ids);
|
||||||
|
foreach ($stmt->fetchAll() as $p) {
|
||||||
|
$projectMap[$p['id']] = $p;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Failed to fetch project names for report: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = [];
|
||||||
|
foreach ($aggregated as $item) {
|
||||||
|
$pid = $item['project_id'];
|
||||||
|
$proj = $pid ? ($projectMap[$pid] ?? null) : null;
|
||||||
|
$report[] = [
|
||||||
|
'user_id' => $item['user_id'],
|
||||||
|
'user_name' => $userNames[$item['user_id']] ?? "User #{$item['user_id']}",
|
||||||
|
'project_id' => $pid ?: null,
|
||||||
|
'project_number' => $proj ? $proj['project_number'] : null,
|
||||||
|
'project_name' => $proj ? $proj['name'] : null,
|
||||||
|
'hours' => round($item['minutes'] / 60, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'report' => $report,
|
||||||
|
'month' => $month,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetPrint(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$month = validateMonth();
|
||||||
|
$filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null;
|
||||||
|
|
||||||
|
$year = (int)substr($month, 0, 4);
|
||||||
|
$monthNum = (int)substr($month, 5, 2);
|
||||||
|
|
||||||
|
$startDate = "{$month}-01";
|
||||||
|
$endDate = date('Y-m-t', strtotime($startDate));
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||||
|
FROM attendance a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.shift_date BETWEEN ? AND ?
|
||||||
|
";
|
||||||
|
$params = [$startDate, $endDate];
|
||||||
|
|
||||||
|
if ($filterUserId) {
|
||||||
|
$sql .= ' AND a.user_id = ?';
|
||||||
|
$params[] = $filterUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY u.last_name ASC, a.shift_date ASC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$records = $stmt->fetchAll();
|
||||||
|
|
||||||
|
enrichRecordsWithProjectLogs($pdo, $records);
|
||||||
|
|
||||||
|
$userTotals = calculateUserTotals($records, true);
|
||||||
|
$leaveBalances = getLeaveBalancesBatch($pdo, array_keys($userTotals), $year);
|
||||||
|
addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum);
|
||||||
|
|
||||||
|
$selectedUserName = '';
|
||||||
|
if ($filterUserId) {
|
||||||
|
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$filterUserId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
$selectedUserName = $user ? $user['name'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'user_totals' => $userTotals,
|
||||||
|
'leave_balances' => $leaveBalances,
|
||||||
|
'users' => $users,
|
||||||
|
'month' => $month,
|
||||||
|
'month_name' => getCzechMonthName($monthNum) . ' ' . $year,
|
||||||
|
'selected_user' => $filterUserId,
|
||||||
|
'selected_user_name' => $selectedUserName,
|
||||||
|
'year' => $year,
|
||||||
|
'fund' => $fund,
|
||||||
|
]);
|
||||||
|
}
|
||||||
370
api/includes/AttendanceHelpers.php
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attendance helper functions - shared between user and admin handlers
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function roundUpTo15Minutes(string $datetime): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($datetime);
|
||||||
|
$minutes = (int)date('i', $timestamp);
|
||||||
|
|
||||||
|
$remainder = $minutes % 15;
|
||||||
|
|
||||||
|
if ($remainder === 0) {
|
||||||
|
$roundedMinutes = $minutes;
|
||||||
|
} else {
|
||||||
|
$roundedMinutes = $minutes + (15 - $remainder);
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||||
|
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundDownTo15Minutes(string $datetime): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($datetime);
|
||||||
|
$minutes = (int)date('i', $timestamp);
|
||||||
|
|
||||||
|
$remainder = $minutes % 15;
|
||||||
|
$roundedMinutes = $minutes - $remainder;
|
||||||
|
|
||||||
|
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||||
|
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToNearest10Minutes(string $datetime): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($datetime);
|
||||||
|
$minutes = (int)date('i', $timestamp);
|
||||||
|
|
||||||
|
$remainder = $minutes % 10;
|
||||||
|
|
||||||
|
if ($remainder < 5) {
|
||||||
|
$roundedMinutes = $minutes - $remainder;
|
||||||
|
} else {
|
||||||
|
$roundedMinutes = $minutes + (10 - $remainder);
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||||
|
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
function calculateWorkMinutes(array $record): int
|
||||||
|
{
|
||||||
|
if (!$record['arrival_time'] || !$record['departure_time']) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$arrival = strtotime($record['arrival_time']);
|
||||||
|
$departure = strtotime($record['departure_time']);
|
||||||
|
$totalMinutes = ($departure - $arrival) / 60;
|
||||||
|
|
||||||
|
if ($record['break_start'] && $record['break_end']) {
|
||||||
|
$breakStart = strtotime($record['break_start']);
|
||||||
|
$breakEnd = strtotime($record['break_end']);
|
||||||
|
$breakMinutes = ($breakEnd - $breakStart) / 60;
|
||||||
|
$totalMinutes -= $breakMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, (int)$totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}
|
||||||
|
*/
|
||||||
|
function getLeaveBalance(PDO $pdo, int $userId, ?int $year = null): array
|
||||||
|
{
|
||||||
|
$year = $year ?: (int)date('Y');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT vacation_total, vacation_used, sick_used 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch get leave balances for multiple users (eliminates N+1 queries)
|
||||||
|
*
|
||||||
|
* @param array<int, int> $userIds
|
||||||
|
* @return array<int, array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}>
|
||||||
|
*/
|
||||||
|
function getLeaveBalancesBatch(PDO $pdo, array $userIds, ?int $year = null): array
|
||||||
|
{
|
||||||
|
$year = $year ?: (int)date('Y');
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
$default = [
|
||||||
|
'vacation_total' => 160,
|
||||||
|
'vacation_used' => 0,
|
||||||
|
'vacation_remaining' => 160,
|
||||||
|
'sick_used' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (empty($userIds)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT user_id, vacation_total, vacation_used, sick_used
|
||||||
|
FROM leave_balances
|
||||||
|
WHERE user_id IN ($placeholders) AND year = ?
|
||||||
|
");
|
||||||
|
$params = array_values($userIds);
|
||||||
|
$params[] = $year;
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$balanceMap = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$balanceMap[$row['user_id']] = [
|
||||||
|
'vacation_total' => (float)$row['vacation_total'],
|
||||||
|
'vacation_used' => (float)$row['vacation_used'],
|
||||||
|
'vacation_remaining' => (float)$row['vacation_total'] - (float)$row['vacation_used'],
|
||||||
|
'sick_used' => (float)$row['sick_used'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($userIds as $uid) {
|
||||||
|
$result[$uid] = $balanceMap[$uid] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLeaveBalance(PDO $pdo, int $userId, string $date, string $leaveType, float $hours): void
|
||||||
|
{
|
||||||
|
if ($leaveType === 'work' || $leaveType === 'holiday' || $leaveType === 'unpaid') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int)date('Y', strtotime($date));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||||
|
$stmt->execute([$userId, $year]);
|
||||||
|
$balance = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$balance) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO leave_balances (user_id, year, vacation_total, vacation_used, sick_used)
|
||||||
|
VALUES (?, ?, 160, 0, 0)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId, $year]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($leaveType === 'vacation') {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'UPDATE leave_balances SET vacation_used = vacation_used + ? WHERE user_id = ? AND year = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$hours, $userId, $year]);
|
||||||
|
} elseif ($leaveType === 'sick') {
|
||||||
|
$stmt = $pdo->prepare('UPDATE leave_balances SET sick_used = sick_used + ? WHERE user_id = ? AND year = ?');
|
||||||
|
$stmt->execute([$hours, $userId, $year]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCzechMonthName(int $month): string
|
||||||
|
{
|
||||||
|
$months = [
|
||||||
|
1 => 'Leden', 2 => 'Únor', 3 => 'Březen', 4 => 'Duben',
|
||||||
|
5 => 'Květen', 6 => 'Červen', 7 => 'Červenec', 8 => 'Srpen',
|
||||||
|
9 => 'Září', 10 => 'Říjen', 11 => 'Listopad', 12 => 'Prosinec',
|
||||||
|
];
|
||||||
|
return $months[$month] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCzechDayName(int $dayOfWeek): string
|
||||||
|
{
|
||||||
|
$days = [
|
||||||
|
0 => 'neděle', 1 => 'pondělí', 2 => 'úterý', 3 => 'středa',
|
||||||
|
4 => 'čtvrtek', 5 => 'pátek', 6 => 'sobota',
|
||||||
|
];
|
||||||
|
return $days[$dayOfWeek] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich attendance records with project logs and project names (in-place)
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $records
|
||||||
|
*/
|
||||||
|
function enrichRecordsWithProjectLogs(PDO $pdo, array &$records): void
|
||||||
|
{
|
||||||
|
$recordIds = array_column($records, 'id');
|
||||||
|
$recordProjectLogs = [];
|
||||||
|
if (!empty($recordIds)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($recordIds), '?'));
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT * FROM attendance_project_logs WHERE attendance_id IN ($placeholders) ORDER BY started_at ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute($recordIds);
|
||||||
|
foreach ($stmt->fetchAll() as $log) {
|
||||||
|
$recordProjectLogs[$log['attendance_id']][] = $log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectIds = [];
|
||||||
|
foreach ($records as $rec) {
|
||||||
|
if ($rec['project_id']) {
|
||||||
|
$projectIds[$rec['project_id']] = $rec['project_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($recordProjectLogs as $logs) {
|
||||||
|
foreach ($logs as $l) {
|
||||||
|
$projectIds[$l['project_id']] = $l['project_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$projectNameMap = fetchProjectNames($projectIds);
|
||||||
|
|
||||||
|
foreach ($records as &$rec) {
|
||||||
|
$rec['project_name'] = $rec['project_id'] ? ($projectNameMap[$rec['project_id']] ?? null) : null;
|
||||||
|
$logs = $recordProjectLogs[$rec['id']] ?? [];
|
||||||
|
foreach ($logs as &$l) {
|
||||||
|
$l['project_name'] = $projectNameMap[$l['project_id']] ?? null;
|
||||||
|
}
|
||||||
|
unset($l);
|
||||||
|
$rec['project_logs'] = $logs;
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate per-user totals from records array
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $records
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
function calculateUserTotals(array $records, bool $includeRecords = false): array
|
||||||
|
{
|
||||||
|
$userTotals = [];
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$uid = $record['user_id'];
|
||||||
|
if (!isset($userTotals[$uid])) {
|
||||||
|
$userTotals[$uid] = [
|
||||||
|
'name' => $record['user_name'],
|
||||||
|
'minutes' => 0,
|
||||||
|
'working' => false,
|
||||||
|
'vacation_hours' => 0,
|
||||||
|
'sick_hours' => 0,
|
||||||
|
'holiday_hours' => 0,
|
||||||
|
'unpaid_hours' => 0,
|
||||||
|
];
|
||||||
|
if ($includeRecords) {
|
||||||
|
$userTotals[$uid]['records'] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaveType = $record['leave_type'] ?? 'work';
|
||||||
|
$leaveHours = (float)($record['leave_hours'] ?? 0);
|
||||||
|
|
||||||
|
if ($leaveType === 'vacation') {
|
||||||
|
$userTotals[$uid]['vacation_hours'] += $leaveHours;
|
||||||
|
} elseif ($leaveType === 'sick') {
|
||||||
|
$userTotals[$uid]['sick_hours'] += $leaveHours;
|
||||||
|
} elseif ($leaveType === 'holiday') {
|
||||||
|
$userTotals[$uid]['holiday_hours'] += $leaveHours;
|
||||||
|
} elseif ($leaveType === 'unpaid') {
|
||||||
|
$userTotals[$uid]['unpaid_hours'] += $leaveHours;
|
||||||
|
} else {
|
||||||
|
$userTotals[$uid]['minutes'] += calculateWorkMinutes($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includeRecords) {
|
||||||
|
$userTotals[$uid]['records'][] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record['arrival_time'] && !$record['departure_time']) {
|
||||||
|
$userTotals[$uid]['working'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $userTotals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add monthly fund data and "working now" status to user totals
|
||||||
|
*
|
||||||
|
* @param array<array<string, mixed>> $userTotals
|
||||||
|
*/
|
||||||
|
function addFundDataToUserTotals(PDO $pdo, array &$userTotals, int $year, int $monthNum): void
|
||||||
|
{
|
||||||
|
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||||
|
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
|
||||||
|
|
||||||
|
foreach ($userTotals as $uid => &$ut) {
|
||||||
|
$workedHours = round($ut['minutes'] / 60, 1);
|
||||||
|
$leaveHours = $ut['vacation_hours'] + $ut['sick_hours'];
|
||||||
|
$covered = $workedHours + $leaveHours;
|
||||||
|
$ut['fund'] = $fund;
|
||||||
|
$ut['business_days'] = $businessDays;
|
||||||
|
$ut['worked_hours'] = $workedHours;
|
||||||
|
$ut['covered'] = $covered;
|
||||||
|
$ut['missing'] = max(0, round($fund - $covered, 1));
|
||||||
|
$ut['overtime'] = max(0, round($covered - $fund, 1));
|
||||||
|
}
|
||||||
|
unset($ut);
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT DISTINCT user_id FROM attendance
|
||||||
|
WHERE shift_date = ?
|
||||||
|
AND arrival_time IS NOT NULL
|
||||||
|
AND departure_time IS NULL
|
||||||
|
AND (leave_type IS NULL OR leave_type = 'work')
|
||||||
|
");
|
||||||
|
$stmt->execute([$today]);
|
||||||
|
$workingNow = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($workingNow as $uid) {
|
||||||
|
if (isset($userTotals[$uid])) {
|
||||||
|
$userTotals[$uid]['working'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch project names from offers DB
|
||||||
|
*
|
||||||
|
* @param array<int, int> $projectIds
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
function fetchProjectNames(array $projectIds): array
|
||||||
|
{
|
||||||
|
if (empty($projectIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$placeholders = implode(',', array_fill(0, count($projectIds), '?'));
|
||||||
|
$stmt = $pdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)");
|
||||||
|
$stmt->execute(array_values($projectIds));
|
||||||
|
$map = [];
|
||||||
|
foreach ($stmt->fetchAll() as $p) {
|
||||||
|
$map[$p['id']] = $p['project_number'] . ' – ' . $p['name'];
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Failed to fetch project names: ' . $e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
556
api/includes/AuditLog.php
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Audit Logging System
|
||||||
|
*
|
||||||
|
* Comprehensive audit trail for all administrative actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
|
||||||
|
class AuditLog
|
||||||
|
{
|
||||||
|
// Action types
|
||||||
|
public const ACTION_LOGIN = 'login';
|
||||||
|
public const ACTION_LOGIN_FAILED = 'login_failed';
|
||||||
|
public const ACTION_LOGOUT = 'logout';
|
||||||
|
public const ACTION_CREATE = 'create';
|
||||||
|
public const ACTION_UPDATE = 'update';
|
||||||
|
public const ACTION_DELETE = 'delete';
|
||||||
|
public const ACTION_VIEW = 'view';
|
||||||
|
public const ACTION_ACTIVATE = 'activate';
|
||||||
|
public const ACTION_DEACTIVATE = 'deactivate';
|
||||||
|
public const ACTION_PASSWORD_CHANGE = 'password_change';
|
||||||
|
public const ACTION_PERMISSION_CHANGE = 'permission_change';
|
||||||
|
public const ACTION_ACCESS_DENIED = 'access_denied';
|
||||||
|
|
||||||
|
private static ?int $currentUserId = null;
|
||||||
|
private static ?string $currentUsername = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nastaví kontext aktuálního uživatele pro všechny následující logy
|
||||||
|
*/
|
||||||
|
public static function setUser(int $userId, string $username): void
|
||||||
|
{
|
||||||
|
self::$currentUserId = $userId;
|
||||||
|
self::$currentUsername = $username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an action
|
||||||
|
*
|
||||||
|
* @param string $action Action type (use class constants)
|
||||||
|
* @param string|null $entityType Entity type (e.g., 'user', 'project')
|
||||||
|
* @param int|null $entityId Entity ID
|
||||||
|
* @param string|null $description Human-readable description
|
||||||
|
* @param array<string, mixed>|null $oldValues Previous values (for updates)
|
||||||
|
* @param array<string, mixed>|null $newValues New values (for updates/creates)
|
||||||
|
*/
|
||||||
|
public static function log(
|
||||||
|
string $action,
|
||||||
|
?string $entityType = null,
|
||||||
|
?int $entityId = null,
|
||||||
|
?string $description = null,
|
||||||
|
?array $oldValues = null,
|
||||||
|
?array $newValues = null
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$userId = self::$currentUserId;
|
||||||
|
$username = self::$currentUsername;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
user_ip,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
description,
|
||||||
|
old_values,
|
||||||
|
new_values,
|
||||||
|
user_agent,
|
||||||
|
session_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$userId,
|
||||||
|
$username,
|
||||||
|
getClientIp(),
|
||||||
|
$action,
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
$description,
|
||||||
|
$oldValues ? json_encode($oldValues, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
$newValues ? json_encode($newValues, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||||
|
session_id() ?: null,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('AuditLog error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log successful login
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param string $username Username
|
||||||
|
*/
|
||||||
|
public static function logLogin(int $userId, string $username): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
user_ip,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
description,
|
||||||
|
user_agent,
|
||||||
|
session_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$userId,
|
||||||
|
$username,
|
||||||
|
getClientIp(),
|
||||||
|
self::ACTION_LOGIN,
|
||||||
|
'user',
|
||||||
|
$userId,
|
||||||
|
"Přihlášení uživatele '$username'",
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||||
|
session_id() ?: null,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('AuditLog login error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log failed login attempt
|
||||||
|
*
|
||||||
|
* @param string $username Attempted username
|
||||||
|
* @param string $reason Failure reason
|
||||||
|
*/
|
||||||
|
public static function logLoginFailed(string $username, string $reason = 'invalid_credentials'): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
username,
|
||||||
|
user_ip,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
description,
|
||||||
|
user_agent,
|
||||||
|
session_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$username,
|
||||||
|
getClientIp(),
|
||||||
|
self::ACTION_LOGIN_FAILED,
|
||||||
|
'user',
|
||||||
|
"Neúspěšné přihlášení '$username': $reason",
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||||
|
session_id() ?: null,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('AuditLog login failed error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log logout
|
||||||
|
*
|
||||||
|
* @param int|null $userId User ID (optional, for JWT-based auth)
|
||||||
|
* @param string|null $username Username (optional, for JWT-based auth)
|
||||||
|
*/
|
||||||
|
public static function logLogout(?int $userId = null, ?string $username = null): void
|
||||||
|
{
|
||||||
|
if ($userId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
user_ip,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
description,
|
||||||
|
user_agent
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$userId,
|
||||||
|
$username,
|
||||||
|
getClientIp(),
|
||||||
|
self::ACTION_LOGOUT,
|
||||||
|
'user',
|
||||||
|
$userId,
|
||||||
|
"Odhlášení uživatele '{$username}'",
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('AuditLog logout error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log entity creation
|
||||||
|
*
|
||||||
|
* @param string $entityType Entity type
|
||||||
|
* @param int $entityId Entity ID
|
||||||
|
* @param array<string, mixed> $data Created data
|
||||||
|
* @param string|null $description Optional description
|
||||||
|
*/
|
||||||
|
public static function logCreate(
|
||||||
|
string $entityType,
|
||||||
|
int $entityId,
|
||||||
|
array $data,
|
||||||
|
?string $description = null
|
||||||
|
): void {
|
||||||
|
// Remove sensitive fields from logged data
|
||||||
|
$safeData = self::sanitizeData($data);
|
||||||
|
|
||||||
|
self::log(
|
||||||
|
self::ACTION_CREATE,
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
$description ?? "Vytvořen $entityType #$entityId",
|
||||||
|
null,
|
||||||
|
$safeData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log entity update
|
||||||
|
*
|
||||||
|
* @param string $entityType Entity type
|
||||||
|
* @param int $entityId Entity ID
|
||||||
|
* @param array<string, mixed> $oldData Old values
|
||||||
|
* @param array<string, mixed> $newData New values
|
||||||
|
* @param string|null $description Optional description
|
||||||
|
*/
|
||||||
|
public static function logUpdate(
|
||||||
|
string $entityType,
|
||||||
|
int $entityId,
|
||||||
|
array $oldData,
|
||||||
|
array $newData,
|
||||||
|
?string $description = null
|
||||||
|
): void {
|
||||||
|
// Only log changed fields
|
||||||
|
$changes = self::getChanges($oldData, $newData);
|
||||||
|
|
||||||
|
if (empty($changes['old']) && empty($changes['new'])) {
|
||||||
|
return; // No actual changes
|
||||||
|
}
|
||||||
|
|
||||||
|
self::log(
|
||||||
|
self::ACTION_UPDATE,
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
$description ?? "Upraven $entityType #$entityId",
|
||||||
|
$changes['old'],
|
||||||
|
$changes['new']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log entity deletion
|
||||||
|
*
|
||||||
|
* @param string $entityType Entity type
|
||||||
|
* @param int $entityId Entity ID
|
||||||
|
* @param array<string, mixed>|null $data Deleted entity data
|
||||||
|
* @param string|null $description Optional description
|
||||||
|
*/
|
||||||
|
public static function logDelete(
|
||||||
|
string $entityType,
|
||||||
|
int $entityId,
|
||||||
|
?array $data = null,
|
||||||
|
?string $description = null
|
||||||
|
): void {
|
||||||
|
$safeData = $data ? self::sanitizeData($data) : null;
|
||||||
|
|
||||||
|
self::log(
|
||||||
|
self::ACTION_DELETE,
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
$description ?? "Smazán $entityType #$entityId",
|
||||||
|
$safeData,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log access denied
|
||||||
|
*
|
||||||
|
* @param string $resource Resource that was denied
|
||||||
|
* @param string|null $permission Required permission
|
||||||
|
*/
|
||||||
|
public static function logAccessDenied(string $resource, ?string $permission = null): void
|
||||||
|
{
|
||||||
|
$description = "Přístup odepřen k '$resource'";
|
||||||
|
if ($permission) {
|
||||||
|
$description .= " (vyžaduje: $permission)";
|
||||||
|
}
|
||||||
|
|
||||||
|
self::log(
|
||||||
|
self::ACTION_ACCESS_DENIED,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get changes between old and new data
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $oldData Old values
|
||||||
|
* @param array<string, mixed> $newData New values
|
||||||
|
* @return array{old: array<string, mixed>, new: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private static function getChanges(array $oldData, array $newData): array
|
||||||
|
{
|
||||||
|
$oldData = self::sanitizeData($oldData);
|
||||||
|
$newData = self::sanitizeData($newData);
|
||||||
|
|
||||||
|
$changedOld = [];
|
||||||
|
$changedNew = [];
|
||||||
|
|
||||||
|
// Find changed fields
|
||||||
|
foreach ($newData as $key => $newValue) {
|
||||||
|
$oldValue = $oldData[$key] ?? null;
|
||||||
|
|
||||||
|
if ($oldValue !== $newValue) {
|
||||||
|
$changedOld[$key] = $oldValue;
|
||||||
|
$changedNew[$key] = $newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find removed fields
|
||||||
|
foreach ($oldData as $key => $oldValue) {
|
||||||
|
if (!array_key_exists($key, $newData)) {
|
||||||
|
$changedOld[$key] = $oldValue;
|
||||||
|
$changedNew[$key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['old' => $changedOld, 'new' => $changedNew];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove sensitive fields from data before logging
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data Data to sanitize
|
||||||
|
* @return array<string, mixed> Sanitized data
|
||||||
|
*/
|
||||||
|
private static function sanitizeData(array $data): array
|
||||||
|
{
|
||||||
|
$sensitiveFields = [
|
||||||
|
'password',
|
||||||
|
'password_hash',
|
||||||
|
'token',
|
||||||
|
'token_hash',
|
||||||
|
'secret',
|
||||||
|
'api_key',
|
||||||
|
'private_key',
|
||||||
|
'csrf_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sensitiveFields as $field) {
|
||||||
|
if (isset($data[$field])) {
|
||||||
|
$data[$field] = '[REDACTED]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs with filtering and pagination
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $filters Filter options
|
||||||
|
* @param int $page Page number (1-based)
|
||||||
|
* @param int $perPage Items per page
|
||||||
|
* @return array{logs: list<array<string, mixed>>, total: int, pages: int, page: int, per_page: int}
|
||||||
|
*/
|
||||||
|
public static function getLogs(array $filters = [], int $page = 1, int $perPage = 50): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (!empty($filters['user_id'])) {
|
||||||
|
$where[] = 'user_id = ?';
|
||||||
|
$params[] = $filters['user_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['username'])) {
|
||||||
|
$where[] = 'username LIKE ?';
|
||||||
|
$params[] = '%' . $filters['username'] . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['action'])) {
|
||||||
|
$where[] = 'action = ?';
|
||||||
|
$params[] = $filters['action'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['entity_type'])) {
|
||||||
|
$where[] = 'entity_type = ?';
|
||||||
|
$params[] = $filters['entity_type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['ip'])) {
|
||||||
|
$where[] = 'user_ip LIKE ?';
|
||||||
|
$params[] = '%' . $filters['ip'] . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$where[] = 'created_at >= ?';
|
||||||
|
$params[] = $filters['date_from'] . ' 00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$where[] = 'created_at <= ?';
|
||||||
|
$params[] = $filters['date_to'] . ' 23:59:59';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$where[] = '(description LIKE ? OR username LIKE ?)';
|
||||||
|
$searchTerm = '%' . $filters['search'] . '%';
|
||||||
|
$params[] = $searchTerm;
|
||||||
|
$params[] = $searchTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
$countSql = "SELECT COUNT(*) FROM audit_logs $whereClause";
|
||||||
|
$stmt = $pdo->prepare($countSql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$total = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
$pages = max(1, ceil($total / $perPage));
|
||||||
|
$page = max(1, min($page, $pages));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
// Get logs
|
||||||
|
$sql = "
|
||||||
|
SELECT *
|
||||||
|
FROM audit_logs
|
||||||
|
$whereClause
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $perPage OFFSET $offset
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$logs = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Parse JSON fields
|
||||||
|
foreach ($logs as &$log) {
|
||||||
|
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
||||||
|
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'logs' => $logs,
|
||||||
|
'total' => $total,
|
||||||
|
'pages' => $pages,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
];
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('AuditLog getLogs error: ' . $e->getMessage());
|
||||||
|
return ['logs' => [], 'total' => 0, 'pages' => 0, 'page' => 1, 'per_page' => $perPage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent activity for a user
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param int $limit Number of records
|
||||||
|
* @return list<array<string, mixed>> Recent logs
|
||||||
|
*/
|
||||||
|
public static function getUserActivity(int $userId, int $limit = 10): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT *
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $limit]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('AuditLog getUserActivity error: ' . $e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get entity history
|
||||||
|
*
|
||||||
|
* @param string $entityType Entity type
|
||||||
|
* @param int $entityId Entity ID
|
||||||
|
* @return list<array<string, mixed>> Audit log history
|
||||||
|
*/
|
||||||
|
public static function getEntityHistory(string $entityType, int $entityId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT *
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE entity_type = ? AND entity_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
');
|
||||||
|
$stmt->execute([$entityType, $entityId]);
|
||||||
|
|
||||||
|
$logs = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($logs as &$log) {
|
||||||
|
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
||||||
|
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $logs;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('AuditLog getEntityHistory error: ' . $e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
api/includes/CnbRates.php
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devizove kurzy CNB s file cache per datum.
|
||||||
|
*
|
||||||
|
* Pouziti:
|
||||||
|
* $cnb = CnbRates::getInstance();
|
||||||
|
* $czk = $cnb->toCzk(1000.0, 'EUR', '2026-03-01');
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class CnbRates
|
||||||
|
{
|
||||||
|
private const API_URL = 'https://api.cnb.cz/cnbapi/exrates/daily';
|
||||||
|
private const CACHE_FILE = 'cnb_rates_cache.json';
|
||||||
|
|
||||||
|
// Kurzy starsi nez dnesek se nemeni, cachujem navzdy.
|
||||||
|
// Dnesni kurz cachujeme na 6 hodin (muze se behem dne aktualizovat).
|
||||||
|
private const TODAY_CACHE_TTL = 21600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory cache: date => currency => {rate, amount}
|
||||||
|
* @var array<string, array<string, array{rate: float, amount: int}>>
|
||||||
|
*/
|
||||||
|
private array $ratesByDate = [];
|
||||||
|
|
||||||
|
private static ?CnbRates $instance = null;
|
||||||
|
|
||||||
|
public static function getInstance(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
$this->loadCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevede castku na CZK dle kurzu platneho k danemu datu.
|
||||||
|
*/
|
||||||
|
public function toCzk(float $amount, string $currency, string $date = ''): float
|
||||||
|
{
|
||||||
|
if ($currency === 'CZK') {
|
||||||
|
return $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rates = $this->getRatesForDate($date ?: date('Y-m-d'));
|
||||||
|
if (!isset($rates[$currency])) {
|
||||||
|
return $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = $rates[$currency];
|
||||||
|
return $amount * ($info['rate'] / $info['amount']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secte pole [{amount, currency, date?}] do jedne CZK castky.
|
||||||
|
* Kazda polozka muze mit vlastni datum pro kurz.
|
||||||
|
*
|
||||||
|
* @param array<int, array{amount: float, currency: string, date?: string}> $items
|
||||||
|
*/
|
||||||
|
public function sumToCzk(array $items): float
|
||||||
|
{
|
||||||
|
$total = 0.0;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$total += $this->toCzk(
|
||||||
|
(float) $item['amount'],
|
||||||
|
(string) $item['currency'],
|
||||||
|
(string) ($item['date'] ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return round($total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{rate: float, amount: int}>
|
||||||
|
*/
|
||||||
|
private function getRatesForDate(string $date): array
|
||||||
|
{
|
||||||
|
if (isset($this->ratesByDate[$date])) {
|
||||||
|
return $this->ratesByDate[$date];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rates = $this->fetchFromApi($date);
|
||||||
|
if ($rates !== []) {
|
||||||
|
$this->ratesByDate[$date] = $rates;
|
||||||
|
$this->saveCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cache ---
|
||||||
|
|
||||||
|
private function getCachePath(): string
|
||||||
|
{
|
||||||
|
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . self::CACHE_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadCache(): void
|
||||||
|
{
|
||||||
|
$path = $this->getCachePath();
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
if ($content === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
foreach ($data as $date => $entry) {
|
||||||
|
if (!is_array($entry) || !isset($entry['rates'], $entry['fetched_at'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Dnesni kurz expiruje po TTL, starsi zustavaji navzdy
|
||||||
|
if ($date === $today) {
|
||||||
|
$age = time() - (int) $entry['fetched_at'];
|
||||||
|
if ($age > self::TODAY_CACHE_TTL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->ratesByDate[$date] = $entry['rates'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveCache(): void
|
||||||
|
{
|
||||||
|
$path = $this->getCachePath();
|
||||||
|
|
||||||
|
// Nacist existujici cache a mergovat
|
||||||
|
$existing = [];
|
||||||
|
if (file_exists($path)) {
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
if ($content !== false) {
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$existing = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
foreach ($this->ratesByDate as $date => $rates) {
|
||||||
|
// Neprepisuj existujici pokud uz tam je (zachovej fetched_at)
|
||||||
|
if (!isset($existing[$date])) {
|
||||||
|
$existing[$date] = [
|
||||||
|
'rates' => $rates,
|
||||||
|
'fetched_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($existing, JSON_THROW_ON_ERROR);
|
||||||
|
file_put_contents($path, $json, LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{rate: float, amount: int}>
|
||||||
|
*/
|
||||||
|
private function fetchFromApi(string $date): array
|
||||||
|
{
|
||||||
|
$url = self::API_URL . '?lang=EN&date=' . urlencode($date);
|
||||||
|
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => ['timeout' => 5],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents($url, false, $context);
|
||||||
|
if ($response === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (!is_array($data) || !isset($data['rates'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rates = [];
|
||||||
|
foreach ($data['rates'] as $entry) {
|
||||||
|
if (!isset($entry['currencyCode'], $entry['rate'], $entry['amount'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rates[$entry['currencyCode']] = [
|
||||||
|
'rate' => (float) $entry['rate'],
|
||||||
|
'amount' => (int) $entry['amount'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rates;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
api/includes/CzechHolidays.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czech Holidays & Work Fund Calculator
|
||||||
|
*
|
||||||
|
* Provides Czech public holidays (including movable Easter dates)
|
||||||
|
* and monthly work fund calculations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class CzechHolidays
|
||||||
|
{
|
||||||
|
/** @var array<int, list<string>> Static cache for holidays by year */
|
||||||
|
private static array $holidayCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all Czech public holidays for a given year.
|
||||||
|
* Returns array of 'Y-m-d' strings (11 fixed + 2 Easter-based).
|
||||||
|
* Results are cached per-request to avoid recalculation.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function getHolidays(int $year): array
|
||||||
|
{
|
||||||
|
if (isset(self::$holidayCache[$year])) {
|
||||||
|
return self::$holidayCache[$year];
|
||||||
|
}
|
||||||
|
// Fixed holidays
|
||||||
|
$holidays = [
|
||||||
|
sprintf('%04d-01-01', $year), // Den obnovy samostatného českého státu
|
||||||
|
sprintf('%04d-05-01', $year), // Svátek práce
|
||||||
|
sprintf('%04d-05-08', $year), // Den vítězství
|
||||||
|
sprintf('%04d-07-05', $year), // Den slovanských věrozvěstů Cyrila a Metoděje
|
||||||
|
sprintf('%04d-07-06', $year), // Den upálení mistra Jana Husa
|
||||||
|
sprintf('%04d-09-28', $year), // Den české státnosti
|
||||||
|
sprintf('%04d-10-28', $year), // Den vzniku samostatného československého státu
|
||||||
|
sprintf('%04d-11-17', $year), // Den boje za svobodu a demokracii
|
||||||
|
sprintf('%04d-12-24', $year), // Štědrý den
|
||||||
|
sprintf('%04d-12-25', $year), // 1. svátek vánoční
|
||||||
|
sprintf('%04d-12-26', $year), // 2. svátek vánoční
|
||||||
|
];
|
||||||
|
|
||||||
|
// Easter-based holidays (Anonymous Gregorian algorithm)
|
||||||
|
$easterSunday = self::getEasterSunday($year);
|
||||||
|
$goodFriday = date('Y-m-d', strtotime($easterSunday . ' -2 days'));
|
||||||
|
$easterMonday = date('Y-m-d', strtotime($easterSunday . ' +1 day'));
|
||||||
|
$holidays[] = $goodFriday; // Velký pátek
|
||||||
|
$holidays[] = $easterMonday; // Velikonoční pondělí
|
||||||
|
|
||||||
|
sort($holidays);
|
||||||
|
self::$holidayCache[$year] = $holidays;
|
||||||
|
return $holidays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a date is a Czech public holiday.
|
||||||
|
*/
|
||||||
|
public static function isHoliday(string $date): bool
|
||||||
|
{
|
||||||
|
$year = (int)date('Y', strtotime($date));
|
||||||
|
$formatted = date('Y-m-d', strtotime($date));
|
||||||
|
return in_array($formatted, self::getHolidays($year), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of business days (Mon-Fri, excluding holidays) in a month.
|
||||||
|
*/
|
||||||
|
public static function getBusinessDaysInMonth(int $year, int $month): int
|
||||||
|
{
|
||||||
|
$holidays = self::getHolidays($year);
|
||||||
|
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
|
||||||
|
$businessDays = 0;
|
||||||
|
|
||||||
|
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||||
|
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
$dayOfWeek = (int)date('N', strtotime($date)); // 1=Mon, 7=Sun
|
||||||
|
if ($dayOfWeek <= 5 && !in_array($date, $holidays, true)) {
|
||||||
|
$businessDays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $businessDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get monthly work fund in hours (business days × 8).
|
||||||
|
*/
|
||||||
|
public static function getMonthlyWorkFund(int $year, int $month): float
|
||||||
|
{
|
||||||
|
return self::getBusinessDaysInMonth($year, $month) * 8.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Easter Sunday date using the Anonymous Gregorian algorithm.
|
||||||
|
* Returns 'Y-m-d' string.
|
||||||
|
*/
|
||||||
|
private static function getEasterSunday(int $year): string
|
||||||
|
{
|
||||||
|
$a = $year % 19;
|
||||||
|
$b = intdiv($year, 100);
|
||||||
|
$c = $year % 100;
|
||||||
|
$d = intdiv($b, 4);
|
||||||
|
$e = $b % 4;
|
||||||
|
$f = intdiv($b + 8, 25);
|
||||||
|
$g = intdiv($b - $f + 1, 3);
|
||||||
|
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||||
|
$i = intdiv($c, 4);
|
||||||
|
$k = $c % 4;
|
||||||
|
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||||
|
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||||
|
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||||
|
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||||
|
|
||||||
|
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
api/includes/Encryption.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-GCM encryption helper for sensitive data at rest (e.g., TOTP secrets).
|
||||||
|
*
|
||||||
|
* Requires TOTP_ENCRYPTION_KEY in .env (64 hex chars = 32 bytes).
|
||||||
|
* Format: base64(nonce + ciphertext + tag)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Encryption
|
||||||
|
{
|
||||||
|
private const CIPHER = 'aes-256-gcm';
|
||||||
|
private const NONCE_LENGTH = 12;
|
||||||
|
private const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
private static ?string $key = null;
|
||||||
|
|
||||||
|
private static function getKey(): string
|
||||||
|
{
|
||||||
|
if (self::$key === null) {
|
||||||
|
$hex = env('TOTP_ENCRYPTION_KEY', '');
|
||||||
|
if (strlen($hex) !== 64 || !ctype_xdigit($hex)) {
|
||||||
|
throw new RuntimeException('TOTP_ENCRYPTION_KEY must be 64 hex chars (32 bytes)');
|
||||||
|
}
|
||||||
|
self::$key = hex2bin($hex);
|
||||||
|
}
|
||||||
|
return self::$key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function encrypt(string $plaintext): string
|
||||||
|
{
|
||||||
|
$key = self::getKey();
|
||||||
|
$nonce = random_bytes(self::NONCE_LENGTH);
|
||||||
|
$tag = '';
|
||||||
|
|
||||||
|
$ciphertext = openssl_encrypt(
|
||||||
|
$plaintext,
|
||||||
|
self::CIPHER,
|
||||||
|
$key,
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$nonce,
|
||||||
|
$tag,
|
||||||
|
'',
|
||||||
|
self::TAG_LENGTH
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ciphertext === false) {
|
||||||
|
throw new RuntimeException('Encryption failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64_encode($nonce . $ciphertext . $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function decrypt(string $encoded): string
|
||||||
|
{
|
||||||
|
$key = self::getKey();
|
||||||
|
$raw = base64_decode($encoded, true);
|
||||||
|
|
||||||
|
if ($raw === false || strlen($raw) < self::NONCE_LENGTH + self::TAG_LENGTH + 1) {
|
||||||
|
throw new RuntimeException('Invalid encrypted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonce = substr($raw, 0, self::NONCE_LENGTH);
|
||||||
|
$tag = substr($raw, -self::TAG_LENGTH);
|
||||||
|
$ciphertext = substr($raw, self::NONCE_LENGTH, -self::TAG_LENGTH);
|
||||||
|
|
||||||
|
$plaintext = openssl_decrypt(
|
||||||
|
$ciphertext,
|
||||||
|
self::CIPHER,
|
||||||
|
$key,
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$nonce,
|
||||||
|
$tag
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($plaintext === false) {
|
||||||
|
throw new RuntimeException('Decryption failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zjisti, zda je hodnota sifrovana (base64 s ocekavanou delkou).
|
||||||
|
* TOTP secret je vzdy 16-32 ASCII znaku, sifrovany je base64 s nonce+tag.
|
||||||
|
*/
|
||||||
|
public static function isEncrypted(string $value): bool
|
||||||
|
{
|
||||||
|
if (strlen($value) < 40) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$decoded = base64_decode($value, true);
|
||||||
|
return $decoded !== false
|
||||||
|
&& strlen($decoded) > self::NONCE_LENGTH + self::TAG_LENGTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
663
api/includes/JWTAuth.php
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - JWT Authentication Handler
|
||||||
|
*
|
||||||
|
* Handles JWT access tokens and refresh tokens for stateless authentication.
|
||||||
|
* Access tokens: Short-lived (configurable, default 15 min), stored in memory on client
|
||||||
|
* Refresh tokens: Long-lived, stored in httpOnly cookie
|
||||||
|
*
|
||||||
|
* Without "remember me": Session cookie + 1 hour DB expiry (sliding window on activity)
|
||||||
|
* With "remember me": Persistent cookie + 30 day expiry
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
class JWTAuth
|
||||||
|
{
|
||||||
|
private const ALGORITHM = 'HS256';
|
||||||
|
|
||||||
|
// Cache for config values
|
||||||
|
private static ?int $accessTokenExpiry = null;
|
||||||
|
private static ?int $refreshTokenExpirySession = null;
|
||||||
|
private static ?int $refreshTokenExpiryDays = null;
|
||||||
|
|
||||||
|
private static ?string $secretKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the secret key from environment
|
||||||
|
*/
|
||||||
|
private static function getSecretKey(): string
|
||||||
|
{
|
||||||
|
if (self::$secretKey === null) {
|
||||||
|
self::$secretKey = env('JWT_SECRET');
|
||||||
|
if (empty(self::$secretKey)) {
|
||||||
|
throw new Exception('JWT_SECRET not configured in environment');
|
||||||
|
}
|
||||||
|
if (strlen(self::$secretKey) < 32) {
|
||||||
|
throw new Exception('JWT_SECRET must be at least 32 characters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::$secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get access token expiry in seconds (from env or default 900 = 15 min)
|
||||||
|
*/
|
||||||
|
public static function getAccessTokenExpiry(): int
|
||||||
|
{
|
||||||
|
if (self::$accessTokenExpiry === null) {
|
||||||
|
self::$accessTokenExpiry = (int) env('JWT_ACCESS_TOKEN_EXPIRY', 900);
|
||||||
|
}
|
||||||
|
return self::$accessTokenExpiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get refresh token session expiry in seconds (from env or default 3600 = 1 hour)
|
||||||
|
* Used when "remember me" is NOT checked
|
||||||
|
*/
|
||||||
|
private static function getRefreshTokenExpirySession(): int
|
||||||
|
{
|
||||||
|
if (self::$refreshTokenExpirySession === null) {
|
||||||
|
self::$refreshTokenExpirySession = (int) env('JWT_REFRESH_TOKEN_EXPIRY_SESSION', 3600);
|
||||||
|
}
|
||||||
|
return self::$refreshTokenExpirySession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get refresh token expiry in days (from env or default 30)
|
||||||
|
* Used when "remember me" IS checked
|
||||||
|
*/
|
||||||
|
private static function getRefreshTokenExpiryDays(): int
|
||||||
|
{
|
||||||
|
if (self::$refreshTokenExpiryDays === null) {
|
||||||
|
self::$refreshTokenExpiryDays = (int) env('JWT_REFRESH_TOKEN_EXPIRY_DAYS', 30);
|
||||||
|
}
|
||||||
|
return self::$refreshTokenExpiryDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an access token (short-lived, for API requests)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $userData
|
||||||
|
*/
|
||||||
|
public static function generateAccessToken(array $userData): string
|
||||||
|
{
|
||||||
|
$issuedAt = time();
|
||||||
|
$expiry = $issuedAt + self::getAccessTokenExpiry();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'iss' => 'boha-automation', // Issuer
|
||||||
|
'iat' => $issuedAt, // Issued at
|
||||||
|
'exp' => $expiry, // Expiry
|
||||||
|
'type' => 'access', // Token type
|
||||||
|
'sub' => $userData['id'], // Subject (user ID)
|
||||||
|
'user' => [
|
||||||
|
'id' => $userData['id'],
|
||||||
|
'username' => $userData['username'],
|
||||||
|
'email' => $userData['email'],
|
||||||
|
'full_name' => trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? '')),
|
||||||
|
'role' => $userData['role'] ?? null,
|
||||||
|
'role_display' => $userData['role_display'] ?? $userData['role'] ?? null,
|
||||||
|
'is_admin' => $userData['is_admin'] ?? ($userData['role'] === 'admin'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return JWT::encode($payload, self::getSecretKey(), self::ALGORITHM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a refresh token (stored in httpOnly cookie)
|
||||||
|
*
|
||||||
|
* @param int $userId User ID
|
||||||
|
* @param bool $remember If true: 30 day persistent cookie. If false: session cookie (1 hour DB expiry)
|
||||||
|
*/
|
||||||
|
public static function generateRefreshToken(int $userId, bool $remember = false): string
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(32)); // 64 character random string
|
||||||
|
$hashedToken = hash('sha256', $token);
|
||||||
|
|
||||||
|
// Calculate expiry based on remember me
|
||||||
|
if ($remember) {
|
||||||
|
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400); // 30 days default
|
||||||
|
$cookieExpiry = $dbExpiry; // Persistent cookie
|
||||||
|
} else {
|
||||||
|
$dbExpiry = time() + self::getRefreshTokenExpirySession(); // 1 hour default
|
||||||
|
$cookieExpiry = 0; // Session cookie (deleted on browser close)
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// Pročistit replaced tokeny (po grace period uz nepotřebné)
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL'
|
||||||
|
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
// Limit aktivních sessions per user (max 5 devices)
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$count = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($count >= 5) {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
DELETE FROM refresh_tokens
|
||||||
|
WHERE user_id = ? AND replaced_at IS NULL
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store new refresh token
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$userId,
|
||||||
|
$hashedToken,
|
||||||
|
$expiresAt,
|
||||||
|
getClientIp(),
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||||
|
$remember ? 1 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set httpOnly cookie
|
||||||
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||||
|
|
||||||
|
setcookie('refresh_token', $token, [
|
||||||
|
'expires' => $cookieExpiry,
|
||||||
|
'path' => '/api/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('JWTAuth refresh token error: ' . $e->getMessage());
|
||||||
|
throw new Exception('Failed to create refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify and decode an access token
|
||||||
|
*
|
||||||
|
* @return array{user_id: mixed, user: array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
public static function verifyAccessToken(string $token): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$decoded = JWT::decode($token, new Key(self::getSecretKey(), self::ALGORITHM));
|
||||||
|
$payload = (array) $decoded;
|
||||||
|
|
||||||
|
// Verify it's an access token
|
||||||
|
if (($payload['type'] ?? '') !== 'access') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => $payload['sub'],
|
||||||
|
'user' => (array) $payload['user'],
|
||||||
|
];
|
||||||
|
} catch (ExpiredException $e) {
|
||||||
|
// Token expired - client should use refresh token
|
||||||
|
return null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('JWT verification error: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify refresh token and return user data if valid
|
||||||
|
* Returns array with 'user' data and 'remember_me' flag
|
||||||
|
* Deletes expired tokens from database when found
|
||||||
|
*
|
||||||
|
* @return array{user: array<string, mixed>, remember_me: bool, in_grace_period?: bool}|null
|
||||||
|
*/
|
||||||
|
public static function verifyRefreshToken(?string $token = null): ?array
|
||||||
|
{
|
||||||
|
// Get token from cookie if not provided
|
||||||
|
if ($token === null) {
|
||||||
|
$token = $_COOKIE['refresh_token'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$hashedToken = hash('sha256', $token);
|
||||||
|
|
||||||
|
// First check if token exists (regardless of expiry)
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT rt.*, u.id as user_id, u.username, u.email, u.first_name, u.last_name,
|
||||||
|
u.is_active, r.name as role_name, r.display_name as role_display_name
|
||||||
|
FROM refresh_tokens rt
|
||||||
|
JOIN users u ON rt.user_id = u.id
|
||||||
|
LEFT JOIN roles r ON u.role_id = r.id
|
||||||
|
WHERE rt.token_hash = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$hashedToken]);
|
||||||
|
$data = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$data) {
|
||||||
|
self::clearRefreshCookie();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token byl rotovan - zkontrolovat grace period
|
||||||
|
if ($data['replaced_at'] !== null) {
|
||||||
|
$replacedAt = strtotime($data['replaced_at']);
|
||||||
|
if ((time() - $replacedAt) <= self::ROTATION_GRACE_PERIOD) {
|
||||||
|
// Grace period - token jeste plati (souběžny request)
|
||||||
|
if (!$data['is_active']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'user' => [
|
||||||
|
'id' => $data['user_id'],
|
||||||
|
'username' => $data['username'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'first_name' => $data['first_name'],
|
||||||
|
'last_name' => $data['last_name'],
|
||||||
|
'role' => $data['role_name'],
|
||||||
|
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
||||||
|
'is_admin' => $data['role_name'] === 'admin',
|
||||||
|
'permissions' => self::getUserPermissions($data['user_id']),
|
||||||
|
],
|
||||||
|
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
||||||
|
'in_grace_period' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Po grace period - stary token uz neni platny, smazat jen tento token
|
||||||
|
$uid = $data['user_id'];
|
||||||
|
error_log("Refresh token reuse after grace period for user {$uid}");
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||||
|
$stmt->execute([$hashedToken]);
|
||||||
|
self::clearRefreshCookie();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if (strtotime($data['expires_at']) < time()) {
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||||
|
$stmt->execute([$hashedToken]);
|
||||||
|
self::clearRefreshCookie();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user is still active
|
||||||
|
if (!$data['is_active']) {
|
||||||
|
self::revokeRefreshToken($token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user' => [
|
||||||
|
'id' => $data['user_id'],
|
||||||
|
'username' => $data['username'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'first_name' => $data['first_name'],
|
||||||
|
'last_name' => $data['last_name'],
|
||||||
|
'role' => $data['role_name'],
|
||||||
|
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
||||||
|
'is_admin' => $data['role_name'] === 'admin',
|
||||||
|
'permissions' => self::getUserPermissions($data['user_id']),
|
||||||
|
],
|
||||||
|
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
||||||
|
];
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('JWTAuth verify refresh error: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grace period pro rotovane tokeny (sekundy) */
|
||||||
|
private const ROTATION_GRACE_PERIOD = 30;
|
||||||
|
|
||||||
|
public static function getGracePeriod(): int
|
||||||
|
{
|
||||||
|
return self::ROTATION_GRACE_PERIOD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh tokens - issue new access token + rotate refresh token
|
||||||
|
* Grace period 30s pro souběžné requesty
|
||||||
|
*
|
||||||
|
* @return array{access_token: string, user: array<string, mixed>, expires_in: int}|null
|
||||||
|
*/
|
||||||
|
public static function refreshTokens(): ?array
|
||||||
|
{
|
||||||
|
$token = $_COOKIE['refresh_token'] ?? null;
|
||||||
|
|
||||||
|
$tokenData = self::verifyRefreshToken($token);
|
||||||
|
if (!$tokenData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userData = $tokenData['user'];
|
||||||
|
$accessToken = self::generateAccessToken($userData);
|
||||||
|
|
||||||
|
// Rotace: pokud token nebyl jiz nahrazen (grace period request), rotovat
|
||||||
|
if (!($tokenData['in_grace_period'] ?? false)) {
|
||||||
|
self::rotateRefreshToken(
|
||||||
|
$token,
|
||||||
|
$userData['id'],
|
||||||
|
(bool) $tokenData['remember_me']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'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' => $userData['permissions'] ?? self::getUserPermissions($userData['id']),
|
||||||
|
],
|
||||||
|
'expires_in' => self::getAccessTokenExpiry(),
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('JWTAuth refresh error: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotace refresh tokenu - vygeneruje novy, stary oznaci jako replaced
|
||||||
|
*/
|
||||||
|
private static function rotateRefreshToken(string $oldToken, int $userId, bool $remember): void
|
||||||
|
{
|
||||||
|
$pdo = db();
|
||||||
|
$oldHash = hash('sha256', $oldToken);
|
||||||
|
|
||||||
|
$newToken = bin2hex(random_bytes(32));
|
||||||
|
$newHash = hash('sha256', $newToken);
|
||||||
|
|
||||||
|
if ($remember) {
|
||||||
|
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400);
|
||||||
|
$cookieExpiry = $dbExpiry;
|
||||||
|
} else {
|
||||||
|
$dbExpiry = time() + self::getRefreshTokenExpirySession();
|
||||||
|
$cookieExpiry = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
||||||
|
|
||||||
|
// Oznacit stary token jako replaced (atomicky - race condition ochrana)
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE refresh_tokens SET replaced_at = NOW(), replaced_by_hash = ?
|
||||||
|
WHERE token_hash = ? AND replaced_at IS NULL
|
||||||
|
');
|
||||||
|
$stmt->execute([$newHash, $oldHash]);
|
||||||
|
|
||||||
|
// Jiny request uz token rotoval - nepokracovat
|
||||||
|
if ($stmt->rowCount() === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procistit drive replaced tokeny (az po uspesne rotaci, respektovat grace period)
|
||||||
|
$pdo->prepare(
|
||||||
|
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL AND token_hash != ?'
|
||||||
|
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
||||||
|
)->execute([$userId, $oldHash]);
|
||||||
|
|
||||||
|
// Vlozit novy token
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$userId,
|
||||||
|
$newHash,
|
||||||
|
$expiresAt,
|
||||||
|
getClientIp(),
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||||
|
$remember ? 1 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Novy cookie
|
||||||
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||||
|
setcookie('refresh_token', $newToken, [
|
||||||
|
'expires' => $cookieExpiry,
|
||||||
|
'path' => '/api/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific refresh token
|
||||||
|
*/
|
||||||
|
public static function revokeRefreshToken(string $token): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$hashedToken = hash('sha256', $token);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||||
|
$stmt->execute([$hashedToken]);
|
||||||
|
|
||||||
|
self::clearRefreshCookie();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('JWTAuth revoke error: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all refresh tokens for a user (logout from all devices)
|
||||||
|
*/
|
||||||
|
public static function revokeAllUserTokens(int $userId): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
self::clearRefreshCookie();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('JWTAuth revoke all error: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the refresh token cookie
|
||||||
|
*/
|
||||||
|
private static function clearRefreshCookie(): void
|
||||||
|
{
|
||||||
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||||
|
setcookie('refresh_token', '', [
|
||||||
|
'expires' => time() - 3600,
|
||||||
|
'path' => '/api/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
unset($_COOKIE['refresh_token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get access token from Authorization header
|
||||||
|
*/
|
||||||
|
public static function getTokenFromHeader(): ?string
|
||||||
|
{
|
||||||
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
|
||||||
|
if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: Require valid access token
|
||||||
|
* Also verifies refresh token still exists in database (session not revoked)
|
||||||
|
* Extends session expiry only when less than 50% of time remaining (smart extend)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function requireAuth(): array
|
||||||
|
{
|
||||||
|
$token = self::getTokenFromHeader();
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
errorResponse('Access token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = self::verifyAccessToken($token);
|
||||||
|
|
||||||
|
if (!$payload) {
|
||||||
|
errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify refresh token exists + smart extend in a single query
|
||||||
|
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||||
|
if ($refreshToken) {
|
||||||
|
$hashedToken = hash('sha256', $refreshToken);
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
// Verify session - tolerovat replaced tokeny v grace period
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT id, remember_me, expires_at, replaced_at
|
||||||
|
FROM refresh_tokens
|
||||||
|
WHERE token_hash = ? AND expires_at > NOW()
|
||||||
|
');
|
||||||
|
$stmt->execute([$hashedToken]);
|
||||||
|
$tokenData = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$tokenData) {
|
||||||
|
self::clearRefreshCookie();
|
||||||
|
errorResponse('Session revoked', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaced token v grace period - jen validovat, neextendovat
|
||||||
|
if ($tokenData['replaced_at'] !== null) {
|
||||||
|
$replacedAt = strtotime($tokenData['replaced_at']);
|
||||||
|
if ((time() - $replacedAt) > self::ROTATION_GRACE_PERIOD) {
|
||||||
|
self::clearRefreshCookie();
|
||||||
|
errorResponse('Session revoked', 401);
|
||||||
|
}
|
||||||
|
// V grace period - skip extend, access token jeste plati
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart extend: only UPDATE when less than 50% of session time remaining
|
||||||
|
$expiresAt = strtotime($tokenData['expires_at']);
|
||||||
|
$now = time();
|
||||||
|
$remaining = $expiresAt - $now;
|
||||||
|
|
||||||
|
if ($tokenData['remember_me']) {
|
||||||
|
$totalWindow = self::getRefreshTokenExpiryDays() * 86400;
|
||||||
|
} else {
|
||||||
|
$totalWindow = self::getRefreshTokenExpirySession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only extend if less than 50% remaining
|
||||||
|
if ($remaining < ($totalWindow * 0.5)) {
|
||||||
|
$newExpiry = date('Y-m-d H:i:s', $now + $totalWindow);
|
||||||
|
$stmt = $pdo->prepare('UPDATE refresh_tokens SET expires_at = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$newExpiry, $tokenData['id']]);
|
||||||
|
|
||||||
|
// Refresh cookie expiry for remember-me sessions
|
||||||
|
if ($tokenData['remember_me']) {
|
||||||
|
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||||
|
setcookie('refresh_token', $refreshToken, [
|
||||||
|
'expires' => $now + $totalWindow,
|
||||||
|
'path' => '/api/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('JWTAuth session check error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: Optional auth - returns user data if valid token, null otherwise
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function optionalAuth(): ?array
|
||||||
|
{
|
||||||
|
$token = self::getTokenFromHeader();
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::verifyAccessToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permission names for a user
|
||||||
|
* Admin role returns all permissions.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function getUserPermissions(int $userId): array
|
||||||
|
{
|
||||||
|
return getUserPermissions($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup expired and replaced refresh tokens
|
||||||
|
*/
|
||||||
|
public static function cleanupExpiredTokens(): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'DELETE FROM refresh_tokens WHERE expires_at < NOW()'
|
||||||
|
. ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL '
|
||||||
|
. self::ROTATION_GRACE_PERIOD . ' SECOND))'
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
return $stmt->rowCount();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('JWTAuth cleanup error: ' . $e->getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
api/includes/LeaveNotification.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Leave Request Email Notifications
|
||||||
|
*
|
||||||
|
* Sends email notifications when leave requests are created.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/Mailer.php';
|
||||||
|
|
||||||
|
class LeaveNotification
|
||||||
|
{
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private static array $leaveTypeLabels = [
|
||||||
|
'vacation' => 'Dovolená',
|
||||||
|
'sick' => 'Nemocenská',
|
||||||
|
'unpaid' => 'Neplacené volno',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification about a new leave request
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $request
|
||||||
|
*/
|
||||||
|
public static function notifyNewRequest(array $request, string $employeeName): void
|
||||||
|
{
|
||||||
|
$notifyEmail = env('LEAVE_NOTIFY_EMAIL', '');
|
||||||
|
if (!$notifyEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaveType = self::$leaveTypeLabels[$request['leave_type']] ?? $request['leave_type'];
|
||||||
|
$dateFrom = date('d.m.Y', strtotime($request['date_from']));
|
||||||
|
$dateTo = date('d.m.Y', strtotime($request['date_to']));
|
||||||
|
$notes = $request['notes'] ?? '';
|
||||||
|
|
||||||
|
$subject = "Nová žádost o nepřítomnost - $employeeName ($leaveType)";
|
||||||
|
|
||||||
|
$html = "
|
||||||
|
<html>
|
||||||
|
<body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>
|
||||||
|
<h2 style='color: #de3a3a;'>Nová žádost o nepřítomnost</h2>
|
||||||
|
<table style='width: 100%; border-collapse: collapse; margin: 20px 0;'>
|
||||||
|
<tr>
|
||||||
|
<td style='padding: 10px; background: #f5f5f5; font-weight: bold; width: 180px;'>
|
||||||
|
Zaměstnanec:</td>
|
||||||
|
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>"
|
||||||
|
. htmlspecialchars($employeeName) . "</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Typ:</td>
|
||||||
|
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($leaveType) . "</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Období:</td>
|
||||||
|
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>$dateFrom – $dateTo</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Pracovní dny:</td>
|
||||||
|
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>"
|
||||||
|
. "{$request['total_days']} dní ({$request['total_hours']} hodin)</td>
|
||||||
|
</tr>"
|
||||||
|
. ($notes ? "
|
||||||
|
<tr>
|
||||||
|
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Poznámka:</td>
|
||||||
|
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($notes) . '</td>
|
||||||
|
</tr>' : '') . "
|
||||||
|
</table>
|
||||||
|
<p style='margin-top: 20px;'>
|
||||||
|
<a href='https://www.boha-automation.cz/boha/leave-approval'
|
||||||
|
style='background: #de3a3a; color: #fff; padding: 10px 20px;
|
||||||
|
text-decoration: none; border-radius: 5px;'>
|
||||||
|
Přejít ke schvalování
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style='margin: 30px 0; border: none; border-top: 1px solid #ddd;'>
|
||||||
|
<p style='font-size: 12px; color: #999;'>
|
||||||
|
Tato zpráva byla automaticky vygenerována systémem BOHA Automation.<br>
|
||||||
|
Datum: " . date('d.m.Y H:i:s') . '
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>';
|
||||||
|
|
||||||
|
$sent = Mailer::send($notifyEmail, $subject, $html);
|
||||||
|
if (!$sent) {
|
||||||
|
error_log("LeaveNotification: Failed to send new request notification to $notifyEmail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
api/includes/Mailer.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - Email Helper
|
||||||
|
*
|
||||||
|
* Sends emails via PHP mail() function.
|
||||||
|
* Configuration via .env variables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Mailer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send an email
|
||||||
|
*
|
||||||
|
* @param string $to Recipient email address
|
||||||
|
* @param string $subject Email subject (plain text, will be UTF-8 encoded)
|
||||||
|
* @param string $htmlBody HTML email body
|
||||||
|
* @param string|null $replyTo Optional reply-to address
|
||||||
|
* @return bool True if sent successfully
|
||||||
|
*/
|
||||||
|
public static function send(string $to, string $subject, string $htmlBody, ?string $replyTo = null): bool
|
||||||
|
{
|
||||||
|
$fromEmail = env('SMTP_FROM_EMAIL', env('CONTACT_EMAIL_FROM', 'web@boha-automation.cz'));
|
||||||
|
$fromName = env('SMTP_FROM_NAME', 'BOHA Automation');
|
||||||
|
|
||||||
|
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||||
|
|
||||||
|
$headers = "MIME-Version: 1.0\r\n";
|
||||||
|
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
|
||||||
|
$headers .= "From: $fromName <$fromEmail>\r\n";
|
||||||
|
if ($replyTo) {
|
||||||
|
$headers .= "Reply-To: $replyTo\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sent = mail($to, $encodedSubject, $htmlBody, $headers);
|
||||||
|
|
||||||
|
if (!$sent) {
|
||||||
|
error_log("Mailer error: mail() failed for recipient $to");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
api/includes/RateLimiter.php
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOHA Automation - IP-based Rate Limiter
|
||||||
|
*
|
||||||
|
* Implements rate limiting using file-based storage to prevent abuse
|
||||||
|
* and protect API endpoints from excessive requests.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - IP-based rate limiting
|
||||||
|
* - Configurable limits per endpoint
|
||||||
|
* - File-based storage (no database dependency)
|
||||||
|
* - Automatic cleanup of expired entries
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class RateLimiter
|
||||||
|
{
|
||||||
|
/** @var string Directory for storing rate limit data */
|
||||||
|
private string $storagePath;
|
||||||
|
|
||||||
|
/** @var int Default requests per minute */
|
||||||
|
private int $defaultLimit = 60;
|
||||||
|
|
||||||
|
/** @var int Time window in seconds (1 minute) */
|
||||||
|
private int $windowSeconds = 60;
|
||||||
|
|
||||||
|
/** @var bool Whether storage directory has been verified */
|
||||||
|
private static bool $dirVerified = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the rate limiter
|
||||||
|
*
|
||||||
|
* @param string|null $storagePath Path to store rate limit files
|
||||||
|
*/
|
||||||
|
public function __construct(?string $storagePath = null)
|
||||||
|
{
|
||||||
|
$this->storagePath = $storagePath
|
||||||
|
?? (defined('RATE_LIMIT_STORAGE_PATH')
|
||||||
|
? RATE_LIMIT_STORAGE_PATH
|
||||||
|
: __DIR__ . '/../rate_limits');
|
||||||
|
|
||||||
|
// Only check directory once per process (static flag)
|
||||||
|
if (!self::$dirVerified) {
|
||||||
|
if (!is_dir($this->storagePath)) {
|
||||||
|
mkdir($this->storagePath, 0755, true);
|
||||||
|
}
|
||||||
|
self::$dirVerified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old files very rarely (0.1% of requests instead of 1%)
|
||||||
|
if (rand(1, 1000) === 1) {
|
||||||
|
$this->cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the request should be rate limited
|
||||||
|
*
|
||||||
|
* Uses exclusive file locking for the entire read-check-increment-write cycle
|
||||||
|
* to prevent race conditions under concurrent requests.
|
||||||
|
*
|
||||||
|
* @param string $endpoint Endpoint identifier (e.g., 'login', 'session')
|
||||||
|
* @param int|null $limit Custom limit for this endpoint (requests per minute)
|
||||||
|
* @return bool True if request is allowed, false if rate limited
|
||||||
|
*/
|
||||||
|
/** @var bool Fail-closed: blokuj request pri chybe FS (pro kriticke endpointy) */
|
||||||
|
private bool $failClosed = false;
|
||||||
|
|
||||||
|
public function setFailClosed(bool $failClosed = true): self
|
||||||
|
{
|
||||||
|
$this->failClosed = $failClosed;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function check(string $endpoint, ?int $limit = null): bool
|
||||||
|
{
|
||||||
|
$limit = $limit ?? $this->defaultLimit;
|
||||||
|
$ip = $this->getClientIp();
|
||||||
|
$key = $this->getKey($ip, $endpoint);
|
||||||
|
$file = $this->storagePath . '/' . $key . '.json';
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// Open file with exclusive lock for atomic read-check-increment-write
|
||||||
|
$fp = @fopen($file, 'c+');
|
||||||
|
if (!$fp) {
|
||||||
|
return !$this->failClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flock($fp, LOCK_EX)) {
|
||||||
|
fclose($fp);
|
||||||
|
return !$this->failClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current data under lock
|
||||||
|
$content = stream_get_contents($fp);
|
||||||
|
$data = $content ? json_decode($content, true) : null;
|
||||||
|
|
||||||
|
if (is_array($data) && $data['window_start'] > ($now - $this->windowSeconds)) {
|
||||||
|
// Same window - check count
|
||||||
|
if ($data['count'] >= $limit) {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
return false; // Rate limited
|
||||||
|
}
|
||||||
|
$data['count']++;
|
||||||
|
} else {
|
||||||
|
// New window - reset counter
|
||||||
|
$data = ['window_start' => $now, 'count' => 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated data
|
||||||
|
ftruncate($fp, 0);
|
||||||
|
rewind($fp);
|
||||||
|
fwrite($fp, json_encode($data));
|
||||||
|
fflush($fp);
|
||||||
|
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce rate limit and return 429 response if exceeded
|
||||||
|
*
|
||||||
|
* @param string $endpoint Endpoint identifier
|
||||||
|
* @param int|null $limit Custom limit for this endpoint
|
||||||
|
*/
|
||||||
|
public function enforce(string $endpoint, ?int $limit = null): void
|
||||||
|
{
|
||||||
|
if (!$this->check($endpoint, $limit)) {
|
||||||
|
$this->sendRateLimitResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 429 Too Many Requests response and exit
|
||||||
|
*/
|
||||||
|
private function sendRateLimitResponse(): void
|
||||||
|
{
|
||||||
|
http_response_code(429);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Retry-After: ' . $this->windowSeconds);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Prilis mnoho pozadavku. Zkuste to prosim pozdeji.',
|
||||||
|
'retry_after' => $this->windowSeconds,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address
|
||||||
|
*
|
||||||
|
* @return string IP address
|
||||||
|
*/
|
||||||
|
private function getClientIp(): string
|
||||||
|
{
|
||||||
|
// Use the global helper if available
|
||||||
|
if (function_exists('getClientIp')) {
|
||||||
|
return getClientIp();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use only REMOTE_ADDR (cannot be spoofed)
|
||||||
|
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate storage key for IP and endpoint
|
||||||
|
*
|
||||||
|
* @param string $ip Client IP
|
||||||
|
* @param string $endpoint Endpoint identifier
|
||||||
|
* @return string Storage key (filename-safe)
|
||||||
|
*/
|
||||||
|
private function getKey(string $ip, string $endpoint): string
|
||||||
|
{
|
||||||
|
// Create a safe filename from IP and endpoint
|
||||||
|
return md5($ip . ':' . $endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup expired rate limit files
|
||||||
|
*
|
||||||
|
* Removes files older than the time window to prevent disk space issues
|
||||||
|
*/
|
||||||
|
private function cleanup(): void
|
||||||
|
{
|
||||||
|
if (!is_dir($this->storagePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = glob($this->storagePath . '/*.json');
|
||||||
|
$expireTime = time() - ($this->windowSeconds * 2); // Keep for 2x window to be safe
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (filemtime($file) < $expireTime) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all rate limit data (useful for testing)
|
||||||
|
*/
|
||||||
|
public function clearAll(): void
|
||||||
|
{
|
||||||
|
if (!is_dir($this->storagePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = glob($this->storagePath . '/*.json');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
api/rate_limits/.htaccess
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Deny from all
|
||||||
20
composer.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "boha/website",
|
||||||
|
"description": "BOHA Automation Website",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"firebase/php-jwt": "^6.11",
|
||||||
|
"tecnickcom/tcpdf": "^6.7",
|
||||||
|
"robthree/twofactorauth": "^3.0",
|
||||||
|
"chillerlan/php-qrcode": "^5.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
|
"squizlabs/php_codesniffer": "^4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3198
composer.lock
generated
Normal file
BIN
composer.phar
Normal file
33
deploy.sh
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIST_DIR="D:/Weby/BOHA Website/New/dist"
|
||||||
|
HTDOCS_DIR="/c/Apache24/htdocs"
|
||||||
|
|
||||||
|
echo "=== BOHA Automation Deploy ==="
|
||||||
|
|
||||||
|
# 1. Build
|
||||||
|
echo "[1/4] Building..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 2. Verify build
|
||||||
|
if [ ! -f "$DIST_DIR/index.html" ] || [ ! -d "$DIST_DIR/api" ]; then
|
||||||
|
echo "ERROR: Build incomplete - missing index.html or api/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[2/4] Build verified OK"
|
||||||
|
|
||||||
|
# 3. Deploy
|
||||||
|
echo "[3/4] Deploying to Apache..."
|
||||||
|
rm -rf "$HTDOCS_DIR"/*
|
||||||
|
cp -r "$DIST_DIR"/* "$HTDOCS_DIR"/
|
||||||
|
|
||||||
|
# 4. Health check
|
||||||
|
echo "[4/4] Health check..."
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/ 2>/dev/null || echo "000")
|
||||||
|
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then
|
||||||
|
echo "=== Deploy OK (HTTP $HTTP_CODE) ==="
|
||||||
|
else
|
||||||
|
echo "WARNING: Health check returned HTTP $HTTP_CODE (Apache may not be running)"
|
||||||
|
fi
|
||||||
2621
docs/AUTH_SYSTEM.md
Normal file
48
eslint.config.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist/', 'api/', 'vendor/', 'node_modules/'] },
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
'prefer-const': 'warn',
|
||||||
|
'no-var': 'error',
|
||||||
|
'eqeqeq': 'error',
|
||||||
|
'curly': ['error', 'multi-line'],
|
||||||
|
'no-duplicate-imports': 'error',
|
||||||
|
'no-template-curly-in-string': 'warn',
|
||||||
|
'no-else-return': 'warn',
|
||||||
|
'no-lonely-if': 'warn',
|
||||||
|
'prefer-template': 'warn',
|
||||||
|
'no-nested-ternary': 'error',
|
||||||
|
'no-unneeded-ternary': 'warn'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/context/*.{js,jsx}', 'src/admin/context/*.{js,jsx}'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'off'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
1519
example_design/boha-spa.html
Normal file
39
index.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cs" data-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
|
||||||
|
<title>BOHA Interni system</title>
|
||||||
|
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="BOHA System" />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#12121a" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Urbanist:wght@400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color: var(--bg-primary, #12121a);">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
3095
package-lock.json
generated
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "boha-system",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "BOHA Automation admin system",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"deploy": "bash deploy.sh",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
|
"framer-motion": "^12.23.25",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-datepicker": "^9.1.0",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-quill-new": "^3.8.3",
|
||||||
|
"react-router-dom": "^6.30.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"eslint": "^10.0.2",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
phpcs.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="BOHA">
|
||||||
|
<description>BOHA Automation coding standard</description>
|
||||||
|
|
||||||
|
<rule ref="PSR12"/>
|
||||||
|
|
||||||
|
<!-- Třídy bez autoloaderu nemají namespace -->
|
||||||
|
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace">
|
||||||
|
<severity>0</severity>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<!-- API soubory mají routing + funkce v jednom souboru -->
|
||||||
|
<rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
|
||||||
|
<type>warning</type>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<file>api/</file>
|
||||||
|
|
||||||
|
<exclude-pattern>vendor/</exclude-pattern>
|
||||||
|
<exclude-pattern>node_modules/</exclude-pattern>
|
||||||
|
|
||||||
|
<arg name="colors"/>
|
||||||
|
<arg value="sp"/>
|
||||||
|
</ruleset>
|
||||||
6
phpstan.neon
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
parameters:
|
||||||
|
level: 6
|
||||||
|
paths:
|
||||||
|
- api
|
||||||
|
excludePaths:
|
||||||
|
- vendor
|
||||||
81
public/.htaccess
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<FilesMatch "^\.env">
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<FilesMatch "\.(log|sql|bak|backup|db|ini)$">
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
AddDefaultCharset UTF-8
|
||||||
|
<IfModule mod_mime.c>
|
||||||
|
AddCharset UTF-8 .html .css .js .json .xml .txt
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Header set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
Header set Permissions-Policy "camera=(), microphone=(), geolocation=(self)"
|
||||||
|
Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# Force HTTPS
|
||||||
|
RewriteCond %{HTTPS} off
|
||||||
|
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
RewriteRule ^api/ - [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# All SPA routes go through router.php
|
||||||
|
RewriteRule ^ /router.php [L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/plain text/html text/xml text/css
|
||||||
|
AddOutputFilterByType DEFLATE application/xml application/xhtml+xml application/rss+xml
|
||||||
|
AddOutputFilterByType DEFLATE application/javascript application/x-javascript application/json
|
||||||
|
AddOutputFilterByType DEFLATE image/svg+xml application/font-woff2
|
||||||
|
SetEnvIfNoCase Request_URI "\.(jpg|jpeg|png|gif|webp|zip|gz|br|woff2)$" no-gzip
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive On
|
||||||
|
ExpiresByType image/jpg "access plus 1 year"
|
||||||
|
ExpiresByType image/jpeg "access plus 1 year"
|
||||||
|
ExpiresByType image/gif "access plus 1 year"
|
||||||
|
ExpiresByType image/png "access plus 1 year"
|
||||||
|
ExpiresByType image/svg+xml "access plus 1 year"
|
||||||
|
ExpiresByType text/css "access plus 1 year"
|
||||||
|
ExpiresByType application/javascript "access plus 1 year"
|
||||||
|
ExpiresByType text/javascript "access plus 1 year"
|
||||||
|
ExpiresByType application/font-woff2 "access plus 1 year"
|
||||||
|
ExpiresByType text/html "access plus 0 seconds"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<FilesMatch "index\.html$">
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
|
Header set Pragma "no-cache"
|
||||||
|
Header set Expires "0"
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<FilesMatch "\.php$">
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
Header set Pragma "no-cache"
|
||||||
|
Header set Expires "0"
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100" viewBox="0 0 100 100"><image width="100" height="100" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAMAAABHPGVmAAAC8VBMVEUAAAAAAAAAAAAAAAAAAAAAAAAAABoODg4NDQ0NDQ0MDAwJCRMJCRIJCRIJCREKCg4JCQ4JCQ4JCQ0JCQ8JCQ8JCQ4LCw4LCw4LCw8LCw8KCg8KCg8JCQ8JCQ8KChAKChAKCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8KChAKChAKCg8KCg8KCg8KCg8KCg8KCg4KChAKCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8KCg8LCg8MCxANCxAOCxAPDBESDRESDRITDREUDhIVDREWDhMXDRIXDhMYDhMZDxQaDxQfERYiERUkExcmExcpFRkrFRktFhotFxszGh41Gh48Gh49HSFDICNIHCBNGh1OGh1OGx5OHB9PHB9PHSBPHiFQHiFQHyJRHyJRICNSISRTIyZYIiVeIiVeIyZfJytmJShmLC9oLjFuLTByLTB2LzJ+MTR+NjmCODuDLzGDMTOGLzGKMzaLNjmLOz2MPD6NOjySOTuZNzmdQEKeODqkRUamRkeoPD6rQUOrRUetSEq5PT67QEG9TU7DSUrEUFHFUFHGT1DIRETMU1TNTk/SRkfSTE3TUVLVRETVRUXWRkbXRkbYT1DZRkbZSkrZT1DZUFHaTU3aUVLbTk7dR0ffPDzgPDzgPT3gPj7gWVrhPj7hPz/iQEDiTE3iT0/iVFTjQUHjQkLjUFDjUVHjVlbkQkLkQ0PkRETkUVHlRETlRUXlVVXmRUXmRkbmR0fmSkrnR0fnSEjoSEjoSUnoSkrpSkrpS0vqS0vqTEzqTU3qUlLrTU3rTk7sTk7sT0/sVFTtUFDtUVHtUlLuUVHuUlLuXF3vU1PvVFTvVVXvYWHwVFTwVVXwWFjxVlbxV1fxWFjxXl7yV1fyWFjyWlrzWFjzWVnzWlr0Wlr0W1v1W1v1XFz1XV32XV32Xl73Xl73X1/3YGD4YGD4YWH4ZGT5YWH5YmL5Y2P6Y2P6ZGT7ZWX7Zmb8Zmb////PKelcAAAAPnRSTlMAAgMEBQYKEhMUFRscHR41Njg5VVdYWlt3eHqGh4iTlJWXmJmbq62ur7O0tba70dLU1eHi4+Tq6+zt7vj5+kGlJc4AAAABYktHRPrVbQZKAAADp0lEQVRo3u3aV1ATURQG4I0gqKgUQRGCFAELFlQUA3Et2MXeu2Lvvffee+9dEbGjoNgQjKIoFlARFTQQsYAFQX0z27J3d+PozJ6bByf/4335ZpObc2ZzDkGgKeLg4lsrqAEpIw2C/H1d7BXEn1LKrS4JlABlSaNEiYpqEjBqz+JSo6yKBE59JxFh6U1iiJclalhVI7HEzwp5DkwGSVYvakC8SWzx5IxyJMY4MkaxQJyIirnJFUms8aCMkmq8iLq0HnEjMUdJEIoA3EjdIoQDiT12hAt+pDxRCT/iQ/jjR2oSKvxIIKHGjwQTpAliRsyIGQFBmrSWpk1oS1ik76HDR8LDj0dEnDodFRUdczn2yrXrcXHxV3etmNGnIRhiMM4jRsItTWLi3fUjoZAjRw1GtNBISnowvykMghgxsbFCI/nRksYgiMi4ITAep0yHQPpJjPhbmtsGI+VOFwCkvxFDwxupaTNBEMq4IDLuGYy0zRCIyEhgjQeskf6yOQCCGgdHjRo9euy0TaiR0RkCQZ5jK3PW4YTeeMwamZ3kIwOoYsJ9VtvYw5W08Zw2XjWTjwykiwll3IxP2M6chexmjfSMTO1qgC9+IG1cow3NjlB92vdcyBtvtZMgENaI4+9VMm9kak+2AkDCKIMrWFIjawREWQlDjESpMRmkCocJjCSRsbQxECI0HrHGS8rI1m3sCoJIjBTEyMk51w0AGRQnaiBC48PHLS3kI4OlRhpqfMydC4CgxsPIyDNnL97XFxPeyHvSVj6CPsc+5qzjxBdaynhPGZ+/jJOPoM12H3c6K1v3jjPyF8hGhqDN1oCMoYwPjJG/CgBBmu0B7nSejjZyKaNgg3wEbbYs0m7CG874ll9QuFY2MpQ16Eb4dOeevfv3X9LpeON7QeFy+YjBYBqhVn+vhMaPKfIRsZElNn70kI0M4410kfGFMdaQAEgy2mylxs/eAIiwgWSLjV9zAKrwMLGRIzQWNwJAhkuNT7l5nPF16t9f6f4FMWZ8Zoxni7rDvM4NTxU1Kcp4fWzdstnje4WY/ywwI2bEjPy/iEkGAiYZbZhkSGOScZMJBmfOhD1+xJZQ1MFt1NavfihxI64mGDAH21BjbE+8SAV6Hm+Ndehfn11fccKJlOGWJLzwGe78nlJlXEZVC2Q5xg+PUcVKsOaD5Yq5W4i2iRzB71i9MtK1qGIewaC/wQrWRje8bJRgdayOq80fd9UUduV9aqpk1Rm1qoaPs61wHe43Ak+61pq2TY0AAAAASUVORK5CYII="></image><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/images/logo-dark.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/logo-light.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
13
public/router.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SPA Router - serves index.html for all admin routes
|
||||||
|
*/
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
|
||||||
|
$indexPath = __DIR__ . '/index.html';
|
||||||
|
if (file_exists($indexPath)) {
|
||||||
|
readfile($indexPath);
|
||||||
|
} else {
|
||||||
|
echo '<!DOCTYPE html><html><body><h1>Application not found</h1></body></html>';
|
||||||
|
}
|
||||||
21
public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "BOHA",
|
||||||
|
"short_name": "BOHA",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
35
sql/migrate_encrypt_totp_secrets.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jednorázová migrace: zašifruje existující TOTP secrets v DB.
|
||||||
|
*
|
||||||
|
* Spuštění: php sql/migrate_encrypt_totp_secrets.php
|
||||||
|
* Vyžaduje TOTP_ENCRYPTION_KEY v .env
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../api/config.php';
|
||||||
|
require_once __DIR__ . '/../api/includes/Encryption.php';
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->query('SELECT id, totp_secret FROM users WHERE totp_secret IS NOT NULL');
|
||||||
|
$users = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$migrated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if (Encryption::isEncrypted($user['totp_secret'])) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted = Encryption::encrypt($user['totp_secret']);
|
||||||
|
$update = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?');
|
||||||
|
$update->execute([$encrypted, $user['id']]);
|
||||||
|
$migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Migrace dokoncena: {$migrated} zasifrovano, {$skipped} preskoceno (jiz sifrovane).\n";
|
||||||
27
src/App.jsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Suspense } from 'react'
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import AdminApp from './admin/AdminApp'
|
||||||
|
|
||||||
|
function AdminLoader() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-primary)'
|
||||||
|
}}>
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<AdminLoader />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/*" element={<AdminApp />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/admin/AdminApp.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import { AlertProvider } from './context/AlertContext'
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
|
import AdminLayout from './components/AdminLayout'
|
||||||
|
import AlertContainer from './components/AlertContainer'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import './admin.css'
|
||||||
|
import './login.css'
|
||||||
|
import './dashboard.css'
|
||||||
|
import './attendance.css'
|
||||||
|
import './leave.css'
|
||||||
|
import './orders.css'
|
||||||
|
import './projects.css'
|
||||||
|
import './settings.css'
|
||||||
|
import './offers.css'
|
||||||
|
import './invoices.css'
|
||||||
|
|
||||||
|
const Users = lazy(() => import('./pages/Users'))
|
||||||
|
const Attendance = lazy(() => import('./pages/Attendance'))
|
||||||
|
const AttendanceHistory = lazy(() => import('./pages/AttendanceHistory'))
|
||||||
|
const AttendanceAdmin = lazy(() => import('./pages/AttendanceAdmin'))
|
||||||
|
const AttendanceBalances = lazy(() => import('./pages/AttendanceBalances'))
|
||||||
|
const AttendanceCreate = lazy(() => import('./pages/AttendanceCreate'))
|
||||||
|
const LeaveRequests = lazy(() => import('./pages/LeaveRequests'))
|
||||||
|
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
||||||
|
const AttendanceLocation = lazy(() => import('./pages/AttendanceLocation'))
|
||||||
|
const Trips = lazy(() => import('./pages/Trips'))
|
||||||
|
const TripsHistory = lazy(() => import('./pages/TripsHistory'))
|
||||||
|
const TripsAdmin = lazy(() => import('./pages/TripsAdmin'))
|
||||||
|
const Vehicles = lazy(() => import('./pages/Vehicles'))
|
||||||
|
const Offers = lazy(() => import('./pages/Offers'))
|
||||||
|
const OfferDetail = lazy(() => import('./pages/OfferDetail'))
|
||||||
|
const OffersCustomers = lazy(() => import('./pages/OffersCustomers'))
|
||||||
|
const OffersTemplates = lazy(() => import('./pages/OffersTemplates'))
|
||||||
|
const CompanySettings = lazy(() => import('./pages/CompanySettings'))
|
||||||
|
const Orders = lazy(() => import('./pages/Orders'))
|
||||||
|
const OrderDetail = lazy(() => import('./pages/OrderDetail'))
|
||||||
|
const Projects = lazy(() => import('./pages/Projects'))
|
||||||
|
const ProjectCreate = lazy(() => import('./pages/ProjectCreate'))
|
||||||
|
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
|
||||||
|
const Invoices = lazy(() => import('./pages/Invoices'))
|
||||||
|
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
|
||||||
|
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
||||||
|
const Settings = lazy(() => import('./pages/Settings'))
|
||||||
|
|
||||||
|
export default function AdminApp() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AlertProvider>
|
||||||
|
<AlertContainer />
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<div className="admin-loading"><div className="admin-spinner" /></div>}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="login" element={<Login />} />
|
||||||
|
<Route element={<AdminLayout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="users" element={<Users />} />
|
||||||
|
<Route path="attendance" element={<Attendance />} />
|
||||||
|
<Route path="attendance/history" element={<AttendanceHistory />} />
|
||||||
|
<Route path="attendance/admin" element={<AttendanceAdmin />} />
|
||||||
|
<Route path="attendance/balances" element={<AttendanceBalances />} />
|
||||||
|
<Route path="attendance/requests" element={<LeaveRequests />} />
|
||||||
|
<Route path="attendance/approval" element={<LeaveApproval />} />
|
||||||
|
<Route path="attendance/create" element={<AttendanceCreate />} />
|
||||||
|
<Route path="attendance/location/:id" element={<AttendanceLocation />} />
|
||||||
|
<Route path="trips" element={<Trips />} />
|
||||||
|
<Route path="trips/history" element={<TripsHistory />} />
|
||||||
|
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||||
|
<Route path="vehicles" element={<Vehicles />} />
|
||||||
|
<Route path="offers" element={<Offers />} />
|
||||||
|
<Route path="offers/new" element={<OfferDetail />} />
|
||||||
|
<Route path="offers/:id" element={<OfferDetail />} />
|
||||||
|
<Route path="offers/customers" element={<OffersCustomers />} />
|
||||||
|
<Route path="offers/templates" element={<OffersTemplates />} />
|
||||||
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
|
<Route path="orders" element={<Orders />} />
|
||||||
|
<Route path="orders/:id" element={<OrderDetail />} />
|
||||||
|
<Route path="projects" element={<Projects />} />
|
||||||
|
<Route path="projects/new" element={<ProjectCreate />} />
|
||||||
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
|
<Route path="invoices" element={<Invoices />} />
|
||||||
|
<Route path="invoices/new" element={<InvoiceCreate />} />
|
||||||
|
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</AlertProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
2150
src/admin/admin.css
Normal file
434
src/admin/attendance.css
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Attendance Module
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.attendance-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.attendance-layout {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.attendance-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clock Card */
|
||||||
|
.attendance-clock-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-clock-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-clock-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-status-dot.active {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--success) 50%, transparent);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-clock-time {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shift Info */
|
||||||
|
.attendance-shift-info {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-shift-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.attendance-shift-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-shift-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-shift-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-shift-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-shift-value.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clock Actions */
|
||||||
|
.attendance-clock-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes */
|
||||||
|
.attendance-notes {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-notes-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project Section */
|
||||||
|
.attendance-project-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-header .attendance-shift-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-section .admin-form-select {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-logs {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-log-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-log-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-log-time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-project-log-duration {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Balance Card */
|
||||||
|
.attendance-balance-card {
|
||||||
|
background: var(--gradient);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-balance-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-balance-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-balance-number {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-balance-unit {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-balance-detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-balance-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-balance-progress {
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Links */
|
||||||
|
.attendance-quick-links {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-quick-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-quick-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: -0.25rem -0.5rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-quick-link:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-quick-link span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-quick-link svg:last-child {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leave Type Badges */
|
||||||
|
.attendance-leave-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-leave-badge.badge-vacation {
|
||||||
|
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-leave-badge.badge-sick {
|
||||||
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-leave-badge.badge-holiday {
|
||||||
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-leave-badge.badge-unpaid {
|
||||||
|
background: var(--muted-light);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Working Status Badge */
|
||||||
|
.attendance-working-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-working-badge.working {
|
||||||
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-working-badge.finished {
|
||||||
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS Link */
|
||||||
|
.attendance-gps-link {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-gps-link:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location Page */
|
||||||
|
.attendance-location-map {
|
||||||
|
height: 400px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
position: relative;
|
||||||
|
z-index: 0; /* stacking context - Leaflet z-indexy zustanou uvnitr */
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-location-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-location-card {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-location-card.empty {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-location-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-location-time {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-location-address {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-location-coords {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
144
src/admin/components/AdminDatePicker.jsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { forwardRef, useMemo } from 'react'
|
||||||
|
import DatePicker, { registerLocale } from 'react-datepicker'
|
||||||
|
import { cs } from 'date-fns/locale'
|
||||||
|
import { parse, format } from 'date-fns'
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css'
|
||||||
|
|
||||||
|
registerLocale('cs', cs)
|
||||||
|
|
||||||
|
// Ensure portal root exists
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('datepicker-portal')) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.id = 'datepicker-portal'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTouchDevice = () =>
|
||||||
|
typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||||
|
|
||||||
|
const CustomInput = forwardRef(({ value, onClick, onChange, placeholder, required, readOnly, disabled }, ref) => (
|
||||||
|
<input
|
||||||
|
className="admin-form-input"
|
||||||
|
onClick={onClick}
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={ref}
|
||||||
|
required={required}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={disabled}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
const modeToInputType = { month: 'month', time: 'time' }
|
||||||
|
|
||||||
|
function NativeInput({ mode, value, onChange, required, minDate, maxDate, disabled }) {
|
||||||
|
const type = modeToInputType[mode] || 'date'
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
lang="cs"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
min={minDate || undefined}
|
||||||
|
max={maxDate || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDatePicker({ mode = 'date', value, onChange, required, minDate, maxDate, ...rest }) {
|
||||||
|
const useNative = useMemo(() => isTouchDevice(), [])
|
||||||
|
|
||||||
|
if (useNative) {
|
||||||
|
return (
|
||||||
|
<NativeInput
|
||||||
|
mode={mode}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
required={required}
|
||||||
|
minDate={minDate}
|
||||||
|
maxDate={maxDate}
|
||||||
|
disabled={rest.disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDate = (val) => {
|
||||||
|
if (!val) return null
|
||||||
|
try {
|
||||||
|
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
|
||||||
|
if (mode === 'time') {
|
||||||
|
const [h, m] = val.split(':')
|
||||||
|
const d = new Date()
|
||||||
|
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
|
||||||
|
} catch { return null }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (date) => {
|
||||||
|
if (!date) { onChange(''); return }
|
||||||
|
if (mode === 'date') onChange(format(date, 'yyyy-MM-dd'))
|
||||||
|
else if (mode === 'time') onChange(format(date, 'HH:mm'))
|
||||||
|
else if (mode === 'month') onChange(format(date, 'yyyy-MM'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseMinMax = (val) => {
|
||||||
|
if (!val) return undefined
|
||||||
|
if (val instanceof Date) return val
|
||||||
|
try {
|
||||||
|
if (mode === 'date') return parse(val, 'yyyy-MM-dd', new Date())
|
||||||
|
if (mode === 'month') return parse(val, 'yyyy-MM', new Date())
|
||||||
|
} catch { return undefined }
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
selected: toDate(value),
|
||||||
|
onChange: handleChange,
|
||||||
|
locale: 'cs',
|
||||||
|
customInput: <CustomInput required={required} />,
|
||||||
|
minDate: parseMinMax(minDate),
|
||||||
|
maxDate: parseMinMax(maxDate),
|
||||||
|
popperPlacement: 'bottom-start',
|
||||||
|
portalId: 'datepicker-portal',
|
||||||
|
...rest
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'time') {
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
{...commonProps}
|
||||||
|
showTimeSelect
|
||||||
|
showTimeSelectOnly
|
||||||
|
timeIntervals={5}
|
||||||
|
timeCaption="Čas"
|
||||||
|
dateFormat="HH:mm"
|
||||||
|
timeFormat="HH:mm"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'month') {
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
{...commonProps}
|
||||||
|
showMonthYearPicker
|
||||||
|
dateFormat="MM/yyyy"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
{...commonProps}
|
||||||
|
dateFormat="dd.MM.yyyy"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
src/admin/components/AdminLayout.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Outlet, Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useTheme } from '../../context/ThemeContext'
|
||||||
|
import { setLogoutAlert } from '../utils/api'
|
||||||
|
import useModalLock from '../hooks/useModalLock'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const { isAuthenticated, loading, checkSession, user, logout } = useAuth()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Extend session on every route change
|
||||||
|
useEffect(() => {
|
||||||
|
checkSession()
|
||||||
|
}, [location.pathname, checkSession])
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
setLoggingOut(true)
|
||||||
|
setSidebarOpen(false)
|
||||||
|
setLogoutAlert()
|
||||||
|
setTimeout(() => logout(), 400)
|
||||||
|
}, [logout])
|
||||||
|
|
||||||
|
useModalLock(sidebarOpen)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="admin-layout">
|
||||||
|
<div className="admin-loading" style={{ width: '100%' }}>
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
// If 2FA is required but user hasn't enabled it, redirect to dashboard (where setup lives)
|
||||||
|
const needs2FASetup = user?.require2FA && !user?.totpEnabled
|
||||||
|
if (needs2FASetup && location.pathname !== '/') {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="admin-layout"
|
||||||
|
initial={{ opacity: 0, scale: 0.97 }}
|
||||||
|
animate={loggingOut
|
||||||
|
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
|
||||||
|
: { scale: 1, opacity: 1, filter: 'none' }
|
||||||
|
}
|
||||||
|
transition={{ duration: loggingOut ? 0.5 : 0.35, ease: [0.4, 0, 0.2, 1] }}
|
||||||
|
>
|
||||||
|
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} onLogout={handleLogout} />
|
||||||
|
|
||||||
|
<div className="admin-main">
|
||||||
|
<header className="admin-header">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="admin-menu-btn"
|
||||||
|
aria-label="Otevřít menu"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="admin-header-theme-btn"
|
||||||
|
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
|
||||||
|
aria-label={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
|
||||||
|
>
|
||||||
|
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="admin-content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/admin/components/AlertContainer.jsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useAlertState } from '../context/AlertContext'
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertContainer() {
|
||||||
|
const { alerts, removeAlert } = useAlertState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-alert-container" role="status" aria-live="polite">
|
||||||
|
<AnimatePresence>
|
||||||
|
{alerts.map(alert => (
|
||||||
|
<motion.div
|
||||||
|
key={alert.id}
|
||||||
|
className={`admin-toast admin-toast-${alert.type}`}
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<span className="admin-toast-icon">{icons[alert.type]}</span>
|
||||||
|
<span className="admin-toast-message">{alert.message}</span>
|
||||||
|
<button
|
||||||
|
className="admin-toast-close"
|
||||||
|
onClick={() => removeAlert(alert.id)}
|
||||||
|
aria-label="Zavřít"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
src/admin/components/BulkAttendanceModal.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import AdminDatePicker from './AdminDatePicker'
|
||||||
|
import useModalLock from '../hooks/useModalLock'
|
||||||
|
|
||||||
|
export default function BulkAttendanceModal({
|
||||||
|
show, onClose, form, setForm, users,
|
||||||
|
onSubmit, submitting, toggleUser, toggleAllUsers
|
||||||
|
}) {
|
||||||
|
useModalLock(show)
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-backdrop" onClick={() => !submitting && onClose()} />
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal admin-modal-lg"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-header">
|
||||||
|
<h2 className="admin-modal-title">Vyplnit docházku za měsíc</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
|
||||||
|
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Existující záznamy se přeskočí.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Měsíc</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="month"
|
||||||
|
value={form.month}
|
||||||
|
onChange={(val) => setForm({ ...form, month: val })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">
|
||||||
|
Zaměstnanci
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllUsers}
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.75rem',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--accent-color)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.user_ids.length === users.length ? 'Odznačit vše' : 'Vybrat vše'}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.375rem',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
borderRadius: 'var(--border-radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)'
|
||||||
|
}}>
|
||||||
|
{users.map(user => (
|
||||||
|
<label key={user.id} className="admin-form-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.user_ids.includes(String(user.id))}
|
||||||
|
onChange={() => toggleUser(user.id)}
|
||||||
|
/>
|
||||||
|
<span>{user.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<small className="admin-form-hint">
|
||||||
|
Vybráno: {form.user_ids.length} z {users.length}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Příchod</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.arrival_time}
|
||||||
|
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Odchod</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.departure_time}
|
||||||
|
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Začátek pauzy</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.break_start_time}
|
||||||
|
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Konec pauzy</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.break_end_time}
|
||||||
|
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="admin-btn admin-btn-secondary"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Zrušit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
disabled={submitting || form.user_ids.length === 0}
|
||||||
|
>
|
||||||
|
{submitting ? 'Vytvářím záznamy...' : 'Vyplnit měsíc'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/admin/components/ConfirmModal.jsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import useModalLock from '../hooks/useModalLock'
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title = 'Potvrdit akci',
|
||||||
|
message = 'Opravdu chcete provést tuto akci?',
|
||||||
|
confirmText = 'Potvrdit',
|
||||||
|
cancelText = 'Zrušit',
|
||||||
|
type = 'danger', // 'danger' | 'warning' | 'info'
|
||||||
|
loading = false
|
||||||
|
}) {
|
||||||
|
useModalLock(isOpen)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handleEsc = (e) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleEsc)
|
||||||
|
return () => document.removeEventListener('keydown', handleEsc)
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
danger: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17" />
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
default: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal admin-confirm-modal"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-modal-title"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-body admin-confirm-content">
|
||||||
|
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
||||||
|
{icons[type]}
|
||||||
|
</div>
|
||||||
|
<h2 id="confirm-modal-title" className="admin-confirm-title">{title}</h2>
|
||||||
|
<p className="admin-confirm-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="admin-btn admin-btn-secondary"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="admin-spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
||||||
|
Zpracování...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
confirmText
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/admin/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Component } from 'react'
|
||||||
|
|
||||||
|
export default class ErrorBoundary extends Component {
|
||||||
|
state = { hasError: false }
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, info) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('ErrorBoundary caught:', error, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '50vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
color: 'var(--text-secondary, #888)'
|
||||||
|
}}>
|
||||||
|
<p>Něco se pokazilo při načítání stránky.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.5rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border-color, #333)',
|
||||||
|
background: 'var(--bg-secondary, #1a1a1a)',
|
||||||
|
color: 'var(--text-primary, #fff)',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Načíst znovu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/admin/components/Forbidden.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
export default function Forbidden() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="forbidden-page"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="forbidden-icon">
|
||||||
|
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
<circle cx="12" cy="16" r="1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="forbidden-title">Přístup odepřen</h1>
|
||||||
|
<p className="forbidden-text">
|
||||||
|
Nemáte oprávnění pro zobrazení této stránky. Kontaktujte administrátora pro přidělení přístupu.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="forbidden-link">
|
||||||
|
Zpět na přehled
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
src/admin/components/OfferItemsSection.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { formatCurrency } from '../utils/formatters'
|
||||||
|
|
||||||
|
export default function OfferItemsSection({
|
||||||
|
items, updateItem, addItem, removeItem, moveItem,
|
||||||
|
itemTemplates, showItemTemplateMenu, setShowItemTemplateMenu,
|
||||||
|
addItemFromTemplate, totals, currency, applyVat, vatRate,
|
||||||
|
itemsError, readOnly
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="offers-editor-section"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<h3 className="admin-card-title" style={{ margin: 0 }}>Položky</h3>
|
||||||
|
{itemsError && <span className="admin-form-error">{itemsError}</span>}
|
||||||
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', position: 'relative' }}>
|
||||||
|
{itemTemplates.length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowItemTemplateMenu(prev => !prev)}
|
||||||
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
|
>
|
||||||
|
Ze šablony
|
||||||
|
</button>
|
||||||
|
{showItemTemplateMenu && (
|
||||||
|
<div className="offers-template-menu">
|
||||||
|
{itemTemplates.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="offers-template-menu-item"
|
||||||
|
onClick={() => addItemFromTemplate(t)}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t.name}</div>
|
||||||
|
{t.default_price > 0 && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
|
||||||
|
{Number(t.default_price).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={addItem} className="admin-btn admin-btn-primary admin-btn-sm">
|
||||||
|
+ Přidat položku
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="offers-items-table">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
||||||
|
<th>Popis položky</th>
|
||||||
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
||||||
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jednotka</th>
|
||||||
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
||||||
|
<th style={{ width: '4.5rem', textAlign: 'center' }}>V ceně</th>
|
||||||
|
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
||||||
|
{!readOnly && <th style={{ width: '5.5rem', textAlign: 'center' }}></th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||||
|
return (
|
||||||
|
<tr key={item._key || index}>
|
||||||
|
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Název položky"
|
||||||
|
style={{ marginBottom: '0.5rem', fontWeight: 500 }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.item_description}
|
||||||
|
onChange={(e) => updateItem(index, 'item_description', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Podrobný popis (volitelný)"
|
||||||
|
style={{ fontSize: '0.8rem', opacity: 0.8 }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||||
|
className="admin-form-input"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="hod"
|
||||||
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
|
||||||
|
className="admin-form-input"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center' }}>
|
||||||
|
<label className="admin-form-checkbox" style={{ justifyContent: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.is_included_in_total}
|
||||||
|
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
|
||||||
|
{formatCurrency(lineTotal, currency)}
|
||||||
|
</td>
|
||||||
|
{!readOnly && (
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
|
||||||
|
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="offers-totals-summary">
|
||||||
|
<div className="offers-totals-row">
|
||||||
|
<span>Mezisoučet:</span>
|
||||||
|
<span>{formatCurrency(totals.subtotal, currency)}</span>
|
||||||
|
</div>
|
||||||
|
{applyVat && (
|
||||||
|
<div className="offers-totals-row">
|
||||||
|
<span>DPH ({vatRate}%):</span>
|
||||||
|
<span>{formatCurrency(totals.vatAmount, currency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="offers-totals-row offers-totals-total">
|
||||||
|
<span>Celkem k úhradě:</span>
|
||||||
|
<span>{formatCurrency(totals.total, currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
src/admin/components/OfferScopeSection.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import RichEditor from './RichEditor'
|
||||||
|
|
||||||
|
export default function OfferScopeSection({
|
||||||
|
sections, addSection, removeSection, updateSection, moveSection,
|
||||||
|
scopeTemplates, showScopeTemplateMenu, setShowScopeTemplateMenu,
|
||||||
|
loadScopeTemplate, form, updateForm, readOnly
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="offers-editor-section"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h3 className="admin-card-title" style={{ margin: 0 }}>Rozsah projektu</h3>
|
||||||
|
{!readOnly && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', position: 'relative' }}>
|
||||||
|
{scopeTemplates.length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowScopeTemplateMenu(prev => !prev)}
|
||||||
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
|
>
|
||||||
|
Ze šablony
|
||||||
|
</button>
|
||||||
|
{showScopeTemplateMenu && (
|
||||||
|
<div className="offers-template-menu">
|
||||||
|
{scopeTemplates.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="offers-template-menu-item"
|
||||||
|
onClick={() => loadScopeTemplate(t)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={addSection} className="admin-btn admin-btn-primary admin-btn-sm">
|
||||||
|
+ Přidat sekci
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Název rozsahu</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.scope_title}
|
||||||
|
onChange={(e) => updateForm('scope_title', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Rozsah projektu"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Popis rozsahu</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.scope_description}
|
||||||
|
onChange={(e) => updateForm('scope_description', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Volitelný popis"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sections.length === 0 ? (
|
||||||
|
<div className="admin-empty-state" style={{ padding: '2rem' }}>
|
||||||
|
<p style={{ color: 'var(--text-tertiary)' }}>Žádné sekce rozsahu. Klikněte na "Přidat sekci" pro přidání.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="offers-scope-list">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<div key={section._key || index} className="offers-scope-section">
|
||||||
|
<div className="offers-scope-section-header">
|
||||||
|
<span className="offers-scope-number">{index + 1}.</span>
|
||||||
|
<span className="offers-scope-title">{(form.language === 'CZ' ? (section.title_cz || section.title) : section.title) || `Sekce ${index + 1}`}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="offers-scope-actions">
|
||||||
|
<button type="button" onClick={() => moveSection(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => moveSection(index, 1)} disabled={index === sections.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => removeSection(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">
|
||||||
|
<span className="offers-lang-badge">EN</span>
|
||||||
|
Název sekce
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={section.title}
|
||||||
|
onChange={(e) => updateSection(index, 'title', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Název sekce (anglicky)"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">
|
||||||
|
<span className="offers-lang-badge offers-lang-badge-cz">CZ</span>
|
||||||
|
Název sekce
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={section.title_cz}
|
||||||
|
onChange={(e) => updateSection(index, 'title_cz', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Název sekce (česky)"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Obsah</label>
|
||||||
|
{readOnly ? (
|
||||||
|
section.content && (
|
||||||
|
<div
|
||||||
|
className="offers-scope-content rich-text-view"
|
||||||
|
style={{ padding: '1rem' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(section.content) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<RichEditor
|
||||||
|
value={section.content}
|
||||||
|
onChange={(val) => updateSection(index, 'content', val)}
|
||||||
|
placeholder="Obsah sekce..."
|
||||||
|
minHeight="150px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
src/admin/components/RichEditor.jsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useMemo, useRef, useCallback } from 'react'
|
||||||
|
import ReactQuill from 'react-quill-new'
|
||||||
|
import 'react-quill-new/dist/quill.snow.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich text editor (Quill).
|
||||||
|
* Font = class-based attributor, Size = inline style attributor (kompatibilni s TCPDF).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Quill = ReactQuill.Quill
|
||||||
|
|
||||||
|
if (!Quill.__bohaRegistered) {
|
||||||
|
const Font = Quill.import('attributors/class/font')
|
||||||
|
Font.whitelist = [
|
||||||
|
'arial', 'tahoma', 'verdana', 'georgia', 'times-new-roman',
|
||||||
|
'courier-new', 'trebuchet-ms', 'impact', 'comic-sans-ms',
|
||||||
|
'lucida-console', 'palatino-linotype', 'garamond'
|
||||||
|
]
|
||||||
|
Quill.register(Font, true)
|
||||||
|
|
||||||
|
const SizeStyle = Quill.import('attributors/style/size')
|
||||||
|
SizeStyle.whitelist = [
|
||||||
|
'8px', '9px', '10px', '11px', '12px', '14px', '16px',
|
||||||
|
'18px', '20px', '24px', '28px', '32px', '36px', '48px'
|
||||||
|
]
|
||||||
|
Quill.register(SizeStyle, true)
|
||||||
|
|
||||||
|
Quill.__bohaRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const Font = Quill.import('attributors/class/font')
|
||||||
|
const SIZE_WHITELIST = [
|
||||||
|
'8px', '9px', '10px', '11px', '12px', '14px', '16px',
|
||||||
|
'18px', '20px', '24px', '28px', '32px', '36px', '48px'
|
||||||
|
]
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#000000', '#1a1a1a', '#333333', '#555555', '#777777', '#999999', '#bbbbbb', '#dddddd', '#ffffff',
|
||||||
|
'#de3a3a', '#e57373', '#c62828',
|
||||||
|
'#1565c0', '#42a5f5', '#0d47a1',
|
||||||
|
'#2e7d32', '#66bb6a', '#1b5e20',
|
||||||
|
'#f57f17', '#ffca28', '#e65100',
|
||||||
|
'#6a1b9a', '#ab47bc', '#4a148c',
|
||||||
|
'#00695c', '#26a69a', '#004d40',
|
||||||
|
'#37474f', '#78909c', '#263238',
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOOLBAR = [
|
||||||
|
[{ font: Font.whitelist }],
|
||||||
|
[{ size: SIZE_WHITELIST }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ color: COLORS }, { background: COLORS }],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
[{ indent: '-1' }, { indent: '+1' }],
|
||||||
|
[{ align: [] }],
|
||||||
|
['link'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
|
||||||
|
const FORMATS = [
|
||||||
|
'font', 'size',
|
||||||
|
'bold', 'italic', 'underline', 'strike',
|
||||||
|
'color', 'background',
|
||||||
|
'list', 'indent', 'align',
|
||||||
|
'link'
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function RichEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Obsah...',
|
||||||
|
minHeight = '120px'
|
||||||
|
}) {
|
||||||
|
const quillRef = useRef(null)
|
||||||
|
const lastValueRef = useRef(value)
|
||||||
|
|
||||||
|
const modules = useMemo(() => ({
|
||||||
|
toolbar: TOOLBAR,
|
||||||
|
clipboard: {
|
||||||
|
matchVisual: false, // strip nepovolenou formataci pri paste
|
||||||
|
},
|
||||||
|
keyboard: {
|
||||||
|
bindings: {
|
||||||
|
tab: {
|
||||||
|
key: 9,
|
||||||
|
handler(range) {
|
||||||
|
const quill = this.quill
|
||||||
|
const [line] = quill.getLine(range.index)
|
||||||
|
if (line && line.statics && line.statics.blotName === 'list-item') {
|
||||||
|
quill.format('indent', '+1', 'user')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
quill.insertText(range.index, ' ', 'user')
|
||||||
|
quill.setSelection(range.index + 4, 'silent')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shiftTab: {
|
||||||
|
key: 9,
|
||||||
|
shiftKey: true,
|
||||||
|
handler(range) {
|
||||||
|
const quill = this.quill
|
||||||
|
const [line] = quill.getLine(range.index)
|
||||||
|
if (line && line.statics && line.statics.blotName === 'list-item') {
|
||||||
|
quill.format('indent', '-1', 'user')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
// Ignoruj programaticke zmeny, reaguj jen na user input
|
||||||
|
const handleChange = useCallback((content, delta, source) => {
|
||||||
|
if (source !== 'user') return
|
||||||
|
if (content === lastValueRef.current) return
|
||||||
|
lastValueRef.current = content
|
||||||
|
onChange(content)
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rich-editor" style={{ '--re-min-height': minHeight }}>
|
||||||
|
<ReactQuill
|
||||||
|
ref={quillRef}
|
||||||
|
theme="snow"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
modules={modules}
|
||||||
|
formats={FORMATS}
|
||||||
|
placeholder={placeholder}
|
||||||
|
bounds=".rich-editor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
346
src/admin/components/ShiftFormModal.jsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import AdminDatePicker from './AdminDatePicker'
|
||||||
|
import useModalLock from '../hooks/useModalLock'
|
||||||
|
import { calcFormWorkMinutes, calcProjectMinutesTotal, formatDate } from '../utils/attendanceHelpers'
|
||||||
|
|
||||||
|
let _logKeyCounter = 0
|
||||||
|
|
||||||
|
function ProjectTimeStatus({ form, projectLogs }) {
|
||||||
|
const totalWork = calcFormWorkMinutes(form)
|
||||||
|
const totalProject = calcProjectMinutesTotal(projectLogs)
|
||||||
|
const remaining = totalWork - totalProject
|
||||||
|
const hasLogs = projectLogs.some(l => l.project_id)
|
||||||
|
|
||||||
|
if (!hasLogs || totalWork <= 0) return null
|
||||||
|
|
||||||
|
const isMatch = remaining === 0
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
background: isMatch ? 'var(--success-bg, rgba(34,197,94,0.1))' : 'var(--danger-bg, rgba(239,68,68,0.1))',
|
||||||
|
color: isMatch ? 'var(--success-color, #16a34a)' : 'var(--danger-color, #dc2626)',
|
||||||
|
border: `1px solid ${isMatch ? 'var(--success-border, rgba(34,197,94,0.3))' : 'var(--danger-border, rgba(239,68,68,0.3))'}`
|
||||||
|
}}>
|
||||||
|
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá: {Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m {remaining < 0 ? '(překročeno)' : ''}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectLogRow({ log, index, projectList, onUpdate, onRemove }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<select
|
||||||
|
value={log.project_id}
|
||||||
|
onChange={(e) => onUpdate(index, 'project_id', e.target.value)}
|
||||||
|
className="admin-form-select"
|
||||||
|
style={{ flex: 3, marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<option value="">— Projekt —</option>
|
||||||
|
{projectList.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.project_number} – {p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="24"
|
||||||
|
value={log.hours}
|
||||||
|
onChange={(e) => onUpdate(index, 'hours', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
|
||||||
|
placeholder="h"
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>h</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value={log.minutes}
|
||||||
|
onChange={(e) => onUpdate(index, 'minutes', e.target.value)}
|
||||||
|
className="admin-form-input"
|
||||||
|
style={{ width: '60px', marginBottom: 0, textAlign: 'center' }}
|
||||||
|
placeholder="m"
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>m</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
|
style={{ padding: '0.375rem', flexShrink: 0 }}
|
||||||
|
title="Odebrat"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShiftFormModal({
|
||||||
|
mode, show, onClose, onSubmit,
|
||||||
|
form, setForm,
|
||||||
|
projectLogs, setProjectLogs, projectList,
|
||||||
|
users, onShiftDateChange, editingRecord
|
||||||
|
}) {
|
||||||
|
useModalLock(show)
|
||||||
|
const isCreate = mode === 'create'
|
||||||
|
const isWorkType = form.leave_type === 'work'
|
||||||
|
|
||||||
|
const updateField = (field, value) => {
|
||||||
|
setForm({ ...form, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProjectLog = (index, field, value) => {
|
||||||
|
const updated = [...projectLogs]
|
||||||
|
updated[index] = { ...updated[index], [field]: value }
|
||||||
|
setProjectLogs(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeProjectLog = (index) => {
|
||||||
|
setProjectLogs(projectLogs.filter((_, j) => j !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProjectLog = () => {
|
||||||
|
setProjectLogs([...projectLogs, { _key: `log-${++_logKeyCounter}`, project_id: '', hours: '', minutes: '' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal admin-modal-lg"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-header">
|
||||||
|
<h2 className="admin-modal-title">
|
||||||
|
{isCreate ? 'Přidat záznam docházky' : 'Upravit docházku'}
|
||||||
|
</h2>
|
||||||
|
{!isCreate && editingRecord && (
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
||||||
|
{editingRecord.user_name} — {formatDate(editingRecord.shift_date)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
{isCreate ? (
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label required">Zaměstnanec</label>
|
||||||
|
<select
|
||||||
|
value={form.user_id}
|
||||||
|
onChange={(e) => updateField('user_id', e.target.value)}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
<option value="">Vyberte zaměstnance</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>{user.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label required">Datum směny</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={form.shift_date}
|
||||||
|
onChange={(val) => onShiftDateChange(val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Datum směny</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={form.shift_date}
|
||||||
|
onChange={(val) => updateField('shift_date', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Typ záznamu</label>
|
||||||
|
<select
|
||||||
|
value={form.leave_type}
|
||||||
|
onChange={(e) => updateField('leave_type', e.target.value)}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
<option value="work">Práce</option>
|
||||||
|
<option value="vacation">Dovolená</option>
|
||||||
|
<option value="sick">Nemoc</option>
|
||||||
|
<option value="holiday">Svátek</option>
|
||||||
|
<option value="unpaid">Neplacené volno</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isWorkType && (
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Počet hodin</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.leave_hours}
|
||||||
|
onChange={(e) => updateField('leave_hours', parseFloat(e.target.value))}
|
||||||
|
min="0.5"
|
||||||
|
max="24"
|
||||||
|
step="0.5"
|
||||||
|
className="admin-form-input"
|
||||||
|
/>
|
||||||
|
{isCreate && <small className="admin-form-hint">8 hodin = celý den</small>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isWorkType && (
|
||||||
|
<>
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Příchod - datum</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={form.arrival_date}
|
||||||
|
onChange={(val) => updateField('arrival_date', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Příchod - čas</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.arrival_time}
|
||||||
|
onChange={(val) => updateField('arrival_time', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Začátek pauzy - datum</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={form.break_start_date}
|
||||||
|
onChange={(val) => updateField('break_start_date', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Začátek pauzy - čas</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.break_start_time}
|
||||||
|
onChange={(val) => updateField('break_start_time', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Konec pauzy - datum</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={form.break_end_date}
|
||||||
|
onChange={(val) => updateField('break_end_date', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Konec pauzy - čas</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.break_end_time}
|
||||||
|
onChange={(val) => updateField('break_end_time', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Odchod - datum</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={form.departure_date}
|
||||||
|
onChange={(val) => updateField('departure_date', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Odchod - čas</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="time"
|
||||||
|
value={form.departure_time}
|
||||||
|
onChange={(val) => updateField('departure_time', val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isWorkType && projectList.length > 0 && (
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Projekty</label>
|
||||||
|
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||||
|
{projectLogs.map((log, i) => (
|
||||||
|
<ProjectLogRow
|
||||||
|
key={log._key || i}
|
||||||
|
log={log}
|
||||||
|
index={i}
|
||||||
|
projectList={projectList}
|
||||||
|
onUpdate={updateProjectLog}
|
||||||
|
onRemove={removeProjectLog}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addProjectLog}
|
||||||
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
|
>
|
||||||
|
+ Přidat projekt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Poznámka</label>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => updateField('notes', e.target.value)}
|
||||||
|
className="admin-form-textarea"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="admin-btn admin-btn-secondary"
|
||||||
|
>
|
||||||
|
Zrušit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
>
|
||||||
|
Uložit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
382
src/admin/components/Sidebar.jsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useTheme } from '../../context/ThemeContext'
|
||||||
|
|
||||||
|
const menuSections = [
|
||||||
|
{
|
||||||
|
label: 'Přehled',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
label: 'Přehled',
|
||||||
|
end: true,
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Docházka',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: '/attendance',
|
||||||
|
label: 'Záznam',
|
||||||
|
permission: 'attendance.record',
|
||||||
|
end: true,
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<polyline points="12 7 12 12 15 15" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/attendance/history',
|
||||||
|
label: 'Moje historie',
|
||||||
|
permission: 'attendance.history',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="12 8 12 12 14 14" />
|
||||||
|
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/attendance/requests',
|
||||||
|
label: 'Žádosti',
|
||||||
|
permission: 'attendance.record',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12" />
|
||||||
|
<line x1="9" y1="15" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/attendance/approval',
|
||||||
|
label: 'Schvalování',
|
||||||
|
permission: 'attendance.approve',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/attendance/admin',
|
||||||
|
label: 'Správa',
|
||||||
|
permission: 'attendance.admin',
|
||||||
|
matchPrefix: '/attendance/admin',
|
||||||
|
matchAlso: ['/attendance/create', '/attendance/location'],
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" />
|
||||||
|
<line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" />
|
||||||
|
<line x1="1" y1="14" x2="7" y2="14" />
|
||||||
|
<line x1="9" y1="8" x2="15" y2="8" />
|
||||||
|
<line x1="17" y1="16" x2="23" y2="16" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/attendance/balances',
|
||||||
|
label: 'Správa bilancí',
|
||||||
|
permission: 'attendance.balances',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kniha jízd',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: '/trips',
|
||||||
|
label: 'Záznam',
|
||||||
|
permission: 'trips.record',
|
||||||
|
end: true,
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="5" cy="18" r="3" /><circle cx="19" cy="18" r="3" />
|
||||||
|
<path d="M5 18V12L8 5h8l3 7v6" /><path d="M10 18h4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/trips/history',
|
||||||
|
label: 'Moje historie',
|
||||||
|
permission: 'trips.history',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="12 8 12 12 14 14" />
|
||||||
|
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/trips/admin',
|
||||||
|
label: 'Správa',
|
||||||
|
permission: 'trips.admin',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" />
|
||||||
|
<line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" />
|
||||||
|
<line x1="1" y1="14" x2="7" y2="14" />
|
||||||
|
<line x1="9" y1="8" x2="15" y2="8" />
|
||||||
|
<line x1="17" y1="16" x2="23" y2="16" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/vehicles',
|
||||||
|
label: 'Vozidla',
|
||||||
|
permission: 'trips.vehicles',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="1" y="3" width="15" height="13" rx="2" />
|
||||||
|
<path d="M16 8h4l3 3v5h-7V8z" />
|
||||||
|
<circle cx="5.5" cy="18.5" r="2.5" />
|
||||||
|
<circle cx="18.5" cy="18.5" r="2.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Administrativa',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: '/offers',
|
||||||
|
label: 'Nabídky',
|
||||||
|
permission: 'offers.view',
|
||||||
|
matchPrefix: '/offers',
|
||||||
|
matchExclude: ['/offers/customers', '/offers/templates'],
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/orders',
|
||||||
|
label: 'Objednávky',
|
||||||
|
permission: 'orders.view',
|
||||||
|
matchPrefix: '/orders',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<path d="M16 10a4 4 0 0 1-8 0" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/invoices',
|
||||||
|
label: 'Faktury',
|
||||||
|
permission: 'invoices.view',
|
||||||
|
matchPrefix: '/invoices',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23" />
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects',
|
||||||
|
label: 'Projekty',
|
||||||
|
permission: 'projects.view',
|
||||||
|
matchPrefix: '/projects',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/offers/customers',
|
||||||
|
label: 'Zákazníci',
|
||||||
|
permission: 'offers.view',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/company/settings',
|
||||||
|
label: 'Firma',
|
||||||
|
permission: 'offers.settings',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Systém',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
label: 'Uživatelé',
|
||||||
|
permission: 'users.view',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
label: 'Nastavení',
|
||||||
|
permission: ['settings.roles', 'settings.security'],
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sidebar({ isOpen, onClose, onLogout }) {
|
||||||
|
const { user, hasPermission } = useAuth()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const isItemActive = (item) => {
|
||||||
|
if (item.matchPrefix) {
|
||||||
|
let active = location.pathname.startsWith(item.matchPrefix)
|
||||||
|
if (active && item.matchExclude) {
|
||||||
|
active = !item.matchExclude.some(ex => location.pathname.startsWith(ex))
|
||||||
|
}
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
if (item.end) {
|
||||||
|
return location.pathname === item.path
|
||||||
|
}
|
||||||
|
return location.pathname.startsWith(item.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasItemPermission = (item) => {
|
||||||
|
if (!item.permission) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Array.isArray(item.permission)) {
|
||||||
|
return item.permission.some(p => hasPermission(p))
|
||||||
|
}
|
||||||
|
return hasPermission(item.permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleSections = menuSections
|
||||||
|
.map(section => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter(hasItemPermission)
|
||||||
|
}))
|
||||||
|
.filter(section => section.items.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`admin-sidebar-overlay${isOpen ? ' open' : ''}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside className={`admin-sidebar${isOpen ? ' open' : ''}`}>
|
||||||
|
<div className="admin-sidebar-header">
|
||||||
|
<img
|
||||||
|
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
|
||||||
|
alt="Logo"
|
||||||
|
className="admin-sidebar-logo"
|
||||||
|
/>
|
||||||
|
<button onClick={onClose} className="admin-sidebar-close" aria-label="Zavřít menu">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="admin-sidebar-nav">
|
||||||
|
{visibleSections.map((section) => (
|
||||||
|
<div key={section.label} className="admin-nav-section">
|
||||||
|
<div className="admin-nav-label">{section.label}</div>
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
end={item.end}
|
||||||
|
onClick={onClose}
|
||||||
|
className={() => {
|
||||||
|
let active = isItemActive(item)
|
||||||
|
if (!active && item.matchAlso) {
|
||||||
|
active = item.matchAlso.some(p => location.pathname.startsWith(p))
|
||||||
|
}
|
||||||
|
return `admin-nav-item${active ? ' active' : ''}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="admin-sidebar-footer">
|
||||||
|
<div className="admin-user-chip">
|
||||||
|
<div className="admin-user-avatar">
|
||||||
|
{user?.fullName?.charAt(0) || user?.username?.charAt(0) || 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="admin-user-details">
|
||||||
|
<div className="admin-user-name">
|
||||||
|
{user?.fullName || user?.username}
|
||||||
|
</div>
|
||||||
|
<div className="admin-user-role">
|
||||||
|
{user?.roleDisplay || user?.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onLogout} className="admin-logout-btn" aria-label="Odhlásit se">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Odhlásit se
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/admin/components/SortIcon.jsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default function SortIcon({ column, sort, order }) {
|
||||||
|
if (sort !== column) return null
|
||||||
|
return (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 4, verticalAlign: 'middle' }}>
|
||||||
|
<path d={order === 'ASC' ? 'M18 15l-6-6-6 6' : 'M6 9l6 6 6-6'} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/admin/context/AlertContext.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
const AlertContext = createContext(null)
|
||||||
|
const AlertStateContext = createContext(null)
|
||||||
|
|
||||||
|
export function AlertProvider({ children }) {
|
||||||
|
const [alerts, setAlerts] = useState([])
|
||||||
|
|
||||||
|
const removeAlert = useCallback((id) => {
|
||||||
|
setAlerts(prev => prev.filter(alert => alert.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const counterRef = useRef(0)
|
||||||
|
const addAlert = useCallback((message, type = 'success', duration = 4000) => {
|
||||||
|
const id = `${Date.now()}-${counterRef.current++}`
|
||||||
|
setAlerts(prev => [...prev, { id, message, type }])
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeAlert(id)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}, [removeAlert])
|
||||||
|
|
||||||
|
const methods = useMemo(() => ({
|
||||||
|
addAlert,
|
||||||
|
removeAlert,
|
||||||
|
success: (message, duration) => addAlert(message, 'success', duration),
|
||||||
|
error: (message, duration) => addAlert(message, 'error', duration),
|
||||||
|
warning: (message, duration) => addAlert(message, 'warning', duration),
|
||||||
|
info: (message, duration) => addAlert(message, 'info', duration)
|
||||||
|
}), [addAlert, removeAlert])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertContext.Provider value={methods}>
|
||||||
|
<AlertStateContext.Provider value={{ alerts, removeAlert }}>
|
||||||
|
{children}
|
||||||
|
</AlertStateContext.Provider>
|
||||||
|
</AlertContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlert() {
|
||||||
|
const context = useContext(AlertContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAlert must be used within an AlertProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlertState() {
|
||||||
|
const context = useContext(AlertStateContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAlertState must be used within an AlertProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
371
src/admin/context/AuthContext.jsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
|
import { setSessionExpired, setTokenGetter, setRefreshFn } from '../utils/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
const AuthStateContext = createContext(null)
|
||||||
|
const AuthActionsContext = createContext(null)
|
||||||
|
|
||||||
|
// Prevod snake_case API user objektu na camelCase pro interni stav
|
||||||
|
function mapUser(u) {
|
||||||
|
if (!u) return null
|
||||||
|
return {
|
||||||
|
...u,
|
||||||
|
fullName: u.full_name ?? u.fullName,
|
||||||
|
roleDisplay: u.role_display ?? u.roleDisplay,
|
||||||
|
isAdmin: u.is_admin ?? u.isAdmin,
|
||||||
|
totpEnabled: u.totp_enabled ?? u.totpEnabled,
|
||||||
|
require2FA: u.require_2fa ?? u.require2FA,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken = null
|
||||||
|
let tokenExpiresAt = null
|
||||||
|
|
||||||
|
let cachedUser = null
|
||||||
|
let sessionFetched = false
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(cachedUser)
|
||||||
|
const [loading, setLoading] = useState(!sessionFetched)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const refreshTimeoutRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cachedUser = user
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const getAccessToken = useCallback(() => {
|
||||||
|
if (tokenExpiresAt && Date.now() > tokenExpiresAt - 30000) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return accessToken
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setAccessToken = useCallback((token, expiresIn) => {
|
||||||
|
accessToken = token
|
||||||
|
tokenExpiresAt = token ? Date.now() + (expiresIn * 1000) : null
|
||||||
|
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
clearTimeout(refreshTimeoutRef.current)
|
||||||
|
refreshTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh 1 min pred expirem
|
||||||
|
if (token && expiresIn > 60) {
|
||||||
|
const refreshTime = (expiresIn - 60) * 1000
|
||||||
|
refreshTimeoutRef.current = setTimeout(() => {
|
||||||
|
silentRefresh()
|
||||||
|
}, refreshTime)
|
||||||
|
}
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const silentRefresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/refresh.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success && data.data?.access_token) {
|
||||||
|
setAccessToken(data.data.access_token, data.data.expires_in)
|
||||||
|
setUser(mapUser(data.data.user))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
accessToken = null
|
||||||
|
tokenExpiresAt = null
|
||||||
|
setUser(null)
|
||||||
|
cachedUser = null
|
||||||
|
setSessionExpired()
|
||||||
|
return false
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (import.meta.env.DEV) console.error('Token refresh failed:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [setAccessToken])
|
||||||
|
|
||||||
|
const checkSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const token = getAccessToken()
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/session.php`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
|
||||||
|
// Neodhlasovat pri rate limitu nebo server erroru
|
||||||
|
if (response.status === 429 || response.status >= 500) {
|
||||||
|
return !!cachedUser
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success && data.data?.authenticated) {
|
||||||
|
if (data.data.access_token) {
|
||||||
|
setAccessToken(data.data.access_token, data.data.expires_in)
|
||||||
|
}
|
||||||
|
setUser(mapUser(data.data.user))
|
||||||
|
cachedUser = mapUser(data.data.user)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
setUser(null)
|
||||||
|
cachedUser = null
|
||||||
|
accessToken = null
|
||||||
|
tokenExpiresAt = null
|
||||||
|
return false
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (import.meta.env.DEV) console.error('Session check failed:', err)
|
||||||
|
return !!cachedUser
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
sessionFetched = true
|
||||||
|
}
|
||||||
|
}, [getAccessToken, setAccessToken])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTokenGetter(getAccessToken)
|
||||||
|
setRefreshFn(silentRefresh)
|
||||||
|
}, [getAccessToken, silentRefresh])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSession()
|
||||||
|
return () => {
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
clearTimeout(refreshTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [checkSession])
|
||||||
|
|
||||||
|
const login = useCallback(async (username, password, remember = false) => {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/login.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
remember
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (data.data?.requires_2fa) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
requires2FA: true,
|
||||||
|
loginToken: data.data.login_token,
|
||||||
|
remember
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccessToken(data.data.access_token, data.data.expires_in)
|
||||||
|
setUser(mapUser(data.data.user))
|
||||||
|
cachedUser = mapUser(data.data.user)
|
||||||
|
sessionFetched = true
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
setError(data.error)
|
||||||
|
return { success: false, error: data.error }
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
const errorMsg = 'Chyba připojení. Zkontrolujte prosím připojení k internetu a zkuste to znovu.'
|
||||||
|
setError(errorMsg)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
}, [setAccessToken])
|
||||||
|
|
||||||
|
const verify2FA = useCallback(async (loginToken, code, remember = false, isBackup = false) => {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const action = isBackup ? 'backup_verify' : 'verify'
|
||||||
|
const response = await fetch(`${API_BASE}/totp.php?action=${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
login_token: loginToken,
|
||||||
|
code,
|
||||||
|
remember
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setAccessToken(data.data.access_token, data.data.expires_in)
|
||||||
|
setUser(mapUser(data.data.user))
|
||||||
|
cachedUser = mapUser(data.data.user)
|
||||||
|
sessionFetched = true
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
setError(data.error)
|
||||||
|
return { success: false, error: data.error }
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
const errorMsg = 'Chyba připojení. Zkontrolujte prosím připojení k internetu a zkuste to znovu.'
|
||||||
|
setError(errorMsg)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
}, [setAccessToken])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const token = getAccessToken()
|
||||||
|
|
||||||
|
await fetch(`${API_BASE}/logout.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` })
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (import.meta.env.DEV) console.error('Logout error:', err)
|
||||||
|
} finally {
|
||||||
|
accessToken = null
|
||||||
|
tokenExpiresAt = null
|
||||||
|
setUser(null)
|
||||||
|
cachedUser = null
|
||||||
|
sessionFetched = false
|
||||||
|
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
clearTimeout(refreshTimeoutRef.current)
|
||||||
|
refreshTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [getAccessToken])
|
||||||
|
|
||||||
|
const apiRequest = useCallback(async (endpoint, options = {}) => {
|
||||||
|
let token = getAccessToken()
|
||||||
|
|
||||||
|
if (!token && user) {
|
||||||
|
const refreshed = await silentRefresh()
|
||||||
|
if (refreshed) {
|
||||||
|
token = getAccessToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && user) {
|
||||||
|
const refreshed = await silentRefresh()
|
||||||
|
if (refreshed) {
|
||||||
|
token = getAccessToken()
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
|
||||||
|
return fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}, [getAccessToken, silentRefresh, user])
|
||||||
|
|
||||||
|
const updateUser = useCallback((updates) => {
|
||||||
|
setUser(prev => prev ? { ...prev, ...updates } : null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasPermission = useCallback((permission) => {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.isAdmin) return true
|
||||||
|
return (user.permissions || []).includes(permission)
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const permissions = useMemo(() => user?.permissions || [], [user])
|
||||||
|
|
||||||
|
// Stabilni objekt - meni se jen kdyz se zmeni user/loading/error
|
||||||
|
const stateValue = useMemo(() => ({
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isAdmin: user?.isAdmin || false,
|
||||||
|
permissions,
|
||||||
|
hasPermission
|
||||||
|
}), [user, loading, error, permissions, hasPermission])
|
||||||
|
|
||||||
|
// Stabilni objekt - callback reference se nemeni
|
||||||
|
const actionsValue = useMemo(() => ({
|
||||||
|
login,
|
||||||
|
verify2FA,
|
||||||
|
logout,
|
||||||
|
checkSession,
|
||||||
|
getAccessToken,
|
||||||
|
apiRequest,
|
||||||
|
silentRefresh,
|
||||||
|
updateUser
|
||||||
|
}), [login, verify2FA, logout, checkSession, getAccessToken, apiRequest, silentRefresh, updateUser])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthActionsContext.Provider value={actionsValue}>
|
||||||
|
<AuthStateContext.Provider value={stateValue}>
|
||||||
|
{children}
|
||||||
|
</AuthStateContext.Provider>
|
||||||
|
</AuthActionsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plny pristup (zpetna kompatibilita) - re-renderuje pri zmene stavu i akci
|
||||||
|
export function useAuth() {
|
||||||
|
const state = useContext(AuthStateContext)
|
||||||
|
const actions = useContext(AuthActionsContext)
|
||||||
|
if (!state || !actions) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return { ...state, ...actions }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pouze stav (user, permissions, loading) - re-renderuje pri zmene uzivatele
|
||||||
|
export function useAuthState() {
|
||||||
|
const context = useContext(AuthStateContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuthState must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pouze akce (login, logout, ...) - stabilni reference, nere-renderuje
|
||||||
|
export function useAuthActions() {
|
||||||
|
const context = useContext(AuthActionsContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuthActions must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthStateContext
|
||||||
544
src/admin/dashboard.css
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Stat Cards
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.admin-stat-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card.success::before { background: var(--success); }
|
||||||
|
.admin-stat-card.warning::before { background: var(--warning); }
|
||||||
|
.admin-stat-card.danger::before { background: var(--danger); }
|
||||||
|
.admin-stat-card.info::before { background: var(--info); }
|
||||||
|
|
||||||
|
.admin-stat-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-footer {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-icon.danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.admin-stat-icon.info { background: var(--info-soft); color: var(--info); }
|
||||||
|
.admin-stat-icon.success { background: var(--success-soft); color: var(--success); }
|
||||||
|
.admin-stat-icon.warning { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Dashboard
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Dashboard layout */
|
||||||
|
.dash {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash .admin-page-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI grid */
|
||||||
|
.dash-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-kpi-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.dash-kpi-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.dash-kpi-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.dash-kpi-1 { grid-template-columns: 1fr; max-width: 320px; }
|
||||||
|
|
||||||
|
/* Quick actions */
|
||||||
|
.dash-quick-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-btn-success { background: var(--success-soft); color: var(--success); }
|
||||||
|
.dash-quick-btn-info { background: var(--info-soft); color: var(--info); }
|
||||||
|
.dash-quick-btn-warning { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
.dash-quick-btn-danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
|
||||||
|
.dash-quick-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .dash-quick-btn-success { background: var(--success); color: #fff; }
|
||||||
|
[data-theme="light"] .dash-quick-btn-info { background: var(--info); color: #fff; }
|
||||||
|
[data-theme="light"] .dash-quick-btn-warning { background: var(--warning); color: #fff; }
|
||||||
|
[data-theme="light"] .dash-quick-btn-danger { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
/* Main content 3-col grid */
|
||||||
|
.dash-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card link */
|
||||||
|
.dash-card-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity rows */
|
||||||
|
.dash-activity-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-activity-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-activity-row:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-activity-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-activity-icon.success { background: var(--success-soft); color: var(--success); }
|
||||||
|
.dash-activity-icon.info { background: var(--info-soft); color: var(--info); }
|
||||||
|
.dash-activity-icon.warning { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
.dash-activity-icon.danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.dash-activity-icon.accent { background: var(--accent-soft); color: var(--accent-color); }
|
||||||
|
.dash-activity-icon.muted { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.dash-activity-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-activity-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-activity-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-activity-time {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Presence rows */
|
||||||
|
.dash-presence-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-presence-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-presence-avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-presence-avatar.dash-status-in { background: var(--success-soft); color: var(--success); }
|
||||||
|
.dash-presence-avatar.dash-status-away { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
.dash-presence-avatar.dash-status-out { background: var(--bg-tertiary); color: var(--text-muted); }
|
||||||
|
.dash-presence-avatar.dash-status-leave { background: var(--info-soft); color: var(--info); }
|
||||||
|
|
||||||
|
.dash-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-status-dot.dash-status-in { background: var(--success); }
|
||||||
|
.dash-status-dot.dash-status-away { background: var(--warning); }
|
||||||
|
.dash-status-dot.dash-status-out { background: var(--text-muted); }
|
||||||
|
.dash-status-dot.dash-status-leave { background: var(--info); }
|
||||||
|
|
||||||
|
.dash-presence-label.dash-status-in { color: var(--success); }
|
||||||
|
.dash-presence-label.dash-status-away { color: var(--warning); }
|
||||||
|
.dash-presence-label.dash-status-out { color: var(--text-muted); }
|
||||||
|
.dash-presence-label.dash-status-leave { color: var(--info); }
|
||||||
|
|
||||||
|
.dash-presence-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-presence-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-presence-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-presence-time {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right column */
|
||||||
|
.dash-right-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project rows */
|
||||||
|
.dash-project-row {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-project-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-project-row:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-project-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-project-customer {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat mini rows */
|
||||||
|
.dash-stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-stat-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty row */
|
||||||
|
.dash-empty-row {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom stacked layout (profile + sessions) */
|
||||||
|
.dash-bottom {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash .admin-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile grid inside account card */
|
||||||
|
.dash-profile-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-profile-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-profile-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-profile-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Responsive
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.dash-main-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-right-col {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-kpi-4 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dash-kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.dash-quick-actions { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
||||||
|
.dash-main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-right-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-bottom {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.dash-quick-actions { grid-template-columns: 1fr 1fr; }
|
||||||
|
.dash-kpi-grid { grid-template-columns: 1fr; }
|
||||||
|
.dash-profile-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Sessions / Devices
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.sessions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-current {
|
||||||
|
background: var(--row-current);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-current:hover {
|
||||||
|
background: var(--row-current-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-current .session-icon {
|
||||||
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-device {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta-separator {
|
||||||
|
color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.session-item {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-device {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/admin/hooks/useApiCall.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import { useAlert } from '../context/AlertContext'
|
||||||
|
import apiFetch from '../utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pro API volani s automatickym error handlingem a AbortControllerem.
|
||||||
|
*
|
||||||
|
* Pouziti:
|
||||||
|
* const apiCall = useApiCall()
|
||||||
|
* const { data, ok } = await apiCall(url, options)
|
||||||
|
*
|
||||||
|
* Vraci { data, ok, response } nebo { data: null, ok: false } pri chybe.
|
||||||
|
* Automaticky zobrazuje alert.error pri selhani (lze potlacit options.silent).
|
||||||
|
*/
|
||||||
|
export default function useApiCall() {
|
||||||
|
const alert = useAlert()
|
||||||
|
const controllerRef = useRef(null)
|
||||||
|
|
||||||
|
const call = useCallback(async (url, options = {}) => {
|
||||||
|
const { silent = false, errorMsg = 'Chyba připojení', ...fetchOpts } = options
|
||||||
|
|
||||||
|
// Zrus predchozi request se stejnym controllerem
|
||||||
|
if (controllerRef.current) {
|
||||||
|
controllerRef.current.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllerRef.current = controller
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(url, { ...fetchOpts, signal: controller.signal })
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return { data: null, ok: false, response }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.success && !silent) {
|
||||||
|
alert.error(data.error || errorMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, ok: data.success, response }
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
return { data: null, ok: false, aborted: true }
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
alert.error(errorMsg)
|
||||||
|
}
|
||||||
|
return { data: null, ok: false }
|
||||||
|
}
|
||||||
|
}, [alert])
|
||||||
|
|
||||||
|
const abort = useCallback(() => {
|
||||||
|
if (controllerRef.current) {
|
||||||
|
controllerRef.current.abort()
|
||||||
|
controllerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { call, abort }
|
||||||
|
}
|
||||||
62
src/admin/hooks/useListData.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useAlert } from '../context/AlertContext'
|
||||||
|
import apiFetch from '../utils/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pro nacitani seznamovych dat s abort kontrolou, hledanim a razenim.
|
||||||
|
*
|
||||||
|
* @param {string} endpoint - PHP endpoint (napr. 'offers.php')
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.dataKey - Klic v result.data (napr. 'quotations')
|
||||||
|
* @param {string} opts.search - Hledany text
|
||||||
|
* @param {string} opts.sort - Sloupec pro razeni
|
||||||
|
* @param {string} opts.order - ASC/DESC
|
||||||
|
* @param {string} [opts.errorMsg] - Chybova zprava pri neuspechu
|
||||||
|
*/
|
||||||
|
export default function useListData(endpoint, { dataKey, search, sort, order, extraParams, errorMsg = 'Nepodařilo se načíst data' } = {}) {
|
||||||
|
const alert = useAlert()
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
const extraParamsStr = extraParams ? JSON.stringify(extraParams) : ''
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (abortRef.current) abortRef.current.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
if (sort) params.set('sort', sort)
|
||||||
|
if (order) params.set('order', order)
|
||||||
|
if (extraParamsStr) {
|
||||||
|
const extra = JSON.parse(extraParamsStr)
|
||||||
|
Object.entries(extra).forEach(([k, v]) => { if (v) params.set(k, v) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(`${API_BASE}/${endpoint}?${params}`, { signal: controller.signal })
|
||||||
|
if (response.status === 401) return
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setItems(result.data[dataKey] || [])
|
||||||
|
} else {
|
||||||
|
alert.error(result.error || errorMsg)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [alert, endpoint, dataKey, search, sort, order, extraParamsStr, errorMsg])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
return () => { if (abortRef.current) abortRef.current.abort() }
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
return { items, setItems, loading, refetch: fetchData }
|
||||||
|
}
|
||||||
54
src/admin/hooks/useModalLock.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
let lockCount = 0
|
||||||
|
|
||||||
|
function preventScroll(e) {
|
||||||
|
let el = e.target
|
||||||
|
while (el && el !== document.body) {
|
||||||
|
if (el.scrollHeight > el.clientHeight) {
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
const overflowY = style.overflowY
|
||||||
|
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el = el.parentElement
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function lock() {
|
||||||
|
if (lockCount === 0) {
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||||
|
|
||||||
|
document.documentElement.style.overflow = 'hidden'
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.addEventListener('touchmove', preventScroll, { passive: false })
|
||||||
|
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
document.body.style.paddingRight = `${scrollbarWidth}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lockCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlock() {
|
||||||
|
lockCount--
|
||||||
|
if (lockCount <= 0) {
|
||||||
|
lockCount = 0
|
||||||
|
|
||||||
|
document.documentElement.style.overflow = ''
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
document.body.style.paddingRight = ''
|
||||||
|
document.removeEventListener('touchmove', preventScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useModalLock(isOpen) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
lock()
|
||||||
|
return () => unlock()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
}
|
||||||
23
src/admin/hooks/useTableSort.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function useTableSort(initialColumn, initialOrder = 'DESC') {
|
||||||
|
const [sort, setSort] = useState(initialColumn)
|
||||||
|
const [order, setOrder] = useState(initialOrder)
|
||||||
|
const userClicked = useRef(false)
|
||||||
|
|
||||||
|
const handleSort = useCallback((column) => {
|
||||||
|
userClicked.current = true
|
||||||
|
setSort(prev => {
|
||||||
|
if (prev === column) {
|
||||||
|
setOrder(o => o === 'ASC' ? 'DESC' : 'ASC')
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
setOrder('DESC')
|
||||||
|
return column
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const activeSort = userClicked.current ? sort : null
|
||||||
|
|
||||||
|
return { sort, order, handleSort, activeSort }
|
||||||
|
}
|
||||||
126
src/admin/invoices.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Invoice Status Badges
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.admin-badge-invoice-issued {
|
||||||
|
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-invoice-paid {
|
||||||
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-invoice-overdue {
|
||||||
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Invoice Month Navigation
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.invoice-month-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-month-nav span {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-month-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-month-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-month-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Received Invoices - Upload Modal
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.received-upload-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-file-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-file-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-file-size {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-card-fields {
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
23
src/admin/leave.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Leave Request Status Badges
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background: color-mix(in srgb, var(--warning) 15%, transparent);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-approved {
|
||||||
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-rejected {
|
||||||
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-cancelled {
|
||||||
|
background: var(--muted-light);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
143
src/admin/login.css
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Login Page
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.admin-login {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
max-height: 100vh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login .bg-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login .bg-orb-1 {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: var(--orb-color-1);
|
||||||
|
top: 10%;
|
||||||
|
left: 20%;
|
||||||
|
animation: float 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login .bg-orb-2 {
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
background: var(--orb-color-2);
|
||||||
|
bottom: 20%;
|
||||||
|
right: 15%;
|
||||||
|
animation: float 25s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-theme-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-theme-btn:hover {
|
||||||
|
background: var(--glass-bg-solid);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color-hover);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
max-height: calc(100dvh - 2rem);
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.admin-login-card {
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-logo {
|
||||||
|
height: 48px;
|
||||||
|
width: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-2fa-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-back-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-back-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
775
src/admin/offers.css
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
/* ============================================
|
||||||
|
Offers Module
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Editor section cards */
|
||||||
|
.offers-editor-section {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings grid */
|
||||||
|
.offers-settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-settings-grid > .admin-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.offers-settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo section */
|
||||||
|
.offers-logo-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-logo-preview {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 100px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-logo-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Items table */
|
||||||
|
.offers-items-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-items-table .admin-table {
|
||||||
|
min-width: 700px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-items-table .admin-table thead th {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 8px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-items-table .admin-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-items-table .admin-table tbody tr {
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-items-table .admin-table tbody tr:hover {
|
||||||
|
background: var(--table-row-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-items-table .admin-table td .admin-form-input {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Totals summary */
|
||||||
|
.offers-totals-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 250px;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-row span:last-child {
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-total {
|
||||||
|
border-top: 2px solid var(--text-primary);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-total span:last-child {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scope sections list wrapper */
|
||||||
|
.offers-scope-list {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scope section card */
|
||||||
|
.offers-scope-section {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: visible;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--border-color) 70%, var(--accent-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header .offers-scope-number {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header .offers-scope-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section-header .offers-scope-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scope-section .admin-form {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Customer selector */
|
||||||
|
.offers-customer-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--input-bg);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-selected span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-selected .admin-btn-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-right: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item div:first-child {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-item div:last-child {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-customer-dropdown-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Template dropdown menu */
|
||||||
|
.offers-template-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 200px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-template-menu-item {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-template-menu-item:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Language badges */
|
||||||
|
.offers-lang-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||||
|
color: var(--info);
|
||||||
|
margin-right: 0.375rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-lang-badge-cz {
|
||||||
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact form row for 3+ columns */
|
||||||
|
.offers-form-row-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.offers-form-row-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs - zachovany pro zpetnou kompatibilitu, nove pouzivat admin-tabs/admin-tab */
|
||||||
|
.offers-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-tab {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-tab.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RichEditor (Quill) */
|
||||||
|
.rich-editor {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .quill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.rich-editor .ql-toolbar.ql-snow {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-toolbar .ql-formats {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar buttons */
|
||||||
|
.rich-editor .ql-snow .ql-stroke {
|
||||||
|
stroke: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow .ql-fill {
|
||||||
|
fill: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow .ql-picker-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-snow button:hover .ql-stroke,
|
||||||
|
.rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
|
||||||
|
stroke: var(--text-primary);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow button:hover .ql-fill,
|
||||||
|
.rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
|
||||||
|
fill: var(--text-primary);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow button:hover,
|
||||||
|
.rich-editor .ql-snow .ql-picker-label:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state */
|
||||||
|
.rich-editor .ql-snow button.ql-active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow button.ql-active .ql-stroke {
|
||||||
|
stroke: var(--accent-color);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow button.ql-active .ql-fill,
|
||||||
|
.rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
|
||||||
|
fill: var(--accent-color);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow .ql-picker-item.ql-selected {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow .ql-picker-label.ql-active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
|
||||||
|
stroke: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdowns (font, size, color, align) */
|
||||||
|
.rich-editor .ql-snow .ql-picker-options {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-snow .ql-picker-item {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.rich-editor .ql-snow .ql-picker-item:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font picker */
|
||||||
|
.rich-editor .ql-snow .ql-font .ql-picker-options { min-width: 11rem; max-height: 200px; overflow-y: auto; }
|
||||||
|
.rich-editor .ql-snow .ql-size .ql-picker-options { max-height: 200px; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* Font labels - vysoka specificita kvuli quill.snow.css */
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="arial"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="arial"]::before { content: 'Arial' !important; font-family: Arial, sans-serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="tahoma"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="tahoma"]::before { content: 'Tahoma' !important; font-family: Tahoma, sans-serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="verdana"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="verdana"]::before { content: 'Verdana' !important; font-family: Verdana, sans-serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="georgia"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="georgia"]::before { content: 'Georgia' !important; font-family: Georgia, serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="times-new-roman"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="times-new-roman"]::before { content: 'Times New Roman' !important; font-family: 'Times New Roman', serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="courier-new"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="courier-new"]::before { content: 'Courier New' !important; font-family: 'Courier New', monospace; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="trebuchet-ms"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="trebuchet-ms"]::before { content: 'Trebuchet MS' !important; font-family: 'Trebuchet MS', sans-serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="impact"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="impact"]::before { content: 'Impact' !important; font-family: Impact, sans-serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="comic-sans-ms"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="comic-sans-ms"]::before { content: 'Comic Sans MS' !important; font-family: 'Comic Sans MS', cursive; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="lucida-console"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="lucida-console"]::before { content: 'Lucida Console' !important; font-family: 'Lucida Console', monospace; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="palatino-linotype"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="palatino-linotype"]::before { content: 'Palatino Linotype' !important; font-family: 'Palatino Linotype', serif; }
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="garamond"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="garamond"]::before { content: 'Garamond' !important; font-family: Garamond, serif; }
|
||||||
|
|
||||||
|
/* Font classes */
|
||||||
|
.ql-font-arial { font-family: Arial, sans-serif; }
|
||||||
|
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
|
||||||
|
.ql-font-verdana { font-family: Verdana, sans-serif; }
|
||||||
|
.ql-font-georgia { font-family: Georgia, serif; }
|
||||||
|
.ql-font-times-new-roman { font-family: 'Times New Roman', serif; }
|
||||||
|
.ql-font-courier-new { font-family: 'Courier New', monospace; }
|
||||||
|
.ql-font-trebuchet-ms { font-family: 'Trebuchet MS', sans-serif; }
|
||||||
|
.ql-font-impact { font-family: Impact, sans-serif; }
|
||||||
|
.ql-font-comic-sans-ms { font-family: 'Comic Sans MS', cursive; }
|
||||||
|
.ql-font-lucida-console { font-family: 'Lucida Console', monospace; }
|
||||||
|
.ql-font-palatino-linotype { font-family: 'Palatino Linotype', serif; }
|
||||||
|
.ql-font-garamond { font-family: Garamond, serif; }
|
||||||
|
|
||||||
|
/* Size picker */
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="8px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="8px"]::before { content: '8px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="9px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="9px"]::before { content: '9px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before { content: '10px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="11px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="11px"]::before { content: '11px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before { content: '12px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before { content: '14px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before { content: '16px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before { content: '18px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before { content: '20px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before { content: '24px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before { content: '28px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before { content: '32px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="36px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="36px"]::before { content: '36px' !important; }
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="48px"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="48px"]::before { content: '48px' !important; }
|
||||||
|
|
||||||
|
/* Editor area */
|
||||||
|
.rich-editor .ql-container.ql-snow {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-editor {
|
||||||
|
min-height: var(--re-min-height, 120px);
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--input-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-editor.ql-blank::before {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists inside editor */
|
||||||
|
.rich-editor .ql-editor ul,
|
||||||
|
.rich-editor .ql-editor ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color picker */
|
||||||
|
.rich-editor .ql-snow .ql-color-picker .ql-picker-options[aria-hidden="false"] {
|
||||||
|
width: 176px;
|
||||||
|
padding: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-snow .ql-color-picker .ql-picker-item {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip (link editor) */
|
||||||
|
.rich-editor .ql-snow .ql-tooltip {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-snow .ql-tooltip input[type="text"] {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor .ql-snow .ql-tooltip a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only rendered rich text (Quill HTML output) */
|
||||||
|
.rich-text-view {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-view ul,
|
||||||
|
.rich-text-view ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-view li {
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-view a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-view strong,
|
||||||
|
.rich-text-view b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-view br + b,
|
||||||
|
.rich-text-view br + strong {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-view > br:first-child,
|
||||||
|
.rich-text-view ul + br,
|
||||||
|
.rich-text-view ol + br {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.offers-editor-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-items-table {
|
||||||
|
margin: 0 -1rem;
|
||||||
|
width: calc(100% + 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-summary {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-totals-row {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Offer draft row in table */
|
||||||
|
.offers-draft-row {
|
||||||
|
background: var(--row-draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-draft-row-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--warning) 14%, transparent);
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expired offer without order */
|
||||||
|
.offers-expired-row {
|
||||||
|
background: var(--row-expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-expired-row td {
|
||||||
|
color: var(--danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-expired-row a {
|
||||||
|
color: var(--danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invalidated offer */
|
||||||
|
.offers-invalidated-row {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-invalidated-row td {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-invalidated-row a {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only form (invalidated offer detail) */
|
||||||
|
.offers-readonly input[readonly],
|
||||||
|
.offers-readonly select:disabled {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Offer draft indicator */
|
||||||
|
.offers-draft-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
23
src/admin/orders.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Order Status Badges
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.admin-badge-order-prijata {
|
||||||
|
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-order-realizace {
|
||||||
|
background: color-mix(in srgb, var(--warning) 15%, transparent);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-order-dokoncena {
|
||||||
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-order-stornovana {
|
||||||
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
883
src/admin/pages/Attendance.jsx
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useAlert } from '../context/AlertContext'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import AdminDatePicker from '../components/AdminDatePicker'
|
||||||
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
|
import useModalLock from '../hooks/useModalLock'
|
||||||
|
import { formatTime, calculateWorkMinutes, formatMinutes } from '../utils/attendanceHelpers'
|
||||||
|
import Forbidden from '../components/Forbidden'
|
||||||
|
import apiFetch from '../utils/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
function pluralizeDays(n) {
|
||||||
|
if (n === 1) return 'den'
|
||||||
|
if (n >= 2 && n <= 4) return 'dny'
|
||||||
|
return 'dnů'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFundBarBackground(fund) {
|
||||||
|
if (fund.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
|
||||||
|
if (fund.covered >= fund.fund) return 'linear-gradient(135deg, var(--success), #059669)'
|
||||||
|
return 'var(--gradient)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Attendance() {
|
||||||
|
const alert = useAlert()
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [data, setData] = useState({
|
||||||
|
ongoing_shift: null,
|
||||||
|
today_shifts: [],
|
||||||
|
date: '',
|
||||||
|
leave_balance: { vacation_total: 160, vacation_used: 0, vacation_remaining: 160, sick_used: 0 },
|
||||||
|
monthly_fund: null
|
||||||
|
})
|
||||||
|
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
||||||
|
const [leaveForm, setLeaveForm] = useState({
|
||||||
|
leave_type: 'vacation',
|
||||||
|
date_from: new Date().toISOString().split('T')[0],
|
||||||
|
date_to: new Date().toISOString().split('T')[0],
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
const [requestSubmitting, setRequestSubmitting] = useState(false)
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [switchingProject, setSwitchingProject] = useState(false)
|
||||||
|
const [projectLogs, setProjectLogs] = useState([])
|
||||||
|
const [activeProjectId, setActiveProjectId] = useState(null)
|
||||||
|
const [gpsConfirm, setGpsConfirm] = useState({ show: false, action: null })
|
||||||
|
const geoAbortRef = useRef(null)
|
||||||
|
|
||||||
|
// Cleanup geocoding fetch pri unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (geoAbortRef.current) geoAbortRef.current.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php`)
|
||||||
|
if (response.status === 401) return
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data)
|
||||||
|
setNotes(result.data.ongoing_shift?.notes || '')
|
||||||
|
setProjectLogs(result.data.project_logs || [])
|
||||||
|
setActiveProjectId(result.data.active_project_id || null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Nepodařilo se načíst data')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [alert])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?action=projects`)
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setProjects(result.data.projects || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent - projects are supplementary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadProjects()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useModalLock(showLeaveModal)
|
||||||
|
|
||||||
|
if (!hasPermission('attendance.record')) return <Forbidden />
|
||||||
|
|
||||||
|
const handlePunch = (action) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert.warning('GPS není dostupná')
|
||||||
|
submitPunch(action, {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const { latitude, longitude, accuracy } = position.coords
|
||||||
|
|
||||||
|
// Punch odeslat hned, adresu doplnit na pozadi
|
||||||
|
submitPunch(action, { latitude, longitude, accuracy, address: '' })
|
||||||
|
|
||||||
|
if (geoAbortRef.current) geoAbortRef.current.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
geoAbortRef.current = controller
|
||||||
|
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, {
|
||||||
|
headers: { 'Accept-Language': 'cs' },
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.display_name) {
|
||||||
|
apiFetch(`${API_BASE}/attendance.php?action=update_address`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ latitude, longitude, address: data.display_name, punch_action: action })
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
let errorMsg = 'Nepodařilo se získat polohu'
|
||||||
|
if (error.code === error.PERMISSION_DENIED) {
|
||||||
|
errorMsg = 'Přístup k poloze byl zamítnut'
|
||||||
|
} else if (error.code === error.TIMEOUT) {
|
||||||
|
errorMsg = 'Vypršel časový limit'
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.error(errorMsg)
|
||||||
|
setGpsConfirm({ show: true, action })
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPunch = async (action, gpsData = {}) => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
punch_action: action,
|
||||||
|
...gpsData
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.status === 401) return
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
setSubmitting(false)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await fetchData()
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.success(result.message)
|
||||||
|
}, 300)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSubmitting(false)
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBreak = async () => {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ punch_action: 'break_start' })
|
||||||
|
})
|
||||||
|
if (response.status === 401) return
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await fetchData()
|
||||||
|
alert.success(result.message)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveNotes = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?action=notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ notes })
|
||||||
|
})
|
||||||
|
if (response.status === 401) return
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert.success('Poznámka byla uložena')
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSwitchProject = async (newProjectId) => {
|
||||||
|
setSwitchingProject(true)
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/attendance.php?action=switch_project`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ project_id: newProjectId || null })
|
||||||
|
})
|
||||||
|
if (response.status === 401) return
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await fetchData()
|
||||||
|
alert.success(result.message)
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setSwitchingProject(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateBusinessDays = (from, to) => {
|
||||||
|
if (!from || !to) return 0
|
||||||
|
const start = new Date(from)
|
||||||
|
const end = new Date(to)
|
||||||
|
if (end < start) return 0
|
||||||
|
let days = 0
|
||||||
|
const current = new Date(start)
|
||||||
|
while (current <= end) {
|
||||||
|
const day = current.getDay()
|
||||||
|
if (day !== 0 && day !== 6) days++
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestSubmit = async () => {
|
||||||
|
setRequestSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_BASE}/leave-requests.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(leaveForm)
|
||||||
|
})
|
||||||
|
if (response.status === 401) return
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setShowLeaveModal(false)
|
||||||
|
await fetchData()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
alert.success(result.message)
|
||||||
|
setLeaveForm({
|
||||||
|
leave_type: 'vacation',
|
||||||
|
date_from: new Date().toISOString().split('T')[0],
|
||||||
|
date_to: new Date().toISOString().split('T')[0],
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
alert.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setRequestSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||||
|
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '1.5rem' }}>
|
||||||
|
<div className="admin-card" style={{ flex: 2 }}>
|
||||||
|
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||||
|
<div className="admin-skeleton-line h-8" style={{ width: '120px', marginBottom: '0.5rem' }} />
|
||||||
|
<div className="admin-skeleton-line h-10" style={{ width: '180px' }} />
|
||||||
|
<div className="admin-skeleton-row">
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
||||||
|
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '100%', height: '6px', borderRadius: '3px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
||||||
|
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
|
||||||
|
<div className="admin-skeleton-line" style={{ width: '100%', height: '6px', borderRadius: '3px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ongoing_shift: ongoingShift, today_shifts: todayShifts, leave_balance: leaveBalance } = data
|
||||||
|
const isOngoingShift = ongoingShift && !ongoingShift.departure_time
|
||||||
|
const completedToday = todayShifts.filter(s => s.departure_time)
|
||||||
|
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8)
|
||||||
|
const vacationHoursRemaining = leaveBalance.vacation_remaining % 8
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<motion.div
|
||||||
|
className="admin-page-header"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-page-title">Docházka</h1>
|
||||||
|
<p className="admin-page-subtitle">
|
||||||
|
{new Date().toLocaleDateString('cs-CZ', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="attendance-layout">
|
||||||
|
{/* Left Column - Clock In/Out */}
|
||||||
|
<motion.div
|
||||||
|
className="attendance-main"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="attendance-clock-card">
|
||||||
|
<div className="attendance-clock-header">
|
||||||
|
<div className="attendance-clock-status">
|
||||||
|
{isOngoingShift ? (
|
||||||
|
<>
|
||||||
|
<span className="attendance-status-dot active" />
|
||||||
|
<span>Pracuji</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="attendance-status-dot" />
|
||||||
|
<span>Nepracuji</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="attendance-clock-time">
|
||||||
|
{new Date().toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOngoingShift ? (
|
||||||
|
<>
|
||||||
|
<div className="attendance-shift-info">
|
||||||
|
<div className="attendance-shift-row">
|
||||||
|
<div className="attendance-shift-item">
|
||||||
|
<span className="attendance-shift-label">Příchod</span>
|
||||||
|
<span className="attendance-shift-value success">
|
||||||
|
{formatTime(ongoingShift.arrival_time)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-shift-item">
|
||||||
|
<span className="attendance-shift-label">Pauza</span>
|
||||||
|
<span className={`attendance-shift-value ${ongoingShift.break_start ? 'success' : ''}`}>
|
||||||
|
{ongoingShift.break_start
|
||||||
|
? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-shift-item">
|
||||||
|
<span className="attendance-shift-label">Odchod</span>
|
||||||
|
<span className="attendance-shift-value">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<div className="attendance-project-section">
|
||||||
|
<div className="attendance-project-header">
|
||||||
|
<span className="attendance-shift-label">Projekt</span>
|
||||||
|
{activeProjectId ? (
|
||||||
|
<span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.8125rem' }}>
|
||||||
|
{projects.find(p => String(p.id) === String(activeProjectId))
|
||||||
|
? `${projects.find(p => String(p.id) === String(activeProjectId)).project_number} – ${projects.find(p => String(p.id) === String(activeProjectId)).name}`
|
||||||
|
: `Projekt #${activeProjectId}`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted" style={{ fontSize: '0.8125rem' }}>Žádný</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={activeProjectId || ''}
|
||||||
|
onChange={(e) => handleSwitchProject(e.target.value || null)}
|
||||||
|
disabled={switchingProject}
|
||||||
|
className="admin-form-select"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
<option value="">— Bez projektu —</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.project_number} – {p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{projectLogs.length > 0 && (
|
||||||
|
<div className="attendance-project-logs">
|
||||||
|
{projectLogs.map((log, i) => {
|
||||||
|
const start = new Date(log.started_at)
|
||||||
|
const end = log.ended_at ? new Date(log.ended_at) : new Date()
|
||||||
|
const mins = Math.floor((end - start) / 60000)
|
||||||
|
const h = Math.floor(mins / 60)
|
||||||
|
const m = mins % 60
|
||||||
|
return (
|
||||||
|
<div key={log.id || i} className="attendance-project-log-item">
|
||||||
|
<span className="attendance-project-log-name">{log.project_name || `Projekt #${log.project_id}`}</span>
|
||||||
|
<span className="attendance-project-log-time">
|
||||||
|
{formatTime(log.started_at)} – {log.ended_at ? formatTime(log.ended_at) : 'nyní'}
|
||||||
|
</span>
|
||||||
|
<span className="attendance-project-log-duration">{h}:{String(m).padStart(2, '0')} h</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="attendance-clock-actions">
|
||||||
|
{!ongoingShift.break_start && (
|
||||||
|
<button
|
||||||
|
onClick={handleBreak}
|
||||||
|
disabled={submitting}
|
||||||
|
className="admin-btn admin-btn-secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
Pauza (30 min)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handlePunch('departure')}
|
||||||
|
disabled={submitting}
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{submitting ? 'Zpracovávám...' : 'Odchod'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLeaveModal(true)}
|
||||||
|
className="admin-btn admin-btn-secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
Žádost o nepřítomnost
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-notes">
|
||||||
|
<label className="attendance-notes-label">Poznámka ke směně</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Co jste dělali během směny..."
|
||||||
|
className="admin-form-textarea"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveNotes}
|
||||||
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
|
>
|
||||||
|
Uložit poznámku
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="attendance-clock-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePunch('arrival')}
|
||||||
|
disabled={submitting}
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{submitting ? 'Zpracovávám...' : 'Příchod'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLeaveModal(true)}
|
||||||
|
className="admin-btn admin-btn-secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
Žádost o nepřítomnost
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Today */}
|
||||||
|
{completedToday.length > 0 && (
|
||||||
|
<div className="admin-card" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h2 className="admin-card-title">Dnešní dokončené směny</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body">
|
||||||
|
<div className="admin-table-responsive">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Příchod</th>
|
||||||
|
<th>Pauza</th>
|
||||||
|
<th>Odchod</th>
|
||||||
|
<th>Odpracováno</th>
|
||||||
|
{projects.length > 0 && <th>Projekty</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{completedToday.map((shift) => {
|
||||||
|
const shiftLogs = shift.project_logs || []
|
||||||
|
return (
|
||||||
|
<tr key={shift.id}>
|
||||||
|
<td className="admin-mono">{formatTime(shift.arrival_time)}</td>
|
||||||
|
<td className="admin-mono">
|
||||||
|
{shift.break_start && shift.break_end
|
||||||
|
? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="admin-mono">{formatTime(shift.departure_time)}</td>
|
||||||
|
<td className="admin-mono">{formatMinutes(calculateWorkMinutes(shift), true)}</td>
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<td>
|
||||||
|
{shiftLogs.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
{shiftLogs.map((log, i) => {
|
||||||
|
const mins = log.ended_at ? Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000) : 0
|
||||||
|
const h = Math.floor(mins / 60)
|
||||||
|
const m = mins % 60
|
||||||
|
return (
|
||||||
|
<span key={log.id || i} style={{ fontSize: '12px' }}>
|
||||||
|
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Right Column - Stats & Quick Links */}
|
||||||
|
<motion.div
|
||||||
|
className="attendance-sidebar"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{/* Leave Balance Card */}
|
||||||
|
<div className="attendance-balance-card">
|
||||||
|
<h3 className="attendance-balance-title">Dovolená {new Date().getFullYear()}</h3>
|
||||||
|
<div className="attendance-balance-value">
|
||||||
|
<span className="attendance-balance-number">{vacationDaysRemaining}</span>
|
||||||
|
<span className="attendance-balance-unit">
|
||||||
|
{pluralizeDays(vacationDaysRemaining)}
|
||||||
|
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-balance-detail">
|
||||||
|
<span>Celkem: {leaveBalance.vacation_total}h</span>
|
||||||
|
<span>Čerpáno: {leaveBalance.vacation_used}h</span>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-balance-bar">
|
||||||
|
<div
|
||||||
|
className="attendance-balance-progress"
|
||||||
|
style={{ width: `${(leaveBalance.vacation_remaining / leaveBalance.vacation_total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Fund Card */}
|
||||||
|
{data.monthly_fund && (
|
||||||
|
<div className="admin-stat-card" style={{ flexDirection: 'column', alignItems: 'stretch' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<div className="admin-stat-icon info">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat-content">
|
||||||
|
<span className="admin-stat-label">{data.monthly_fund.month_name}</span>
|
||||||
|
<span className="admin-stat-value">{data.monthly_fund.worked}h / {data.monthly_fund.fund}h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8125rem', marginBottom: '0.5rem' }}>
|
||||||
|
<span>Odpracováno: {data.monthly_fund.worked}h</span>
|
||||||
|
{data.monthly_fund.overtime > 0 ? (
|
||||||
|
<span className="text-warning fw-600">Přesčas: +{data.monthly_fund.overtime}h</span>
|
||||||
|
) : (
|
||||||
|
<span>Zbývá: {data.monthly_fund.remaining}h</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="attendance-balance-bar">
|
||||||
|
<div
|
||||||
|
className="attendance-balance-progress"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (data.monthly_fund.covered / data.monthly_fund.fund) * 100)}%`,
|
||||||
|
background: getFundBarBackground(data.monthly_fund)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{data.monthly_fund.leave_hours > 0 && (
|
||||||
|
<div className="text-muted" style={{ fontSize: '0.75rem', marginTop: '0.375rem' }}>
|
||||||
|
{'Pokryto: '}{data.monthly_fund.covered}h (práce {data.monthly_fund.worked}h
|
||||||
|
{data.monthly_fund.vacation_hours > 0 && ` + dovolená ${data.monthly_fund.vacation_hours}h`}
|
||||||
|
{data.monthly_fund.sick_hours > 0 && ` + nemoc ${data.monthly_fund.sick_hours}h`}
|
||||||
|
{data.monthly_fund.holiday_hours > 0 && ` + svátek ${data.monthly_fund.holiday_hours}h`}
|
||||||
|
{data.monthly_fund.unpaid_hours > 0 && ` + neplacené ${data.monthly_fund.unpaid_hours}h`}
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sick Leave Card */}
|
||||||
|
<div className="admin-stat-card">
|
||||||
|
<div className="admin-stat-icon danger">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat-content">
|
||||||
|
<span className="admin-stat-label">Nemoc {new Date().getFullYear()}</span>
|
||||||
|
<span className="admin-stat-value">{leaveBalance.sick_used}h čerpáno</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="attendance-quick-links">
|
||||||
|
<h4 className="attendance-quick-title">Rychlé odkazy</h4>
|
||||||
|
<Link to="/attendance/requests" className="attendance-quick-link">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
|
</svg>
|
||||||
|
<span>Moje žádosti</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link to="/attendance/history" className="attendance-quick-link">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 3v18h18" />
|
||||||
|
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3" />
|
||||||
|
</svg>
|
||||||
|
<span>Historie docházky</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
{hasPermission('attendance.admin') && (
|
||||||
|
<Link to="/attendance/admin" className="attendance-quick-link">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
<span>Správa docházky</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{hasPermission('attendance.balances') && (
|
||||||
|
<Link to="/attendance/balances" className="attendance-quick-link">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
|
</svg>
|
||||||
|
<span>Správa bilancí</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leave Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showLeaveModal && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-backdrop" onClick={() => setShowLeaveModal(false)} />
|
||||||
|
<motion.div
|
||||||
|
className="admin-modal"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-header">
|
||||||
|
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Typ nepřítomnosti</label>
|
||||||
|
<select
|
||||||
|
value={leaveForm.leave_type}
|
||||||
|
onChange={(e) => setLeaveForm({ ...leaveForm, leave_type: e.target.value })}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
<option value="vacation">Dovolená</option>
|
||||||
|
<option value="sick">Nemoc</option>
|
||||||
|
<option value="unpaid">Neplacené volno</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Od</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={leaveForm.date_from}
|
||||||
|
onChange={(val) => {
|
||||||
|
setLeaveForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
date_from: val,
|
||||||
|
date_to: prev.date_to < val ? val : prev.date_to
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Do</label>
|
||||||
|
<AdminDatePicker
|
||||||
|
mode="date"
|
||||||
|
value={leaveForm.date_to}
|
||||||
|
minDate={leaveForm.date_from}
|
||||||
|
onChange={(val) => setLeaveForm({ ...leaveForm, date_to: val })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leaveForm.date_from && leaveForm.date_to && (
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
<span>
|
||||||
|
<strong>{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)}</strong>{' '}
|
||||||
|
{(() => {
|
||||||
|
const d = calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)
|
||||||
|
if (d === 1) return 'pracovní den'
|
||||||
|
if (d >= 2 && d <= 4) return 'pracovní dny'
|
||||||
|
return 'pracovních dnů'
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted">
|
||||||
|
{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) * 8} hodin
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-form-group">
|
||||||
|
<label className="admin-form-label">Poznámka</label>
|
||||||
|
<textarea
|
||||||
|
value={leaveForm.notes}
|
||||||
|
onChange={(e) => setLeaveForm({ ...leaveForm, notes: e.target.value })}
|
||||||
|
placeholder="Volitelná poznámka..."
|
||||||
|
className="admin-form-textarea"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLeaveModal(false)}
|
||||||
|
className="admin-btn admin-btn-secondary"
|
||||||
|
disabled={requestSubmitting}
|
||||||
|
>
|
||||||
|
Zrušit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRequestSubmit}
|
||||||
|
disabled={requestSubmitting || calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0}
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
>
|
||||||
|
{requestSubmitting ? 'Odesílám...' : 'Odeslat žádost'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={gpsConfirm.show}
|
||||||
|
onClose={() => { setGpsConfirm({ show: false, action: null }); setSubmitting(false) }}
|
||||||
|
onConfirm={() => { setGpsConfirm({ show: false, action: null }); submitPunch(gpsConfirm.action, {}) }}
|
||||||
|
title="GPS nedostupná"
|
||||||
|
message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?"
|
||||||
|
confirmText="Pokračovat"
|
||||||
|
cancelText="Zrušit"
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||