feat: dist/ pridan do repa pro server deploy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 09:19:40 +01:00
parent 1d27d19157
commit b2a2937a35
119 changed files with 15628 additions and 1 deletions

2
.gitignore vendored
View File

@@ -10,7 +10,7 @@ example_design/
sql/
# Build
dist/
# dist/
# IDE
.vscode/

152
dist/api/admin/attendance.php vendored Normal file
View File

@@ -0,0 +1,152 @@
<?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';
require_once __DIR__ . '/handlers/attendance-handlers.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
// ============================================================================

94
dist/api/admin/audit-log.php vendored Normal file
View File

@@ -0,0 +1,94 @@
<?php
/**
* Audit Log API - prohlížení audit logu
*
* GET /api/admin/audit-log.php
* ?page=1&per_page=50&search=&action=&entity_type=&date_from=&date_to=
*/
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');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'], true)) {
errorResponse('Method not allowed', 405);
}
requirePermission($authData, 'settings.audit');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = getJsonInput();
$action = $input['action'] ?? '';
if ($action !== 'cleanup') {
errorResponse('Neplatná akce');
}
$days = (int) ($input['days'] ?? 90);
$pdo = db();
if ($days === 0) {
$stmt = $pdo->query('DELETE FROM audit_logs');
$deleted = $stmt->rowCount();
$msg = $deleted > 0
? "Smazáno všech $deleted záznamů"
: 'Audit log je prázdný';
} else {
$days = max(1, $days);
$stmt = $pdo->prepare(
'DELETE FROM audit_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)'
);
$stmt->execute([$days]);
$deleted = $stmt->rowCount();
$msg = $deleted > 0
? "Smazáno $deleted záznamů starších $days dní"
: "Žádné záznamy starší než $days dní nebyly nalezeny";
}
successResponse(['deleted' => $deleted], $msg);
}
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50)));
$filters = [];
if (!empty($_GET['search'])) {
$filters['search'] = (string) $_GET['search'];
}
if (!empty($_GET['action'])) {
$filters['action'] = (string) $_GET['action'];
}
if (!empty($_GET['entity_type'])) {
$filters['entity_type'] = (string) $_GET['entity_type'];
}
if (!empty($_GET['date_from'])) {
$filters['date_from'] = (string) $_GET['date_from'];
}
if (!empty($_GET['date_to'])) {
$filters['date_to'] = (string) $_GET['date_to'];
}
$result = AuditLog::getLogs($filters, $page, $perPage);
successResponse($result);

70
dist/api/admin/bank-accounts.php vendored Normal file
View File

@@ -0,0 +1,70 @@
<?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';
require_once __DIR__ . '/handlers/bank-accounts-handlers.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);
}
}

71
dist/api/admin/company-settings.php vendored Normal file
View File

@@ -0,0 +1,71 @@
<?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';
require_once __DIR__ . '/handlers/company-settings-handlers.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);
}
}

79
dist/api/admin/customers.php vendored Normal file
View File

@@ -0,0 +1,79 @@
<?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';
require_once __DIR__ . '/handlers/customers-handlers.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);
}
}

281
dist/api/admin/dashboard.php vendored Normal file
View 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);

View File

@@ -0,0 +1,613 @@
<?php
declare(strict_types=1);
function handleGetCurrent(PDO $pdo, int $userId): void
{
$dbTime = getDbNow($pdo);
$today = $dbTime['today'];
$stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng,
arrival_accuracy, arrival_address, break_start, break_end,
departure_time, departure_lat, departure_lng, departure_accuracy,
departure_address, notes, project_id, leave_type, leave_hours, created_at
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 id, attendance_id, project_id, started_at, ended_at, hours, minutes
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 id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng,
arrival_accuracy, arrival_address, break_start, break_end,
departure_time, departure_lat, departure_lng, departure_accuracy,
departure_address, notes, project_id, leave_type, leave_hours, created_at
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 id, attendance_id, project_id, started_at, ended_at, hours, minutes
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 = $dbTime['year'];
$currentMonth = $dbTime['month'];
$fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth);
$businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth);
$startDate = substr($dbTime['today'], 0, 7) . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$stmt = $pdo->prepare('
SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
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 id, user_id, shift_date, arrival_time, arrival_address,
break_start, break_end, departure_time, departure_address,
notes, project_id, leave_type, leave_hours, created_at
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'] ?? '';
$dbTime = getDbNow($pdo);
$today = $dbTime['today'];
$rawNow = $dbTime['now'];
$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 id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, created_at
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 = getDbNow($pdo)['now'];
$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 id, attendance_id, project_id, started_at, ended_at, hours, minutes
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 id 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');
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
function handleGetBankAccountList(PDO $pdo): void
{
$stmt = $pdo->query(
'SELECT id, account_name, bank_name, account_number, iban, bic,
currency, is_default, position, created_at, modified_at
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 id, account_name, bank_name, account_number, iban, bic,
currency, is_default, position
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 id, account_name 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');
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
/**
* @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 id, company_name, company_id, vat_id, street, city, postal_code,
country, quotation_prefix, default_currency, default_vat_rate,
custom_fields, logo_data, uuid, modified_at, sync_version,
order_type_code, invoice_type_code, is_deleted, require_2fa
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();
}

View File

@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
/** @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.id, c.name, c.street, c.city, c.postal_code, c.country,
c.company_id, c.vat_id, c.custom_fields, c.created_at,
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 id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields, created_at
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 id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
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 id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
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 id, name 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');
}

View File

@@ -0,0 +1,696 @@
<?php
declare(strict_types=1);
/** @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'))));
$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
{
$statusFilter = trim($_GET['status'] ?? '');
$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',
];
$p = PaginationHelper::parseParams($sortMap);
$where = 'WHERE 1=1';
$params = [];
if ($p['search']) {
$where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)';
$searchParam = "%{$p['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);
}
}
$from = "FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
LEFT JOIN orders o ON i.order_id = o.id";
$result = PaginationHelper::paginate(
$pdo,
"SELECT COUNT(*) FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id {$where}",
"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} {$where}
ORDER BY {$p['sort']} {$p['order']}",
$params,
$p
);
$invoices = $result['items'];
// 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,
'pagination' => $result['pagination'],
]);
}
function handleGetDetail(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('
SELECT i.id, i.invoice_number, i.order_id, i.customer_id, i.status,
i.currency, i.vat_rate, i.apply_vat, i.payment_method,
i.constant_symbol, i.bank_name, i.bank_swift, i.bank_iban,
i.bank_account, i.issue_date, i.due_date, i.tax_date,
i.paid_date, i.issued_by, i.notes, i.internal_notes,
i.created_at, i.modified_at,
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 id, invoice_id, description, quantity, unit, unit_price, vat_rate, position
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 id, order_id, description, item_description, quantity, unit,
unit_price, is_included_in_total, position
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 id, 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, paid_date,
issued_by, notes, internal_notes
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 id, invoice_number, customer_id 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;
}
}

View File

@@ -0,0 +1,481 @@
<?php
declare(strict_types=1);
/**
* 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 id, user_id, year, 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'],
];
}
/**
* 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.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
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.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
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.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
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 id, user_id, leave_type, date_from, date_to, total_hours,
total_days, notes, status
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 id, user_id, leave_type, date_from, date_to, total_hours,
total_days, status
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 id, user_id, status 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');
}

View File

@@ -0,0 +1,586 @@
<?php
declare(strict_types=1);
function handleGetList(PDO $pdo): void
{
$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',
];
$p = PaginationHelper::parseParams($sortMap);
$where = 'WHERE 1=1';
$params = [];
if ($p['search']) {
$where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
$searchParam = "%{$p['search']}%";
$params = [$searchParam, $searchParam, $searchParam];
}
$from = "FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id";
$result = PaginationHelper::paginate(
$pdo,
"SELECT COUNT(*) {$from} {$where}",
"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} {$where}
ORDER BY {$p['sort']} {$p['order']}",
$params,
$p
);
successResponse([
'quotations' => $result['items'],
'pagination' => $result['pagination'],
]);
}
function handleGetDetail(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('
SELECT q.id, q.quotation_number, q.project_code, q.customer_id,
q.created_at, q.valid_until, q.currency, q.language,
q.vat_rate, q.apply_vat, q.exchange_rate, q.order_id,
q.status, q.scope_title, q.scope_description,
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 id, quotation_id, position, description, item_description,
quantity, unit, unit_price, is_included_in_total
FROM quotation_items
WHERE quotation_id = ?
ORDER BY position
');
$stmt->execute([$id]);
$quotation['items'] = $stmt->fetchAll();
// Get scope sections
$stmt = $pdo->prepare('
SELECT id, quotation_id, position, title, title_cz, content
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 id, quotation_number, project_code, customer_id, created_at,
valid_until, currency, language, vat_rate, apply_vat,
exchange_rate, order_id, status, scope_title, scope_description
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 id, quotation_number, project_code, customer_id, currency,
language, vat_rate, apply_vat, exchange_rate,
scope_title, scope_description
FROM quotations WHERE id = ?'
);
$stmt->execute([$sourceId]);
$source = $stmt->fetch();
if (!$source) {
errorResponse('Zdrojová nabídka nebyla nalezena', 404);
}
$stmt = $pdo->prepare(
'SELECT description, item_description, quantity, unit, unit_price,
is_included_in_total, position
FROM quotation_items WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$sourceId]);
$sourceItems = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT title, title_cz, content, position
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),
]);
}
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
function handleGetItemTemplates(PDO $pdo): void
{
$stmt = $pdo->query(
'SELECT id, name, description, default_price, category
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 id, name, title, description FROM scope_templates ORDER BY name'
);
successResponse(['templates' => $stmt->fetchAll()]);
}
function handleGetScopeDetail(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare(
'SELECT id, name, title, description FROM scope_templates WHERE id = ?'
);
$stmt->execute([$id]);
$template = $stmt->fetch();
if (!$template) {
errorResponse('Šablona nebyla nalezena', 404);
}
$stmt = $pdo->prepare(
'SELECT id, scope_template_id, position, title, title_cz, content
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;
}
}

View File

@@ -0,0 +1,528 @@
<?php
declare(strict_types=1);
/** @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
{
$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',
];
$p = PaginationHelper::parseParams($sortMap);
$where = 'WHERE 1=1';
$params = [];
if ($p['search']) {
$where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
$searchParam = "%{$p['search']}%";
$params = [$searchParam, $searchParam, $searchParam, $searchParam];
}
$from = "FROM orders o
LEFT JOIN quotations q ON o.quotation_id = q.id
LEFT JOIN customers c ON o.customer_id = c.id";
$result = PaginationHelper::paginate(
$pdo,
"SELECT COUNT(*) {$from} {$where}",
"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} {$where}
ORDER BY {$p['sort']} {$p['order']}",
$params,
$p
);
successResponse([
'orders' => $result['items'],
'pagination' => $result['pagination'],
]);
}
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 id, order_id, description, item_description, quantity, unit,
unit_price, is_included_in_total, position
FROM order_items WHERE order_id = ? ORDER BY position'
);
$stmt->execute([$id]);
$order['items'] = $stmt->fetchAll();
// Get sections
$stmt = $pdo->prepare(
'SELECT id, order_id, title, title_cz, content, position
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 id, quotation_number, project_code, customer_id, currency,
language, vat_rate, apply_vat, exchange_rate, order_id,
scope_title, scope_description
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 description, item_description, quantity, unit,
unit_price, is_included_in_total, position
FROM quotation_items WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$quotationId]);
$quotationItems = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT title, title_cz, content, position
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 id, order_number, status, notes 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 id, order_number, quotation_id 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;
}
}

View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
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 id, project_number, name, order_id, status 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
{
$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',
];
$p = PaginationHelper::parseParams($sortMap);
$where = 'WHERE 1=1';
$params = [];
if ($p['search']) {
$where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)';
$searchParam = "%{$p['search']}%";
$params = [$searchParam, $searchParam, $searchParam];
}
$from = "FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN orders o ON p.order_id = o.id";
$result = PaginationHelper::paginate(
$pdo,
"SELECT COUNT(*) {$from} {$where}",
"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} {$where}
ORDER BY {$p['sort']} {$p['order']}",
$params,
$p
);
successResponse([
'projects' => $result['items'],
'pagination' => $result['pagination'],
]);
}
function handleGetDetail(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('
SELECT p.id, p.project_number, p.name, p.customer_id,
p.quotation_id, p.order_id, p.status,
p.start_date, p.end_date, p.notes,
p.created_at, p.modified_at,
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 id, project_number, name, status, start_date, end_date, notes
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');
}

View File

@@ -0,0 +1,511 @@
<?php
declare(strict_types=1);
/** @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 id, supplier_name, invoice_number, description,
amount, currency, vat_rate, vat_amount,
issue_date, due_date, paid_date, status, notes
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');
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
/**
* 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.id, r.name, r.display_name, r.description, r.created_at,
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 id, name, display_name, description 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 id, name, display_name, description 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');
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/** @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];
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
/**
* 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,
];
}

View File

@@ -0,0 +1,426 @@
<?php
declare(strict_types=1);
use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\Providers\Qr\QRServerProvider;
function getTfa(): TwoFactorAuth
{
static $tfa = null;
if ($tfa === null) {
$tfa = new TwoFactorAuth(new QRServerProvider(), 'BOHA Automation');
}
return $tfa;
}
/** 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.id, u.username, u.email, u.first_name, u.last_name,
u.role_id, u.is_active, u.totp_secret, 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.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.id, u.username, u.email, u.first_name, u.last_name,
u.role_id, u.is_active, u.totp_enabled, u.totp_backup_codes,
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 id, user_id, token_hash, expires_at, created_at
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é');
}

View File

@@ -0,0 +1,685 @@
<?php
declare(strict_types=1);
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.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
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.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
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.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
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.id, v.spz, v.name, v.brand, v.model,
v.initial_km, v.actual_km, v.is_active,
v.created_at, v.updated_at,
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 id, vehicle_id, user_id, trip_date, start_km, end_km,
route_from, route_to, is_business, notes
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 id, vehicle_id, user_id, trip_date, start_km, end_km,
route_from, route_to, is_business, notes
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 id, spz, name, brand, model, is_active 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.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
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),
],
]);
}

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
/**
* 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 id, username, email, first_name, last_name, role_id, is_active,
last_login, created_at
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');
}

1113
dist/api/admin/invoices-pdf.php vendored Normal file

File diff suppressed because it is too large Load Diff

110
dist/api/admin/invoices.php vendored Normal file
View File

@@ -0,0 +1,110 @@
<?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';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/invoices-handlers.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();
// Overdue detekce - jednou pred dispatchem (misto v kazdem handleru)
if ($method === 'GET') {
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
}
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> */

80
dist/api/admin/leave-requests.php vendored Normal file
View File

@@ -0,0 +1,80 @@
<?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/Mailer.php';
require_once dirname(__DIR__) . '/includes/LeaveNotification.php';
require_once __DIR__ . '/handlers/leave-requests-handlers.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
// ============================================================================

180
dist/api/admin/login.php vendored Normal file
View 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
dist/api/admin/logout.php vendored Normal file
View 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é');

879
dist/api/admin/offers-pdf.php vendored Normal file
View File

@@ -0,0 +1,879 @@
<?php
/**
* 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 id, quotation_number, project_code, customer_id, created_at,
valid_until, currency, language, vat_rate, apply_vat,
exchange_rate, scope_title, scope_description
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 id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
FROM customers WHERE id = ?'
);
$stmt->execute([$quotation['customer_id']]);
$customer = $stmt->fetch();
}
$stmt = $pdo->prepare(
'SELECT id, quotation_id, position, description, item_description,
quantity, unit, unit_price, is_included_in_total
FROM quotation_items WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$id]);
$items = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT id, quotation_id, position, title, title_cz, content
FROM scope_sections WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$id]);
$sections = $stmt->fetchAll();
$stmt = $pdo->query(
'SELECT id, company_name, company_id, vat_id, street, city,
postal_code, country, custom_fields, logo_data,
quotation_prefix, default_currency, default_vat_rate
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 &nbsp; with regular spaces in text content (not inside tags)
$html = preg_replace_callback(
'/(<[^>]*>)|(&nbsp;)/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);
}
}

101
dist/api/admin/offers-templates.php vendored Normal file
View File

@@ -0,0 +1,101 @@
<?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';
require_once __DIR__ . '/handlers/offers-templates-handlers.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 ---

96
dist/api/admin/offers.php vendored Normal file
View File

@@ -0,0 +1,96 @@
<?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';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/offers-handlers.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);
}
}

92
dist/api/admin/orders.php vendored Normal file
View File

@@ -0,0 +1,92 @@
<?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';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/orders-handlers.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> */

117
dist/api/admin/profile.php vendored Normal file
View File

@@ -0,0 +1,117 @@
<?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 id, username, email, first_name, last_name, role_id, is_active,
last_login, created_at
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);
}

115
dist/api/admin/projects.php vendored Normal file
View File

@@ -0,0 +1,115 @@
<?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&noteId=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';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/projects-handlers.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 ---

97
dist/api/admin/received-invoices.php vendored Normal file
View File

@@ -0,0 +1,97 @@
<?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';
require_once __DIR__ . '/handlers/received-invoices-handlers.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> */

59
dist/api/admin/refresh.php vendored Normal file
View 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');

67
dist/api/admin/roles.php vendored Normal file
View File

@@ -0,0 +1,67 @@
<?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';
require_once __DIR__ . '/handlers/roles-handlers.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);
}

94
dist/api/admin/session.php vendored Normal file
View File

@@ -0,0 +1,94 @@
<?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';
require_once __DIR__ . '/handlers/session-handlers.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);
}
$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,
]);

67
dist/api/admin/sessions.php vendored Normal file
View File

@@ -0,0 +1,67 @@
<?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';
require_once __DIR__ . '/handlers/sessions-handlers.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);
}
}

72
dist/api/admin/totp.php vendored Normal file
View File

@@ -0,0 +1,72 @@
<?php
/**
* 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 __DIR__ . '/handlers/totp-handlers.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'] ?? '';
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);
}

132
dist/api/admin/trips.php vendored Normal file
View File

@@ -0,0 +1,132 @@
<?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';
require_once __DIR__ . '/handlers/trips-handlers.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
// ============================================================================

73
dist/api/admin/users.php vendored Normal file
View File

@@ -0,0 +1,73 @@
<?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';
require_once __DIR__ . '/handlers/users-handlers.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);
}
}

58
dist/api/cleanup.php vendored Normal file
View File

@@ -0,0 +1,58 @@
<?php
/**
* Cleanup CLI script - maze stare rate limit soubory a audit log zaznamy.
*
* Pouziti: php api/cleanup.php
* Doporuceny cron: 0 3 * * * php /path/to/api/cleanup.php
*/
declare(strict_types=1);
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo 'Pouze CLI';
exit(1);
}
require_once __DIR__ . '/config.php';
$rateLimitDir = __DIR__ . '/rate_limits';
$rateLimitMaxAge = 24 * 60 * 60; // 24 hodin
$auditLogMaxDays = 90;
$deleted = 0;
$errors = 0;
// Rate limit soubory starsi 24h
if (is_dir($rateLimitDir)) {
$now = time();
$files = glob($rateLimitDir . '/*.json');
if ($files !== false) {
foreach ($files as $file) {
$age = $now - filemtime($file);
if ($age > $rateLimitMaxAge) {
if (unlink($file)) {
$deleted++;
} else {
$errors++;
}
}
}
}
}
echo "Rate limits: smazano {$deleted} souboru" . ($errors > 0 ? " ({$errors} chyb)" : '') . "\n";
// Audit log zaznamy starsi 90 dni
try {
$pdo = db();
$stmt = $pdo->prepare(
'DELETE FROM audit_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)'
);
$stmt->execute([$auditLogMaxDays]);
$auditDeleted = $stmt->rowCount();
echo "Audit log: smazano {$auditDeleted} zaznamu starsich {$auditLogMaxDays} dni\n";
} catch (PDOException $e) {
echo "Audit log: chyba - {$e->getMessage()}\n";
}

25
dist/api/config.php vendored Normal file
View File

@@ -0,0 +1,25 @@
<?php
/**
* API Configuration Bootstrap
*
* Nacte helper funkce, env promenne a konstanty.
* Toto je jediny soubor, ktery API endpointy musi require_once.
*/
declare(strict_types=1);
require_once __DIR__ . '/includes/helpers.php';
require_once dirname(__DIR__) . '/vendor/autoload.php';
loadEnv(__DIR__ . '/.env');
require_once __DIR__ . '/includes/constants.php';
if (DEBUG_MODE) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
}

991
dist/api/includes/AttendanceAdmin.php vendored Normal file
View File

@@ -0,0 +1,991 @@
<?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.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_address,
a.break_start, a.break_end, a.departure_time, a.departure_address,
a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at,
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 id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
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.id, a.user_id, a.shift_date, a.arrival_time,
a.arrival_lat, a.arrival_lng, a.arrival_accuracy, a.arrival_address,
a.break_start, a.break_end, a.departure_time,
a.departure_lat, a.departure_lng, a.departure_accuracy,
a.departure_address, a.notes, a.project_id,
a.leave_type, a.leave_hours, a.created_at,
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 id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
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 id, user_id, shift_date, leave_type, leave_hours
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.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_address,
a.break_start, a.break_end, a.departure_time, a.departure_address,
a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at,
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,
]);
}

386
dist/api/includes/AttendanceHelpers.php vendored Normal file
View File

@@ -0,0 +1,386 @@
<?php
/**
* Attendance helper functions - shared between user and admin handlers
*/
declare(strict_types=1);
/**
* Vraci aktualni cas a datum z MySQL (jednotny zdroj casu)
* @return array{now: string, today: string, year: int, month: int}
*/
function getDbNow(PDO $pdo): array
{
$row = $pdo->query("SELECT NOW() AS now, CURDATE() AS today, YEAR(NOW()) AS y, MONTH(NOW()) AS m")->fetch();
return [
'now' => $row['now'],
'today' => $row['today'],
'year' => (int)$row['y'],
'month' => (int)$row['m'],
];
}
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 id, attendance_id, project_id, started_at, ended_at, hours, minutes
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);
$stmt = $pdo->prepare("
SELECT DISTINCT user_id FROM attendance
WHERE shift_date = CURDATE()
AND arrival_time IS NOT NULL
AND departure_time IS NULL
AND (leave_type IS NULL OR leave_type = 'work')
");
$stmt->execute();
$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 [];
}
}

560
dist/api/includes/AuditLog.php vendored Normal file
View File

@@ -0,0 +1,560 @@
<?php
/**
* Audit Logging System
*
* Comprehensive audit trail for all administrative actions
*/
declare(strict_types=1);
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 id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
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 id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
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 id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
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
dist/api/includes/CnbRates.php vendored Normal file
View 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
dist/api/includes/CzechHolidays.php vendored Normal file
View 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
dist/api/includes/Encryption.php vendored Normal file
View 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
dist/api/includes/JWTAuth.php vendored Normal file
View File

@@ -0,0 +1,663 @@
<?php
/**
* 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);
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.id, rt.user_id, rt.token_hash, rt.expires_at,
rt.replaced_at, rt.remember_me,
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;
}
}
}

90
dist/api/includes/LeaveNotification.php vendored Normal file
View File

@@ -0,0 +1,90 @@
<?php
/**
* Leave Request Email Notifications
*
* Sends email notifications when leave requests are created.
*/
declare(strict_types=1);
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>
" . (env('APP_URL', '') ? "
<p style='margin-top: 20px;'>
<a href='" . htmlspecialchars(env('APP_URL', '')) . "/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.<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
dist/api/includes/Mailer.php vendored Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* 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', 'System');
$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;
}
}

84
dist/api/includes/PaginationHelper.php vendored Normal file
View File

@@ -0,0 +1,84 @@
<?php
/**
* Pagination helper - extrakce spolecne logiky pro strankovani seznamu.
*/
declare(strict_types=1);
class PaginationHelper
{
private const DEFAULT_PER_PAGE = 25;
private const MAX_PER_PAGE = 500;
/**
* Nacte pagination parametry z GET requestu.
*
* @param array<string, string> $sortMap
* @return array{page: int, per_page: int, sort: string, order: string, search: string}
*/
public static function parseParams(array $sortMap, string $defaultSort = 'created_at'): array
{
$sort = $_GET['sort'] ?? $defaultSort;
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(self::MAX_PER_PAGE, max(1, (int) ($_GET['per_page'] ?? self::DEFAULT_PER_PAGE)));
$search = trim($_GET['search'] ?? '');
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
return [
'page' => $page,
'per_page' => $perPage,
'sort' => $sortMap[$sort],
'order' => $order,
'search' => $search ? mb_substr($search, 0, 100) : '',
];
}
/**
* Spusti COUNT + SELECT dotazy s pagination a vrati vysledek.
*
* @param PDO $pdo
* @param string $countSql - COUNT(*) dotaz
* @param string $dataSql - SELECT dotaz (bez LIMIT/OFFSET)
* @param array<int, mixed> $params - parametry pro prepared statement
* @param array{page: int, per_page: int, sort: string, order: string} $pagination
* @return array{items: array<int, array<string, mixed>>,
* pagination: array{total: int, page: int, per_page: int, total_pages: int}}
*/
public static function paginate(
PDO $pdo,
string $countSql,
string $dataSql,
array $params,
array $pagination
): array {
$page = $pagination['page'];
$perPage = $pagination['per_page'];
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage;
$totalPages = (int) ceil($total / $perPage);
$sql = "{$dataSql} LIMIT {$perPage} OFFSET {$offset}";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll();
return [
'items' => $items,
'pagination' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
],
];
}
}

220
dist/api/includes/RateLimiter.php vendored Normal file
View 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);
}
}
}

139
dist/api/includes/Validator.php vendored Normal file
View File

@@ -0,0 +1,139 @@
<?php
/**
* Validacni helper pro API vstupy.
*
* Pouziti:
* $v = new Validator($input);
* $v->required('name')->string('name', 1, 255);
* $v->required('email')->email('email');
* $v->int('amount', 0, 1000000);
* $v->in('status', ['active', 'inactive']);
* if ($v->fails()) errorResponse($v->firstError());
*/
declare(strict_types=1);
class Validator
{
/** @var array<string, mixed> */
private array $data;
/** @var array<string, string> */
private array $errors = [];
/** @param array<string, mixed> $data */
public function __construct(array $data)
{
$this->data = $data;
}
public function required(string $field, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
$this->errors[$field] = ($label ?: $field) . ' je povinné pole';
}
return $this;
}
public function string(string $field, int $min = 0, int $max = 0, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_string($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být text';
return $this;
}
$len = mb_strlen($value);
if ($min > 0 && $len < $min) {
$this->errors[$field] = ($label ?: $field) . " musí mít alespoň {$min} znaků";
} elseif ($max > 0 && $len > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max} znaků";
}
return $this;
}
public function int(string $field, ?int $min = null, ?int $max = null, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_numeric($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být číslo';
return $this;
}
$intVal = (int) $value;
if ($min !== null && $intVal < $min) {
$this->errors[$field] = ($label ?: $field) . " musí být alespoň {$min}";
} elseif ($max !== null && $intVal > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max}";
}
return $this;
}
public function email(string $field, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field] = ($label ?: $field) . ' musí být platný e-mail';
}
return $this;
}
/**
* @param list<string> $allowed
*/
public function in(string $field, array $allowed, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!in_array($value, $allowed, true)) {
$this->errors[$field] = ($label ?: $field) . ' má neplatnou hodnotu';
}
return $this;
}
public function numeric(string $field, ?float $min = null, ?float $max = null, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_numeric($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být číslo';
return $this;
}
$numVal = (float) $value;
if ($min !== null && $numVal < $min) {
$this->errors[$field] = ($label ?: $field) . " musí být alespoň {$min}";
} elseif ($max !== null && $numVal > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max}";
}
return $this;
}
public function fails(): bool
{
return count($this->errors) > 0;
}
public function firstError(): string
{
return reset($this->errors) ?: '';
}
/** @return array<string, string> */
public function errors(): array
{
return $this->errors;
}
}

38
dist/api/includes/constants.php vendored Normal file
View File

@@ -0,0 +1,38 @@
<?php
/**
* Aplikacni konstanty
*
* Definuje konstanty pouzivane v celé API.
* Vyzaduje, aby byl pred includovanim tohoto souboru nacten helpers.php a .env.
*/
declare(strict_types=1);
// Environment
define('APP_ENV', env('APP_ENV', 'production'));
define('DEBUG_MODE', APP_ENV === 'local');
// 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 - konfigurovatelne pres env (comma-separated), fallback na hardcoded hodnoty
define('CORS_ALLOWED_ORIGINS', env('CORS_ALLOWED_ORIGINS', '')
? array_map('trim', explode(',', (string) env('CORS_ALLOWED_ORIGINS', '')))
: ['http://www.boha-automation.cz', 'https://www.boha-automation.cz']);
// Paths
define('API_ROOT', dirname(__DIR__));
define('INCLUDES_PATH', API_ROOT . '/includes');
// Rate limiting
define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits');

357
dist/api/includes/helpers.php vendored Normal file
View File

@@ -0,0 +1,357 @@
<?php
/**
* Sdilene helper funkce pro API
*
* Definuje pomocne funkce pouzivane v celé API.
* Tento soubor NEDELA zadne side effects - jen definuje funkce.
*/
declare(strict_types=1);
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]);
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;
}
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? $default;
}
/**
* 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();
$stmt = $pdo->prepare('
SELECT r.name FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = ?
');
$stmt->execute([$userId]);
$role = $stmt->fetch();
if ($role && $role['name'] === 'admin') {
$stmt = $pdo->query('SELECT name FROM permissions');
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $cache[$userId];
}
$stmt = $pdo->prepare('
SELECT p.name
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
JOIN users u ON u.role_id = rp.role_id
WHERE u.id = ?
');
$stmt->execute([$userId]);
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $cache[$userId];
} catch (PDOException $e) {
error_log('getUserPermissions error: ' . $e->getMessage());
return [];
}
}
/**
* Require a specific permission, return 403 if denied
*
* @param array<string, mixed> $authData
*/
function requirePermission(array $authData, string $permission): void
{
if ($authData['user']['is_admin'] ?? false) {
return;
}
$permissions = getUserPermissions($authData['user_id']);
if (!in_array($permission, $permissions)) {
errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403);
}
}
/**
* Check if user has a specific permission (returns bool)
*
* @param array<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);
}

1
dist/api/rate_limits/.htaccess vendored Normal file
View File

@@ -0,0 +1 @@
Deny from all

View File

@@ -0,0 +1 @@
{"window_start":1773343550,"count":1}

View File

@@ -0,0 +1 @@
{"window_start":1773345124,"count":12}

View File

@@ -0,0 +1 @@
{"window_start":1773344714,"count":1}

View File

@@ -0,0 +1 @@
{"window_start":1773343540,"count":1}

BIN
dist/apple-touch-icon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

1
dist/assets/Attendance-Bq3ErxVC.js vendored Normal file

File diff suppressed because one or more lines are too long

125
dist/assets/AttendanceAdmin-CN6S51Mm.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/AuditLog-DGV9ABTZ.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/Forbidden-D25jV3Oq.js vendored Normal file
View File

@@ -0,0 +1 @@
import{j as e,m as i}from"./vendor-animation-0s3FMHwK.js";import{L as t}from"./vendor-react-BVs3cwbi.js";function o(){return e.jsxs(i.div,{className:"forbidden-page",initial:{opacity:0,y:20},animate:{opacity:1,y:0},transition:{duration:.4},children:[e.jsx("div",{className:"forbidden-icon",children:e.jsxs("svg",{width:"80",height:"80",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.5",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"11",width:"18",height:"11",rx:"2",ry:"2"}),e.jsx("path",{d:"M7 11V7a5 5 0 0 1 10 0v4"}),e.jsx("circle",{cx:"12",cy:"16",r:"1"})]})}),e.jsx("h1",{className:"forbidden-title",children:"Přístup odepřen"}),e.jsx("p",{className:"forbidden-text",children:"Nemáte oprávnění pro zobrazení této stránky. Kontaktujte administrátora pro přidělení přístupu."}),e.jsx(t,{to:"/",className:"forbidden-link",children:"Zpět na přehled"})]})}export{o as F};

2
dist/assets/InvoiceCreate-D7azSaER.js vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/assets/InvoiceDetail-CxmXBolF.js vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/assets/Invoices-BxKVmNYN.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/LeaveApproval-BQyC3i8M.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/LeaveRequests-CJA9No9B.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/NotFound-Cm3yLPlV.js vendored Normal file
View File

@@ -0,0 +1 @@
import{j as t,m as i}from"./vendor-animation-0s3FMHwK.js";import{L as e}from"./vendor-react-BVs3cwbi.js";function o(){return t.jsxs(i.div,{className:"admin-empty-state",style:{minHeight:"60vh",justifyContent:"center"},initial:{opacity:0,y:20},animate:{opacity:1,y:0},transition:{duration:.4},children:[t.jsx("div",{className:"admin-empty-icon",style:{width:80,height:80,marginBottom:"1.5rem"},children:t.jsxs("svg",{width:"36",height:"36",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.5",strokeLinecap:"round",strokeLinejoin:"round",children:[t.jsx("circle",{cx:"12",cy:"12",r:"10"}),t.jsx("path",{d:"M16 16s-1.5-2-4-2-4 2-4 2"}),t.jsx("line",{x1:"9",y1:"9",x2:"9.01",y2:"9"}),t.jsx("line",{x1:"15",y1:"9",x2:"15.01",y2:"9"})]})}),t.jsx("h2",{style:{fontSize:"1.5rem",fontWeight:600,marginBottom:"0.5rem",color:"var(--text-primary)"},children:"404"}),t.jsx("p",{children:"Stránka nebyla nalezena."}),t.jsx(e,{to:"/",className:"admin-btn admin-btn-primary",style:{marginTop:"0.5rem"},children:"Zpět na Dashboard"})]})}export{o as default};

1
dist/assets/OfferDetail-TQHeNuC6.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Offers-DwUrbYu8.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/OrderDetail-3O2WshUa.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Orders-CSsExPPr.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Pagination-B1sbY6V7.js vendored Normal file
View File

@@ -0,0 +1 @@
import{j as i}from"./vendor-animation-0s3FMHwK.js";import{r as b}from"./vendor-react-BVs3cwbi.js";function k({pagination:t,onPageChange:l,onPerPageChange:m}){const a=t?.page??1,n=t?.total_pages??1,d=t?.total??0,r=t?.per_page??25,h=b.useMemo(()=>{const s=[];let e=Math.max(1,a-Math.floor(2.5));const o=Math.min(n,e+5-1);o-e<4&&(e=Math.max(1,o-5+1)),e>1&&(s.push(1),e>2&&s.push("..."));for(let c=e;c<=o;c++)s.push(c);return o<n&&(o<n-1&&s.push("..."),s.push(n)),s},[a,n]);if(!t||n<=1)return null;const u=(a-1)*r+1,x=Math.min(a*r,d);return i.jsxs("div",{className:"admin-pagination",children:[i.jsxs("span",{className:"admin-pagination-info",children:[u,"",x," z ",d]}),i.jsxs("div",{className:"admin-pagination-controls",children:[i.jsx("button",{className:"admin-btn-secondary admin-btn-sm",disabled:a<=1,onClick:()=>l(a-1),"aria-label":"Předchozí stránka",children:i.jsx("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",children:i.jsx("polyline",{points:"15 18 9 12 15 6"})})}),h.map((s,p)=>s==="..."?i.jsx("span",{className:"admin-pagination-ellipsis",children:"…"},`ellipsis-${p}`):i.jsx("button",{className:`admin-pagination-page${s===a?" active":""}`,onClick:()=>l(s),children:s},s)),i.jsx("button",{className:"admin-btn-secondary admin-btn-sm",disabled:a>=n,onClick:()=>l(a+1),"aria-label":"Další stránka",children:i.jsx("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",children:i.jsx("polyline",{points:"9 18 15 12 9 6"})})})]}),m&&i.jsx("select",{className:"admin-pagination-select",value:r,onChange:s=>m(Number(s.target.value)),"aria-label":"Záznamů na stránku",children:[10,25,50,100].map(s=>i.jsxs("option",{value:s,children:[s," / strana"]},s))})]})}export{k as P};

1
dist/assets/ProjectCreate-B8awV2Y4.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/ProjectDetail-BWBiBOHM.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Projects-DRnqfGWv.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
dist/assets/RichEditor-7oN3-GhD.css vendored Normal file

File diff suppressed because one or more lines are too long

49
dist/assets/RichEditor-Bfur5pi6.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Settings-WU5LlT1S.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Trips-BXj-7zce.js vendored Normal file

File diff suppressed because one or more lines are too long

81
dist/assets/TripsAdmin-yiBDyemU.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/TripsHistory-BBeF9ORG.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Users-_q0u-jiE.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Vehicles-drdX9CTA.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
const u=t=>t?new Date(t).toLocaleDateString("cs-CZ"):"—",o=t=>{if(!t)return"—";const e=new Date(t);return`${e.getDate()}.${e.getMonth()+1}. ${e.toLocaleTimeString("cs-CZ",{hour:"2-digit",minute:"2-digit"})}`},c=t=>t?new Date(t).toLocaleTimeString("cs-CZ",{hour:"2-digit",minute:"2-digit"}):"—",l=t=>{if(!t.arrival_time||!t.departure_time)return 0;const e=new Date(t.arrival_time).getTime();let a=(new Date(t.departure_time).getTime()-e)/6e4;if(t.break_start&&t.break_end){const n=new Date(t.break_start).getTime(),i=new Date(t.break_end).getTime();a-=(i-n)/6e4}return Math.max(0,Math.floor(a))},g=(t,e=!1)=>{const r=Math.floor(t/60),a=t%60;return`${r}:${String(a).padStart(2,"0")}${e?" h":""}`},_=t=>({work:"Práce",vacation:"Dovolená",sick:"Nemoc",holiday:"Svátek",unpaid:"Neplacené volno"})[t]||"Práce",m=t=>({vacation:"badge-vacation",sick:"badge-sick",holiday:"badge-holiday",unpaid:"badge-unpaid"})[t]||"",p=t=>t?t.includes("T")?t.split("T")[0]:t.split(" ")[0]:"",d=t=>{if(!t)return"";const e=new Date(t);return`${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},k=t=>t.filter(e=>e.project_id).reduce((e,r)=>e+(parseInt(r.hours)||0)*60+(parseInt(r.minutes)||0),0),D=t=>{if(!t.arrival_time||!t.departure_time)return 0;const e=`${t.arrival_date}T${t.arrival_time}`,r=`${t.departure_date}T${t.departure_time}`;let a=(new Date(r)-new Date(e))/6e4;if(t.break_start_time&&t.break_end_time){const n=`${t.break_start_date}T${t.break_start_time}`,i=`${t.break_end_date}T${t.break_end_time}`;a-=(new Date(i)-new Date(n))/6e4}return Math.max(0,Math.floor(a))},T=(t,e)=>{if(!t)return"—";if(new Date(t).toISOString().split("T")[0]!==e){const a=new Date(t);return`${a.getDate()}.${a.getMonth()+1}. ${a.toLocaleTimeString("cs-CZ",{hour:"2-digit",minute:"2-digit"})}`}return new Date(t).toLocaleTimeString("cs-CZ",{hour:"2-digit",minute:"2-digit"})},b=t=>{if((t.leave_type||"work")!=="work")return(t.leave_hours||8)*60;if(!t.arrival_time||!t.departure_time)return 0;const r=new Date(t.arrival_time).getTime();let n=(new Date(t.departure_time).getTime()-r)/6e4;if(t.break_start&&t.break_end){const i=new Date(t.break_start).getTime(),s=new Date(t.break_end).getTime();n-=(s-i)/6e4}return Math.max(0,Math.floor(n))};export{g as a,u as b,l as c,m as d,o as e,c as f,_ as g,b as h,T as i,D as j,k,d as l,p as m};

Some files were not shown because too many files have changed in this diff Show More