Initial commit
This commit is contained in:
737
api/admin/attendance.php
Normal file
737
api/admin/attendance.php
Normal file
@@ -0,0 +1,737 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Attendance API
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/admin/attendance.php - Get current shift status and today's shifts
|
||||
* GET /api/admin/attendance.php?action=history - Get attendance history for month
|
||||
* GET /api/admin/attendance.php?action=admin - Get all attendance records (admin)
|
||||
* GET /api/admin/attendance.php?action=balances - Get leave balances (admin)
|
||||
* GET /api/admin/attendance.php?action=workfund&year=YYYY - Get work fund overview (admin)
|
||||
* GET /api/admin/attendance.php?action=location&id=X - Get location for record (admin)
|
||||
* GET /api/admin/attendance.php?action=print - Get print data for attendance (admin)
|
||||
* GET /api/admin/attendance.php?action=projects - Get active projects list
|
||||
* GET /api/admin/attendance.php?action=project_report&month=YYYY-MM - Get project hours report (admin)
|
||||
* GET /api/admin/attendance.php?action=project_logs&attendance_id=X - Get project logs for a shift
|
||||
* POST /api/admin/attendance.php - Clock in/out/break actions
|
||||
* POST /api/admin/attendance.php?action=update_address - Doplnit adresu k poslednimu zaznamu
|
||||
* POST /api/admin/attendance.php?action=notes - Save notes for current shift
|
||||
* POST /api/admin/attendance.php?action=switch_project - Switch active project on current shift
|
||||
* POST /api/admin/attendance.php?action=leave - Add leave record
|
||||
* POST /api/admin/attendance.php?action=save_project_logs - Save project logs for record (admin)
|
||||
* POST /api/admin/attendance.php?action=create - Create attendance record (admin)
|
||||
* POST /api/admin/attendance.php?action=bulk_attendance - Bulk add attendance for month (admin)
|
||||
* POST /api/admin/attendance.php?action=balances - Update leave balance (admin)
|
||||
* PUT /api/admin/attendance.php?id=X - Update attendance record (admin)
|
||||
* DELETE /api/admin/attendance.php?id=X - Delete attendance record (admin)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/CzechHolidays.php';
|
||||
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||
require_once dirname(__DIR__) . '/includes/AttendanceAdmin.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$recordId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$userId = $authData['user_id'];
|
||||
$isAdmin = $authData['user']['is_admin'] ?? false;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($action === 'history') {
|
||||
requirePermission($authData, 'attendance.history');
|
||||
handleGetHistory($pdo, $userId);
|
||||
} elseif ($action === 'admin') {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
handleGetAdmin($pdo);
|
||||
} elseif ($action === 'balances') {
|
||||
requirePermission($authData, 'attendance.balances');
|
||||
handleGetBalances($pdo);
|
||||
} elseif ($action === 'location' && $recordId) {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
handleGetLocation($pdo, $recordId);
|
||||
} elseif ($action === 'users') {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
handleGetUsers($pdo);
|
||||
} elseif ($action === 'print') {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
handleGetPrint($pdo);
|
||||
} elseif ($action === 'workfund') {
|
||||
requirePermission($authData, 'attendance.balances');
|
||||
handleGetWorkFund($pdo);
|
||||
} elseif ($action === 'projects') {
|
||||
handleGetProjects();
|
||||
} elseif ($action === 'project_report') {
|
||||
if (!hasPermission($authData, 'attendance.admin') && !hasPermission($authData, 'attendance.balances')) {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
}
|
||||
handleGetProjectReport($pdo);
|
||||
} elseif ($action === 'project_logs') {
|
||||
handleGetProjectLogs($pdo, $userId, $authData);
|
||||
} else {
|
||||
requirePermission($authData, 'attendance.record');
|
||||
handleGetCurrent($pdo, $userId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'leave') {
|
||||
requirePermission($authData, 'attendance.record');
|
||||
handleAddLeave($pdo, $userId);
|
||||
} elseif ($action === 'notes') {
|
||||
requirePermission($authData, 'attendance.record');
|
||||
handleSaveNotes($pdo, $userId);
|
||||
} elseif ($action === 'switch_project') {
|
||||
requirePermission($authData, 'attendance.record');
|
||||
handleSwitchProject($pdo, $userId);
|
||||
} elseif ($action === 'save_project_logs') {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
handleSaveProjectLogs($pdo);
|
||||
} elseif ($action === 'create') {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
handleCreateAttendance($pdo);
|
||||
} elseif ($action === 'bulk_attendance') {
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
handleBulkAttendance($pdo);
|
||||
} elseif ($action === 'balances') {
|
||||
requirePermission($authData, 'attendance.balances');
|
||||
handleUpdateBalance($pdo);
|
||||
} elseif ($action === 'update_address') {
|
||||
requirePermission($authData, 'attendance.record');
|
||||
handleUpdateAddress($pdo, $userId);
|
||||
} else {
|
||||
requirePermission($authData, 'attendance.record');
|
||||
handlePunch($pdo, $userId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
if (!$recordId) {
|
||||
errorResponse('ID záznamu je povinné');
|
||||
}
|
||||
handleUpdateAttendance($pdo, $recordId);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'attendance.admin');
|
||||
if (!$recordId) {
|
||||
errorResponse('ID záznamu je povinné');
|
||||
}
|
||||
handleDeleteAttendance($pdo, $recordId);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Attendance API error: ' . $e->getMessage());
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User-facing handlers
|
||||
// ============================================================================
|
||||
|
||||
function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM attendance
|
||||
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$ongoingShift = $stmt->fetch();
|
||||
|
||||
$projectLogs = [];
|
||||
$activeProjectId = null;
|
||||
if ($ongoingShift) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC');
|
||||
$stmt->execute([$ongoingShift['id']]);
|
||||
$projectLogs = $stmt->fetchAll();
|
||||
foreach ($projectLogs as $log) {
|
||||
if ($log['ended_at'] === null) {
|
||||
$activeProjectId = (int)$log['project_id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM attendance
|
||||
WHERE user_id = ? AND shift_date = ?
|
||||
AND departure_time IS NOT NULL
|
||||
AND (leave_type IS NULL OR leave_type = 'work')
|
||||
ORDER BY arrival_time DESC
|
||||
");
|
||||
$stmt->execute([$userId, $today]);
|
||||
$todayShifts = $stmt->fetchAll();
|
||||
|
||||
$completedShiftIds = array_column($todayShifts, 'id');
|
||||
$completedProjectLogs = [];
|
||||
if (!empty($completedShiftIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($completedShiftIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM attendance_project_logs
|
||||
WHERE attendance_id IN ($placeholders)
|
||||
ORDER BY started_at ASC"
|
||||
);
|
||||
$stmt->execute($completedShiftIds);
|
||||
$allLogs = $stmt->fetchAll();
|
||||
foreach ($allLogs as $log) {
|
||||
$completedProjectLogs[$log['attendance_id']][] = $log;
|
||||
}
|
||||
}
|
||||
|
||||
$leaveBalance = getLeaveBalance($pdo, $userId);
|
||||
|
||||
$currentYear = (int)date('Y');
|
||||
$currentMonth = (int)date('m');
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth);
|
||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth);
|
||||
|
||||
$startDate = date('Y-m-01');
|
||||
$endDate = date('Y-m-t');
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT * FROM attendance
|
||||
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
|
||||
');
|
||||
$stmt->execute([$userId, $startDate, $endDate]);
|
||||
$monthRecords = $stmt->fetchAll();
|
||||
|
||||
$workedMinutes = 0;
|
||||
$leaveHoursMonth = 0;
|
||||
$vacationHours = 0;
|
||||
$sickHours = 0;
|
||||
$holidayHours = 0;
|
||||
$unpaidHours = 0;
|
||||
foreach ($monthRecords as $rec) {
|
||||
$lt = $rec['leave_type'] ?? 'work';
|
||||
$lh = (float)($rec['leave_hours'] ?? 0);
|
||||
if ($lt === 'work') {
|
||||
if ($rec['departure_time']) {
|
||||
$workedMinutes += calculateWorkMinutes($rec);
|
||||
}
|
||||
} elseif ($lt === 'vacation') {
|
||||
$vacationHours += $lh;
|
||||
$leaveHoursMonth += $lh;
|
||||
} elseif ($lt === 'sick') {
|
||||
$sickHours += $lh;
|
||||
$leaveHoursMonth += $lh;
|
||||
} elseif ($lt === 'holiday') {
|
||||
$holidayHours += $lh;
|
||||
} elseif ($lt === 'unpaid') {
|
||||
$unpaidHours += $lh;
|
||||
}
|
||||
}
|
||||
|
||||
$workedHours = round($workedMinutes / 60, 1);
|
||||
$covered = $workedHours + $leaveHoursMonth;
|
||||
$remaining = max(0, $fund - $covered);
|
||||
$overtime = max(0, round($covered - $fund, 1));
|
||||
|
||||
$monthlyFund = [
|
||||
'fund' => $fund,
|
||||
'business_days' => $businessDays,
|
||||
'worked' => $workedHours,
|
||||
'leave_hours' => $leaveHoursMonth,
|
||||
'vacation_hours' => $vacationHours,
|
||||
'sick_hours' => $sickHours,
|
||||
'holiday_hours' => $holidayHours,
|
||||
'unpaid_hours' => $unpaidHours,
|
||||
'covered' => $covered,
|
||||
'remaining' => $remaining,
|
||||
'overtime' => $overtime,
|
||||
'month_name' => getCzechMonthName($currentMonth) . ' ' . $currentYear,
|
||||
];
|
||||
|
||||
// Enrich project logs with names
|
||||
$allLogProjectIds = [];
|
||||
foreach ($projectLogs as $l) {
|
||||
$allLogProjectIds[$l['project_id']] = $l['project_id'];
|
||||
}
|
||||
foreach ($completedProjectLogs as $logs) {
|
||||
foreach ($logs as $l) {
|
||||
$allLogProjectIds[$l['project_id']] = $l['project_id'];
|
||||
}
|
||||
}
|
||||
$projNameMap = fetchProjectNames($allLogProjectIds);
|
||||
|
||||
foreach ($projectLogs as &$l) {
|
||||
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
||||
}
|
||||
unset($l);
|
||||
foreach ($completedProjectLogs as &$logs) {
|
||||
foreach ($logs as &$l) {
|
||||
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
||||
}
|
||||
unset($l);
|
||||
}
|
||||
unset($logs);
|
||||
|
||||
foreach ($todayShifts as &$shift) {
|
||||
$shift['project_logs'] = $completedProjectLogs[$shift['id']] ?? [];
|
||||
}
|
||||
unset($shift);
|
||||
|
||||
successResponse([
|
||||
'ongoing_shift' => $ongoingShift,
|
||||
'today_shifts' => $todayShifts,
|
||||
'date' => $today,
|
||||
'leave_balance' => $leaveBalance,
|
||||
'monthly_fund' => $monthlyFund,
|
||||
'project_logs' => $projectLogs,
|
||||
'active_project_id' => $activeProjectId,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetHistory(PDO $pdo, int $userId): void
|
||||
{
|
||||
$month = validateMonth();
|
||||
$year = (int)substr($month, 0, 4);
|
||||
$monthNum = (int)substr($month, 5, 2);
|
||||
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT * FROM attendance
|
||||
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
|
||||
ORDER BY shift_date DESC
|
||||
');
|
||||
$stmt->execute([$userId, $startDate, $endDate]);
|
||||
$records = $stmt->fetchAll();
|
||||
|
||||
enrichRecordsWithProjectLogs($pdo, $records);
|
||||
|
||||
$totalMinutes = 0;
|
||||
$vacationHours = 0;
|
||||
$sickHours = 0;
|
||||
$holidayHours = 0;
|
||||
$unpaidHours = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$leaveType = $record['leave_type'] ?? 'work';
|
||||
$leaveHours = (float)($record['leave_hours'] ?? 0);
|
||||
|
||||
if ($leaveType === 'vacation') {
|
||||
$vacationHours += $leaveHours;
|
||||
} elseif ($leaveType === 'sick') {
|
||||
$sickHours += $leaveHours;
|
||||
} elseif ($leaveType === 'holiday') {
|
||||
$holidayHours += $leaveHours;
|
||||
} elseif ($leaveType === 'unpaid') {
|
||||
$unpaidHours += $leaveHours;
|
||||
} else {
|
||||
$totalMinutes += calculateWorkMinutes($record);
|
||||
}
|
||||
}
|
||||
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
|
||||
$workedHours = round($totalMinutes / 60, 1);
|
||||
$leaveHoursCovered = $vacationHours + $sickHours;
|
||||
$covered = $workedHours + $leaveHoursCovered;
|
||||
$remaining = max(0, round($fund - $covered, 1));
|
||||
$overtime = max(0, round($covered - $fund, 1));
|
||||
|
||||
$leaveBalance = getLeaveBalance($pdo, $userId, $year);
|
||||
|
||||
successResponse([
|
||||
'records' => $records,
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'month_name' => getCzechMonthName($monthNum) . ' ' . $year,
|
||||
'total_minutes' => $totalMinutes,
|
||||
'vacation_hours' => $vacationHours,
|
||||
'sick_hours' => $sickHours,
|
||||
'holiday_hours' => $holidayHours,
|
||||
'unpaid_hours' => $unpaidHours,
|
||||
'leave_balance' => $leaveBalance,
|
||||
'monthly_fund' => [
|
||||
'fund' => $fund,
|
||||
'business_days' => $businessDays,
|
||||
'worked' => $workedHours,
|
||||
'leave_hours' => $leaveHoursCovered,
|
||||
'covered' => $covered,
|
||||
'remaining' => $remaining,
|
||||
'overtime' => $overtime,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function handlePunch(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$action = $input['punch_action'] ?? '';
|
||||
$today = date('Y-m-d');
|
||||
$rawNow = date('Y-m-d H:i:s');
|
||||
|
||||
$lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null;
|
||||
$lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null;
|
||||
$accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null;
|
||||
$address = !empty($input['address']) ? $input['address'] : null;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM attendance
|
||||
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$ongoingShift = $stmt->fetch();
|
||||
|
||||
if ($action === 'arrival' && !$ongoingShift) {
|
||||
$now = roundUpTo15Minutes($rawNow);
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO attendance
|
||||
(user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]);
|
||||
|
||||
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
|
||||
'arrival_time' => $now,
|
||||
'location' => $address,
|
||||
], 'Příchod zaznamenán');
|
||||
|
||||
successResponse(null, 'Příchod zaznamenán');
|
||||
} elseif ($ongoingShift) {
|
||||
switch ($action) {
|
||||
case 'break_start':
|
||||
if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) {
|
||||
$breakStart = roundToNearest10Minutes($rawNow);
|
||||
$breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60));
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
||||
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
||||
|
||||
successResponse(null, 'Pauza zaznamenána');
|
||||
} else {
|
||||
errorResponse('Nelze zadat pauzu');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'departure':
|
||||
if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) {
|
||||
$now = roundDownTo15Minutes($rawNow);
|
||||
|
||||
// Auto-add break if shift is longer than 6h and no break
|
||||
if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) {
|
||||
$arrivalTime = strtotime($ongoingShift['arrival_time']);
|
||||
$departureTime = strtotime($now);
|
||||
$hoursWorked = ($departureTime - $arrivalTime) / 3600;
|
||||
|
||||
if ($hoursWorked > 12) {
|
||||
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
|
||||
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60)));
|
||||
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60)));
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
||||
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
||||
} elseif ($hoursWorked > 6) {
|
||||
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
|
||||
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
|
||||
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
||||
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE attendance
|
||||
SET departure_time = ?, departure_lat = ?, departure_lng = ?,
|
||||
departure_accuracy = ?, departure_address = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]);
|
||||
|
||||
// Close any open project log
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL
|
||||
');
|
||||
$stmt->execute([$now, $ongoingShift['id']]);
|
||||
|
||||
AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [
|
||||
'departure_time' => $now,
|
||||
'location' => $address,
|
||||
], 'Odchod zaznamenán');
|
||||
|
||||
successResponse(null, 'Odchod zaznamenán');
|
||||
} else {
|
||||
errorResponse('Nelze zadat odchod');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Neplatná akce');
|
||||
}
|
||||
} else {
|
||||
errorResponse('Neplatná akce - nemáte aktivní směnu');
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateAddress(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$address = trim($input['address'] ?? '');
|
||||
$punchAction = $input['punch_action'] ?? '';
|
||||
|
||||
if (!$address) {
|
||||
successResponse(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($punchAction === 'arrival') {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE attendance SET arrival_address = ?
|
||||
WHERE id = (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM attendance
|
||||
WHERE user_id = ? AND (arrival_address IS NULL OR arrival_address = '')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) t
|
||||
)
|
||||
");
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE attendance SET departure_address = ?
|
||||
WHERE id = (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM attendance
|
||||
WHERE user_id = ? AND (departure_address IS NULL OR departure_address = '')
|
||||
AND departure_time IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) t
|
||||
)
|
||||
");
|
||||
}
|
||||
$stmt->execute([$address, $userId]);
|
||||
|
||||
successResponse(null);
|
||||
}
|
||||
|
||||
function handleAddLeave(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$leaveType = $input['leave_type'] ?? '';
|
||||
$leaveDate = $input['leave_date'] ?? '';
|
||||
$leaveHours = (float)($input['leave_hours'] ?? 8);
|
||||
$notes = trim($input['notes'] ?? '');
|
||||
|
||||
if (!$leaveType || !$leaveDate || $leaveHours <= 0) {
|
||||
errorResponse('Vyplňte všechna povinná pole');
|
||||
}
|
||||
|
||||
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
|
||||
errorResponse('Neplatný typ nepřítomnosti');
|
||||
}
|
||||
|
||||
if ($leaveType === 'vacation') {
|
||||
$year = (int)date('Y', strtotime($leaveDate));
|
||||
$balance = getLeaveBalance($pdo, $userId, $year);
|
||||
|
||||
if ($balance['vacation_remaining'] < $leaveHours) {
|
||||
errorResponse(
|
||||
"Nemáte dostatek hodin dovolené. Zbývá vám "
|
||||
. "{$balance['vacation_remaining']} hodin, požadujete {$leaveHours} hodin."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$userId, $leaveDate, $leaveType, $leaveHours, $notes ?: null]);
|
||||
|
||||
updateLeaveBalance($pdo, $userId, $leaveDate, $leaveType, $leaveHours);
|
||||
|
||||
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
|
||||
'leave_type' => $leaveType,
|
||||
'leave_hours' => $leaveHours,
|
||||
], "Zaznamenána nepřítomnost: $leaveType");
|
||||
|
||||
successResponse(null, 'Nepřítomnost byla zaznamenána');
|
||||
}
|
||||
|
||||
function handleSaveNotes(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$notes = trim($input['notes'] ?? '');
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id FROM attendance
|
||||
WHERE user_id = ? AND departure_time IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
$currentShift = $stmt->fetch();
|
||||
|
||||
if (!$currentShift) {
|
||||
errorResponse('Nemáte aktivní směnu');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE attendance SET notes = ? WHERE id = ?');
|
||||
$stmt->execute([$notes, $currentShift['id']]);
|
||||
|
||||
successResponse(null, 'Poznámka byla uložena');
|
||||
}
|
||||
|
||||
function handleGetProjects(): void
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, project_number, name FROM projects
|
||||
WHERE status = 'aktivni' ORDER BY project_number ASC"
|
||||
);
|
||||
$projects = $stmt->fetchAll();
|
||||
successResponse(['projects' => $projects]);
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to fetch projects: ' . $e->getMessage());
|
||||
successResponse(['projects' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitchProject(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
/** @var mixed $rawProjectId */
|
||||
$rawProjectId = $input['project_id'] ?? null;
|
||||
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||
? (int)$rawProjectId
|
||||
: null;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id FROM attendance
|
||||
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$currentShift = $stmt->fetch();
|
||||
|
||||
if (!$currentShift) {
|
||||
errorResponse('Nemáte aktivní směnu');
|
||||
}
|
||||
|
||||
$attendanceId = $currentShift['id'];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE attendance_project_logs SET ended_at = ?
|
||||
WHERE attendance_id = ? AND ended_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$now, $attendanceId]);
|
||||
|
||||
if ($projectId) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO attendance_project_logs
|
||||
(attendance_id, project_id, started_at) VALUES (?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$attendanceId, $projectId, $now]);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE attendance SET project_id = ? WHERE id = ?');
|
||||
$stmt->execute([$projectId, $attendanceId]);
|
||||
|
||||
successResponse(null, $projectId ? 'Projekt přepnut' : 'Projekt zastaven');
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $authData */
|
||||
function handleGetProjectLogs(PDO $pdo, int $currentUserId, array $authData): void
|
||||
{
|
||||
$attendanceId = (int)($_GET['attendance_id'] ?? 0);
|
||||
if (!$attendanceId) {
|
||||
errorResponse('attendance_id je povinné');
|
||||
}
|
||||
|
||||
// Ověření vlastnictví záznamu nebo admin oprávnění
|
||||
if (!hasPermission($authData, 'attendance.admin')) {
|
||||
$ownerStmt = $pdo->prepare('SELECT user_id FROM attendance WHERE id = ?');
|
||||
$ownerStmt->execute([$attendanceId]);
|
||||
$owner = $ownerStmt->fetch();
|
||||
if (!$owner || (int)$owner['user_id'] !== $currentUserId) {
|
||||
errorResponse('Nemáte oprávnění zobrazit tyto záznamy', 403);
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC');
|
||||
$stmt->execute([$attendanceId]);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
$projectIds = [];
|
||||
foreach ($logs as $l) {
|
||||
$projectIds[$l['project_id']] = $l['project_id'];
|
||||
}
|
||||
$projNameMap = fetchProjectNames($projectIds);
|
||||
foreach ($logs as &$l) {
|
||||
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
||||
}
|
||||
unset($l);
|
||||
|
||||
successResponse(['logs' => $logs]);
|
||||
}
|
||||
|
||||
function handleSaveProjectLogs(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$attendanceId = (int)($input['attendance_id'] ?? 0);
|
||||
$logs = $input['project_logs'] ?? [];
|
||||
|
||||
if (!$attendanceId) {
|
||||
errorResponse('attendance_id je povinné');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||
$stmt->execute([$attendanceId]);
|
||||
$record = $stmt->fetch();
|
||||
if (!$record) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||
$stmt->execute([$attendanceId]);
|
||||
|
||||
if (!empty($logs)) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO attendance_project_logs
|
||||
(attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($logs as $log) {
|
||||
$projectId = (int)($log['project_id'] ?? 0);
|
||||
if (!$projectId) {
|
||||
continue;
|
||||
}
|
||||
$h = (int)($log['hours'] ?? 0);
|
||||
$m = (int)($log['minutes'] ?? 0);
|
||||
if ($h === 0 && $m === 0) {
|
||||
continue;
|
||||
}
|
||||
$stmt->execute([$attendanceId, $projectId, $h, $m]);
|
||||
}
|
||||
}
|
||||
|
||||
successResponse(null, 'Projektové záznamy byly uloženy');
|
||||
}
|
||||
232
api/admin/bank-accounts.php
Normal file
232
api/admin/bank-accounts.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Bank Accounts API
|
||||
*
|
||||
* GET /api/admin/bank-accounts.php - Seznam bankovnich uctu
|
||||
* POST /api/admin/bank-accounts.php - Vytvoreni uctu
|
||||
* PUT /api/admin/bank-accounts.php?id=X - Uprava uctu
|
||||
* DELETE /api/admin/bank-accounts.php?id=X - Smazani uctu
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'offers.settings');
|
||||
handleGetBankAccountList($pdo);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'offers.settings');
|
||||
handleCreateBankAccount($pdo);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'offers.settings');
|
||||
if (!$id) {
|
||||
errorResponse('ID účtu je povinné');
|
||||
}
|
||||
handleUpdateBankAccount($pdo, $id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'offers.settings');
|
||||
if (!$id) {
|
||||
errorResponse('ID účtu je povinné');
|
||||
}
|
||||
handleDeleteBankAccount($pdo, $id);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Bank Accounts API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGetBankAccountList(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('SELECT * FROM bank_accounts ORDER BY position, id');
|
||||
successResponse($stmt->fetchAll());
|
||||
}
|
||||
|
||||
function handleCreateBankAccount(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$accountName = trim($input['account_name'] ?? '');
|
||||
$bankName = trim($input['bank_name'] ?? '');
|
||||
$accountNumber = trim($input['account_number'] ?? '');
|
||||
$iban = trim($input['iban'] ?? '');
|
||||
$bic = trim($input['bic'] ?? '');
|
||||
$currency = trim($input['currency'] ?? 'CZK');
|
||||
$isDefault = !empty($input['is_default']) ? 1 : 0;
|
||||
|
||||
if (!$accountName) {
|
||||
errorResponse('Název účtu je povinný');
|
||||
}
|
||||
if (mb_strlen($accountName) > 100) {
|
||||
errorResponse('Název účtu je příliš dlouhý (max 100 znaků)');
|
||||
}
|
||||
if (mb_strlen($bankName) > 255) {
|
||||
errorResponse('Název banky je příliš dlouhý (max 255 znaků)');
|
||||
}
|
||||
if (mb_strlen($accountNumber) > 50) {
|
||||
errorResponse('Číslo účtu je příliš dlouhé (max 50 znaků)');
|
||||
}
|
||||
if (mb_strlen($iban) > 50) {
|
||||
errorResponse('IBAN je příliš dlouhý (max 50 znaků)');
|
||||
}
|
||||
if (mb_strlen($bic) > 20) {
|
||||
errorResponse('BIC/SWIFT je příliš dlouhý (max 20 znaků)');
|
||||
}
|
||||
if (!in_array($currency, ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||
errorResponse('Neplatná měna');
|
||||
}
|
||||
|
||||
// Zjistit dalsi pozici
|
||||
$maxPos = (int) $pdo->query('SELECT COALESCE(MAX(position), 0) FROM bank_accounts')->fetchColumn();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Pokud je default, zrusit ostatnim
|
||||
if ($isDefault) {
|
||||
$pdo->exec('UPDATE bank_accounts SET is_default = 0');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO bank_accounts
|
||||
(account_name, bank_name, account_number, iban, bic, currency, is_default, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$accountName, $bankName, $accountNumber, $iban, $bic, $currency, $isDefault, $maxPos + 1]);
|
||||
$newId = (int) $pdo->lastInsertId();
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logCreate(
|
||||
'bank_account',
|
||||
$newId,
|
||||
['account_name' => $accountName],
|
||||
"Vytvořen bankovní účet '$accountName'"
|
||||
);
|
||||
|
||||
successResponse(['id' => $newId], 'Bankovní účet byl vytvořen');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateBankAccount(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$account = $stmt->fetch();
|
||||
|
||||
if (!$account) {
|
||||
errorResponse('Bankovní účet nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
// Delkove limity a validace
|
||||
$maxLengths = ['account_name' => 100, 'bank_name' => 255, 'account_number' => 50, 'iban' => 50, 'bic' => 20];
|
||||
foreach ($maxLengths as $f => $max) {
|
||||
if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) {
|
||||
errorResponse("Pole $f je příliš dlouhé (max $max znaků)");
|
||||
}
|
||||
}
|
||||
if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||
errorResponse('Neplatná měna');
|
||||
}
|
||||
|
||||
$fields = ['account_name', 'bank_name', 'account_number', 'iban', 'bic', 'currency'];
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$updates[] = "$field = ?";
|
||||
$params[] = trim((string) $input[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if (array_key_exists('is_default', $input)) {
|
||||
$isDefault = !empty($input['is_default']) ? 1 : 0;
|
||||
if ($isDefault) {
|
||||
$pdo->exec('UPDATE bank_accounts SET is_default = 0');
|
||||
}
|
||||
$updates[] = 'is_default = ?';
|
||||
$params[] = $isDefault;
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
errorResponse('Žádná data k aktualizaci');
|
||||
}
|
||||
|
||||
$updates[] = 'modified_at = NOW()';
|
||||
$params[] = $id;
|
||||
|
||||
$sql = 'UPDATE bank_accounts SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate('bank_account', $id, [], $input, "Aktualizován bankovní účet #{$id}");
|
||||
|
||||
successResponse(null, 'Bankovní účet byl aktualizován');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteBankAccount(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$account = $stmt->fetch();
|
||||
|
||||
if (!$account) {
|
||||
errorResponse('Bankovní účet nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM bank_accounts WHERE id = ?')->execute([$id]);
|
||||
|
||||
AuditLog::logDelete(
|
||||
'bank_account',
|
||||
$id,
|
||||
['account_name' => $account['account_name']],
|
||||
"Smazán bankovní účet '{$account['account_name']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Bankovní účet byl smazán');
|
||||
}
|
||||
314
api/admin/company-settings.php
Normal file
314
api/admin/company-settings.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Company Settings API
|
||||
*
|
||||
* GET /api/admin/company-settings.php - Get company settings
|
||||
* PUT /api/admin/company-settings.php - Update company settings
|
||||
* POST /api/admin/company-settings.php?action=logo - Upload logo
|
||||
* GET /api/admin/company-settings.php?action=logo - Get logo image
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
if (!($method === 'GET' && $action === 'logo')) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($action === 'logo') {
|
||||
requirePermission($authData, 'offers.view');
|
||||
handleGetLogo($pdo);
|
||||
} else {
|
||||
requirePermission($authData, 'offers.settings');
|
||||
handleGetOffersSettings($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'offers.settings');
|
||||
handleUpdateOffersSettings($pdo);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'logo') {
|
||||
requirePermission($authData, 'offers.settings');
|
||||
handleUploadLogo($pdo);
|
||||
} else {
|
||||
errorResponse('Neplatná akce', 400);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Offers Settings API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $includeLogo false = bez logo_data BLOBu
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array
|
||||
{
|
||||
if ($includeLogo) {
|
||||
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1');
|
||||
} else {
|
||||
$stmt = $pdo->query('
|
||||
SELECT id, company_name, company_id, vat_id, street, city, postal_code, country,
|
||||
quotation_prefix, default_currency, default_vat_rate,
|
||||
custom_fields, uuid, modified_at, sync_version,
|
||||
order_type_code, invoice_type_code, is_deleted,
|
||||
CASE WHEN logo_data IS NOT NULL AND LENGTH(logo_data) > 0 THEN 1 ELSE 0 END as has_logo
|
||||
FROM company_settings LIMIT 1
|
||||
');
|
||||
}
|
||||
$settings = $stmt->fetch();
|
||||
|
||||
if (!$settings) {
|
||||
$uuid = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0x0fff) | 0x4000,
|
||||
random_int(0, 0x3fff) | 0x8000,
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff)
|
||||
);
|
||||
$pdo->prepare(
|
||||
"INSERT INTO company_settings
|
||||
(id, company_name, quotation_prefix, default_currency,
|
||||
default_vat_rate, uuid, modified_at, sync_version)
|
||||
VALUES (1, '', 'N', 'EUR', 21.0, ?, NOW(), 1)"
|
||||
)->execute([$uuid]);
|
||||
return getOrCreateSettings($pdo, $includeLogo);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
function handleGetOffersSettings(PDO $pdo): void
|
||||
{
|
||||
$settings = getOrCreateSettings($pdo, false);
|
||||
/** @var array<mixed>|null $cfRaw */
|
||||
$cfRaw = !empty($settings['custom_fields'])
|
||||
? json_decode($settings['custom_fields'], true)
|
||||
: null;
|
||||
if (is_array($cfRaw) && !isset($cfRaw['fields'])) {
|
||||
$settings['custom_fields'] = $cfRaw;
|
||||
$settings['supplier_field_order'] = null;
|
||||
} elseif (is_array($cfRaw) && isset($cfRaw['fields'])) {
|
||||
$settings['custom_fields'] = $cfRaw['fields'];
|
||||
$settings['supplier_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null;
|
||||
} else {
|
||||
$settings['custom_fields'] = [];
|
||||
$settings['supplier_field_order'] = null;
|
||||
}
|
||||
|
||||
$settings['has_logo'] = (bool)($settings['has_logo'] ?? false);
|
||||
|
||||
successResponse($settings);
|
||||
}
|
||||
|
||||
function handleUpdateOffersSettings(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$settings = getOrCreateSettings($pdo);
|
||||
|
||||
// Delkove limity
|
||||
$maxLengths = [
|
||||
'company_name' => 255, 'street' => 255, 'city' => 255,
|
||||
'postal_code' => 20, 'country' => 100,
|
||||
'company_id' => 50, 'vat_id' => 50,
|
||||
'default_currency' => 5,
|
||||
];
|
||||
foreach ($maxLengths as $f => $max) {
|
||||
if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) {
|
||||
errorResponse("Pole $f je příliš dlouhé (max $max znaků)");
|
||||
}
|
||||
}
|
||||
// Validace meny
|
||||
if (isset($input['default_currency']) && !in_array($input['default_currency'], ['EUR', 'USD', 'CZK', 'GBP'])) {
|
||||
errorResponse('Neplatná měna');
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'company_name', 'street', 'city', 'postal_code', 'country',
|
||||
'company_id', 'vat_id',
|
||||
'quotation_prefix', 'default_currency',
|
||||
'order_type_code', 'invoice_type_code',
|
||||
];
|
||||
|
||||
$setClauses = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$setClauses[] = "$field = ?";
|
||||
$params[] = $input[$field];
|
||||
}
|
||||
}
|
||||
|
||||
// custom_fields + SupplierFieldOrder - ulozeny dohromady jako JSON
|
||||
if (array_key_exists('custom_fields', $input) || array_key_exists('supplier_field_order', $input)) {
|
||||
/** @var array<mixed>|null $currentRaw */
|
||||
$currentRaw = !empty($settings['custom_fields'])
|
||||
? json_decode($settings['custom_fields'], true)
|
||||
: null;
|
||||
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
||||
/** @var array<string, mixed> $stored */
|
||||
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
||||
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
||||
/** @var array<string, mixed> $stored */
|
||||
$stored = $currentRaw;
|
||||
} else {
|
||||
$stored = ['fields' => [], 'field_order' => null];
|
||||
}
|
||||
|
||||
if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) {
|
||||
$stored['fields'] = $input['custom_fields'];
|
||||
}
|
||||
if (array_key_exists('supplier_field_order', $input)) {
|
||||
$stored['field_order'] = is_array($input['supplier_field_order']) ? $input['supplier_field_order'] : null;
|
||||
}
|
||||
|
||||
// Odstranit stary klic
|
||||
unset($stored['fieldOrder']);
|
||||
|
||||
$setClauses[] = 'custom_fields = ?';
|
||||
$params[] = json_encode($stored, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// Validace prefixu
|
||||
if (isset($input['quotation_prefix']) && !preg_match('/^[A-Za-z0-9]{0,10}$/', $input['quotation_prefix'])) {
|
||||
errorResponse('Prefix nabídky může obsahovat pouze alfanumerické znaky (max 10)');
|
||||
}
|
||||
if (isset($input['order_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['order_type_code'])) {
|
||||
errorResponse('Typový kód objednávek může obsahovat pouze čísla (max 10)');
|
||||
}
|
||||
if (isset($input['invoice_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['invoice_type_code'])) {
|
||||
errorResponse('Typový kód faktur může obsahovat pouze čísla (max 10)');
|
||||
}
|
||||
|
||||
$numericFields = ['default_vat_rate'];
|
||||
foreach ($numericFields as $field) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$val = is_numeric($input[$field]) ? floatval($input[$field]) : 0;
|
||||
if ($val < 0 || $val > 100) {
|
||||
errorResponse('Sazba DPH musí být mezi 0 a 100');
|
||||
}
|
||||
$setClauses[] = "$field = ?";
|
||||
$params[] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($setClauses)) {
|
||||
errorResponse('Žádná data k aktualizaci');
|
||||
}
|
||||
|
||||
$setClauses[] = 'modified_at = NOW()';
|
||||
$setClauses[] = 'sync_version = sync_version + 1';
|
||||
|
||||
$sql = 'UPDATE company_settings SET ' . implode(', ', $setClauses) . ' WHERE id = ?';
|
||||
$params[] = $settings['id'];
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
|
||||
AuditLog::logUpdate('company_settings', (int)$settings['id'], [], $input, 'Aktualizováno nastavení firmy');
|
||||
|
||||
successResponse(null, 'Nastavení bylo uloženo');
|
||||
}
|
||||
|
||||
function handleUploadLogo(PDO $pdo): void
|
||||
{
|
||||
if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
|
||||
errorResponse('Nebyl nahrán žádný soubor');
|
||||
}
|
||||
|
||||
$file = $_FILES['logo'];
|
||||
$allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mimeType, $allowedTypes)) {
|
||||
errorResponse('Nepodporovaný formát obrázku. Povolené: PNG, JPEG, GIF, WebP');
|
||||
}
|
||||
|
||||
if ($file['size'] > 5 * 1024 * 1024) {
|
||||
errorResponse('Soubor je příliš velký (max 5 MB)');
|
||||
}
|
||||
|
||||
$logoData = file_get_contents($file['tmp_name']);
|
||||
|
||||
$settings = getOrCreateSettings($pdo);
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE company_settings SET logo_data = ?, modified_at = NOW(), sync_version = sync_version + 1 WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$logoData, $settings['id']]);
|
||||
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'company_settings',
|
||||
(int)$settings['id'],
|
||||
[],
|
||||
['logo' => 'uploaded'],
|
||||
'Aktualizováno logo firmy'
|
||||
);
|
||||
|
||||
successResponse(null, 'Logo bylo nahráno');
|
||||
}
|
||||
|
||||
function handleGetLogo(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('SELECT logo_data FROM company_settings LIMIT 1');
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row || empty($row['logo_data'])) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'error' => 'Logo nenalezeno']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$logoData = $row['logo_data'];
|
||||
|
||||
// Detect image type from binary data
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_buffer($finfo, $logoData);
|
||||
finfo_close($finfo);
|
||||
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . strlen($logoData));
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
echo $logoData;
|
||||
exit();
|
||||
}
|
||||
350
api/admin/customers.php
Normal file
350
api/admin/customers.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Customers API
|
||||
*
|
||||
* GET /api/admin/customers.php - List customers
|
||||
* GET /api/admin/customers.php?id=X - Get single customer
|
||||
* GET /api/admin/customers.php?action=search&q= - Search customers
|
||||
* POST /api/admin/customers.php - Create customer
|
||||
* PUT /api/admin/customers.php?id=X - Update customer
|
||||
* DELETE /api/admin/customers.php?id=X - Delete customer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$customerId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'offers.view');
|
||||
if ($action === 'search') {
|
||||
handleSearch($pdo);
|
||||
} elseif ($customerId) {
|
||||
handleGetOne($pdo, $customerId);
|
||||
} else {
|
||||
handleGetAll($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'offers.create');
|
||||
handleCreateCustomer($pdo);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'offers.edit');
|
||||
if (!$customerId) {
|
||||
errorResponse('ID zákazníka je povinné');
|
||||
}
|
||||
handleUpdateCustomer($pdo, $customerId);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'offers.delete');
|
||||
if (!$customerId) {
|
||||
errorResponse('ID zákazníka je povinné');
|
||||
}
|
||||
handleDeleteCustomer($pdo, $customerId);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Customers API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $customer */
|
||||
function parseCustomerCustomFields(array &$customer): void
|
||||
{
|
||||
/** @var array<mixed>|null $cfRaw */
|
||||
$cfRaw = !empty($customer['custom_fields'])
|
||||
? json_decode($customer['custom_fields'], true)
|
||||
: null;
|
||||
if (is_array($cfRaw) && !isset($cfRaw['fields'])) {
|
||||
$customer['custom_fields'] = $cfRaw;
|
||||
$customer['customer_field_order'] = null;
|
||||
} elseif (is_array($cfRaw) && isset($cfRaw['fields'])) {
|
||||
$customer['custom_fields'] = $cfRaw['fields'];
|
||||
$customer['customer_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null;
|
||||
} else {
|
||||
$customer['custom_fields'] = [];
|
||||
$customer['customer_field_order'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
function encodeCustomerCustomFields(array $input, ?string $existingJson): ?string
|
||||
{
|
||||
if (!array_key_exists('custom_fields', $input) && !array_key_exists('customer_field_order', $input)) {
|
||||
return $existingJson;
|
||||
}
|
||||
/** @var array<mixed>|null $currentRaw */
|
||||
$currentRaw = !empty($existingJson) ? json_decode($existingJson, true) : null;
|
||||
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
||||
/** @var array<string, mixed> $stored */
|
||||
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
||||
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
||||
/** @var array<string, mixed> $stored */
|
||||
$stored = $currentRaw;
|
||||
} else {
|
||||
$stored = ['fields' => [], 'field_order' => null];
|
||||
}
|
||||
|
||||
if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) {
|
||||
$stored['fields'] = $input['custom_fields'];
|
||||
}
|
||||
if (array_key_exists('customer_field_order', $input)) {
|
||||
$stored['field_order'] = is_array($input['customer_field_order']) ? $input['customer_field_order'] : null;
|
||||
}
|
||||
|
||||
unset($stored['fieldOrder']);
|
||||
|
||||
return json_encode($stored, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
function handleGetAll(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('
|
||||
SELECT c.*, COUNT(q.id) as quotation_count
|
||||
FROM customers c
|
||||
LEFT JOIN quotations q ON q.customer_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name ASC
|
||||
');
|
||||
$customers = $stmt->fetchAll();
|
||||
|
||||
foreach ($customers as &$c) {
|
||||
parseCustomerCustomFields($c);
|
||||
}
|
||||
unset($c);
|
||||
|
||||
successResponse(['customers' => $customers]);
|
||||
}
|
||||
|
||||
function handleGetOne(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$customer = $stmt->fetch();
|
||||
|
||||
if (!$customer) {
|
||||
errorResponse('Zákazník nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
parseCustomerCustomFields($customer);
|
||||
successResponse($customer);
|
||||
}
|
||||
|
||||
function handleSearch(PDO $pdo): void
|
||||
{
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
if (strlen($q) < 1 || mb_strlen($q) > 100) {
|
||||
successResponse(['customers' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT * FROM customers
|
||||
WHERE name LIKE ? OR company_id LIKE ? OR city LIKE ?
|
||||
ORDER BY name ASC
|
||||
LIMIT 20
|
||||
');
|
||||
$search = "%{$q}%";
|
||||
$stmt->execute([$search, $search, $search]);
|
||||
|
||||
$results = $stmt->fetchAll();
|
||||
foreach ($results as &$c) {
|
||||
parseCustomerCustomFields($c);
|
||||
}
|
||||
unset($c);
|
||||
successResponse(['customers' => $results]);
|
||||
}
|
||||
|
||||
function handleCreateCustomer(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
if (empty($input['name'])) {
|
||||
errorResponse('Název zákazníka je povinný');
|
||||
}
|
||||
if (mb_strlen($input['name']) > 255) {
|
||||
errorResponse('Název zákazníka je příliš dlouhý (max 255 znaků)');
|
||||
}
|
||||
foreach (['street', 'city', 'country'] as $f) {
|
||||
if (isset($input[$f]) && mb_strlen($input[$f]) > 255) {
|
||||
errorResponse("Pole $f je příliš dlouhé (max 255 znaků)");
|
||||
}
|
||||
}
|
||||
if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) {
|
||||
errorResponse('PSČ je příliš dlouhé (max 20 znaků)');
|
||||
}
|
||||
if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) {
|
||||
errorResponse('IČO je příliš dlouhé (max 50 znaků)');
|
||||
}
|
||||
if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) {
|
||||
errorResponse('DIČ je příliš dlouhé (max 50 znaků)');
|
||||
}
|
||||
|
||||
$uuid = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0x0fff) | 0x4000,
|
||||
random_int(0, 0x3fff) | 0x8000,
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff)
|
||||
);
|
||||
|
||||
$customFieldsJson = encodeCustomerCustomFields($input, null);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO customers (name, street, city, postal_code, country,
|
||||
company_id, vat_id, custom_fields, created_at, uuid, modified_at, sync_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), 1)
|
||||
');
|
||||
$stmt->execute([
|
||||
$input['name'],
|
||||
$input['street'] ?? '',
|
||||
$input['city'] ?? '',
|
||||
$input['postal_code'] ?? '',
|
||||
$input['country'] ?? '',
|
||||
$input['company_id'] ?? '',
|
||||
$input['vat_id'] ?? '',
|
||||
$customFieldsJson,
|
||||
$uuid,
|
||||
]);
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
|
||||
AuditLog::logCreate('customer', (int)$newId, [
|
||||
'name' => $input['name'],
|
||||
], "Vytvořen zákazník '{$input['name']}'");
|
||||
|
||||
successResponse(['id' => $newId], 'Zákazník byl vytvořen');
|
||||
}
|
||||
|
||||
function handleUpdateCustomer(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
if (!$existing) {
|
||||
errorResponse('Zákazník nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
// Delkove limity
|
||||
if (isset($input['name']) && mb_strlen($input['name']) > 255) {
|
||||
errorResponse('Název je příliš dlouhý (max 255 znaků)');
|
||||
}
|
||||
foreach (['street', 'city', 'country'] as $f) {
|
||||
if (isset($input[$f]) && mb_strlen($input[$f]) > 255) {
|
||||
errorResponse("Pole $f je příliš dlouhé (max 255 znaků)");
|
||||
}
|
||||
}
|
||||
if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) {
|
||||
errorResponse('PSČ je příliš dlouhé (max 20 znaků)');
|
||||
}
|
||||
if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) {
|
||||
errorResponse('IČO je příliš dlouhé (max 50 znaků)');
|
||||
}
|
||||
if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) {
|
||||
errorResponse('DIČ je příliš dlouhé (max 50 znaků)');
|
||||
}
|
||||
|
||||
$customFieldsJson = encodeCustomerCustomFields($input, $existing['custom_fields'] ?? null);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE customers SET
|
||||
name = ?,
|
||||
street = ?,
|
||||
city = ?,
|
||||
postal_code = ?,
|
||||
country = ?,
|
||||
company_id = ?,
|
||||
vat_id = ?,
|
||||
custom_fields = ?,
|
||||
modified_at = NOW(),
|
||||
sync_version = sync_version + 1
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([
|
||||
$input['name'] ?? $existing['name'],
|
||||
$input['street'] ?? $existing['street'],
|
||||
$input['city'] ?? $existing['city'],
|
||||
$input['postal_code'] ?? $existing['postal_code'],
|
||||
$input['country'] ?? $existing['country'],
|
||||
$input['company_id'] ?? $existing['company_id'],
|
||||
$input['vat_id'] ?? $existing['vat_id'],
|
||||
$customFieldsJson,
|
||||
$id,
|
||||
]);
|
||||
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'customer',
|
||||
$id,
|
||||
['name' => $existing['name']],
|
||||
['name' => $input['name'] ?? $existing['name']],
|
||||
"Upraven zákazník #$id"
|
||||
);
|
||||
|
||||
successResponse(null, 'Zákazník byl aktualizován');
|
||||
}
|
||||
|
||||
function handleDeleteCustomer(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$customer = $stmt->fetch();
|
||||
|
||||
if (!$customer) {
|
||||
errorResponse('Zákazník nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
// Check if customer has quotations
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM quotations WHERE customer_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$count = (int)$stmt->fetchColumn();
|
||||
|
||||
if ($count > 0) {
|
||||
errorResponse("Zákazníka nelze smazat, má $count nabídek");
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM customers WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
|
||||
AuditLog::logDelete('customer', $id, ['name' => $customer['name']], "Smazán zákazník '{$customer['name']}'");
|
||||
|
||||
successResponse(null, 'Zákazník byl smazán');
|
||||
}
|
||||
281
api/admin/dashboard.php
Normal file
281
api/admin/dashboard.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dashboard API - agregovaná data pro dashboard
|
||||
*
|
||||
* GET /api/admin/dashboard.php
|
||||
* Vrací sekce dle oprávnění přihlášeného uživatele.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$result = [];
|
||||
|
||||
// --- Stav smeny aktualniho uzivatele (attendance.record) ---
|
||||
$userId = $authData['user_id'];
|
||||
if (hasPermission($authData, 'attendance.record')) {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id FROM attendance
|
||||
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$result['my_shift'] = [
|
||||
'has_ongoing' => (bool) $stmt->fetch(),
|
||||
];
|
||||
}
|
||||
|
||||
// --- Docházka dnes (attendance.admin) ---
|
||||
if (hasPermission($authData, 'attendance.admin')) {
|
||||
// Poslední pracovní záznam per uživatel (vyloučit ty co mají leave dnes)
|
||||
$stmt = $pdo->query("
|
||||
SELECT u.id, CONCAT(u.first_name, ' ', u.last_name) as name,
|
||||
CONCAT(LEFT(u.first_name, 1), LEFT(u.last_name, 1)) as initials,
|
||||
a.arrival_time, a.departure_time, a.break_start, a.break_end
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT a1.*
|
||||
FROM attendance a1
|
||||
INNER JOIN (
|
||||
SELECT user_id, MAX(id) as max_id
|
||||
FROM attendance
|
||||
WHERE shift_date = CURDATE()
|
||||
AND (leave_type IS NULL OR leave_type = 'work')
|
||||
GROUP BY user_id
|
||||
) a2 ON a1.id = a2.max_id
|
||||
) a ON u.id = a.user_id
|
||||
WHERE u.is_active = 1
|
||||
AND u.id NOT IN (
|
||||
SELECT user_id FROM attendance
|
||||
WHERE shift_date = CURDATE() AND leave_type IN ('vacation', 'sick', 'holiday', 'unpaid')
|
||||
)
|
||||
ORDER BY a.arrival_time IS NULL, a.arrival_time ASC
|
||||
");
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$present = 0;
|
||||
$away = 0;
|
||||
$attendanceUsers = [];
|
||||
|
||||
foreach ($users as $u) {
|
||||
$status = 'out';
|
||||
$arrivedAt = null;
|
||||
|
||||
if ($u['arrival_time'] !== null) {
|
||||
if ($u['departure_time'] !== null) {
|
||||
$status = 'out';
|
||||
} elseif ($u['break_start'] !== null && $u['break_end'] === null) {
|
||||
$status = 'away';
|
||||
$away++;
|
||||
} else {
|
||||
$status = 'in';
|
||||
$present++;
|
||||
}
|
||||
$arrivedAt = date('H:i', strtotime($u['arrival_time']));
|
||||
}
|
||||
|
||||
$attendanceUsers[] = [
|
||||
'name' => $u['name'],
|
||||
'initials' => $u['initials'],
|
||||
'status' => $status,
|
||||
'arrived_at' => $arrivedAt,
|
||||
];
|
||||
}
|
||||
|
||||
// Dnes na dovolene/nemocenske
|
||||
$stmtLeave = $pdo->query("
|
||||
SELECT CONCAT(u.first_name, ' ', u.last_name) as name,
|
||||
CONCAT(LEFT(u.first_name, 1), LEFT(u.last_name, 1)) as initials,
|
||||
a.leave_type
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date = CURDATE() AND a.leave_type IN ('vacation', 'sick', 'holiday', 'unpaid')
|
||||
");
|
||||
$onLeave = $stmtLeave->fetchAll();
|
||||
|
||||
foreach ($onLeave as $leave) {
|
||||
$attendanceUsers[] = [
|
||||
'name' => $leave['name'],
|
||||
'initials' => $leave['initials'],
|
||||
'status' => 'leave',
|
||||
'arrived_at' => null,
|
||||
'leave_type' => $leave['leave_type'],
|
||||
];
|
||||
}
|
||||
|
||||
$result['attendance'] = [
|
||||
'present_today' => $present,
|
||||
'away_today' => $away,
|
||||
'total_active' => count($users),
|
||||
'on_leave' => count($onLeave),
|
||||
'users' => $attendanceUsers,
|
||||
];
|
||||
}
|
||||
|
||||
// --- Nabídky (offers.view) ---
|
||||
if (hasPermission($authData, 'offers.view')) {
|
||||
$stmt = $pdo->query("
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN q.order_id IS NULL
|
||||
AND (q.valid_until IS NULL OR q.valid_until >= CURDATE())
|
||||
THEN 1 ELSE 0 END) as open_count,
|
||||
SUM(CASE WHEN q.order_id IS NULL
|
||||
AND q.valid_until < CURDATE()
|
||||
THEN 1 ELSE 0 END) as expired_count,
|
||||
SUM(CASE WHEN q.order_id IS NOT NULL
|
||||
THEN 1 ELSE 0 END) as converted_count
|
||||
FROM quotations q
|
||||
");
|
||||
$counts = $stmt->fetch();
|
||||
|
||||
$stmtMonth = $pdo->query("
|
||||
SELECT COUNT(*) as count FROM quotations
|
||||
WHERE YEAR(created_at) = YEAR(CURDATE()) AND MONTH(created_at) = MONTH(CURDATE())
|
||||
");
|
||||
$monthData = $stmtMonth->fetch();
|
||||
|
||||
$result['offers'] = [
|
||||
'total' => (int) $counts['total'],
|
||||
'open_count' => (int) $counts['open_count'],
|
||||
'expired_count' => (int) $counts['expired_count'],
|
||||
'converted_count' => (int) $counts['converted_count'],
|
||||
'created_this_month' => (int) $monthData['count'],
|
||||
];
|
||||
}
|
||||
|
||||
// --- Projekty (projects.view) ---
|
||||
if (hasPermission($authData, 'projects.view')) {
|
||||
$stmt = $pdo->query("
|
||||
SELECT p.id, p.name, p.status, c.name as customer_name
|
||||
FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
WHERE p.status = 'aktivni'
|
||||
ORDER BY p.modified_at DESC
|
||||
LIMIT 5
|
||||
");
|
||||
$activeProjects = $stmt->fetchAll();
|
||||
|
||||
$stmtCounts = $pdo->query("
|
||||
SELECT
|
||||
SUM(CASE WHEN status = 'aktivni' THEN 1 ELSE 0 END) as active_count,
|
||||
SUM(CASE WHEN status = 'dokonceny' THEN 1 ELSE 0 END) as completed_count
|
||||
FROM projects WHERE status != 'deleted'
|
||||
");
|
||||
$projectCounts = $stmtCounts->fetch();
|
||||
|
||||
$result['projects'] = [
|
||||
'active_count' => (int) ($projectCounts['active_count'] ?? 0),
|
||||
'completed_count' => (int) ($projectCounts['completed_count'] ?? 0),
|
||||
'active_projects' => $activeProjects,
|
||||
];
|
||||
}
|
||||
|
||||
// --- Faktury (invoices.view) ---
|
||||
if (hasPermission($authData, 'invoices.view')) {
|
||||
$stmt = $pdo->query("
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN i.status = 'paid'
|
||||
AND YEAR(i.paid_date) = YEAR(CURDATE())
|
||||
AND MONTH(i.paid_date) = MONTH(CURDATE())
|
||||
THEN 1 ELSE 0 END) as paid_this_month,
|
||||
SUM(CASE WHEN i.status IN ('issued', 'overdue')
|
||||
THEN 1 ELSE 0 END) as unpaid_count
|
||||
FROM invoices i
|
||||
");
|
||||
$invCounts = $stmt->fetch();
|
||||
|
||||
// Tržby tento měsíc per faktura (pro kurz k datu vystaveni)
|
||||
$stmtRevenue = $pdo->query("
|
||||
SELECT i.id, i.currency, i.issue_date,
|
||||
COALESCE(SUM(ii.quantity * ii.unit_price), 0) as revenue
|
||||
FROM invoices i
|
||||
JOIN invoice_items ii ON i.id = ii.invoice_id
|
||||
WHERE i.status = 'paid'
|
||||
AND YEAR(i.paid_date) = YEAR(CURDATE())
|
||||
AND MONTH(i.paid_date) = MONTH(CURDATE())
|
||||
GROUP BY i.id, i.currency, i.issue_date
|
||||
ORDER BY revenue DESC
|
||||
");
|
||||
|
||||
$revByCurrency = [];
|
||||
$revCzkItems = [];
|
||||
foreach ($stmtRevenue->fetchAll() as $row) {
|
||||
$cur = $row['currency'];
|
||||
$amt = (float) $row['revenue'];
|
||||
$revByCurrency[$cur] = ($revByCurrency[$cur] ?? 0) + $amt;
|
||||
$revCzkItems[] = [
|
||||
'amount' => $amt,
|
||||
'currency' => $cur,
|
||||
'date' => $row['issue_date'],
|
||||
];
|
||||
}
|
||||
|
||||
$revenueByCurrency = [];
|
||||
foreach ($revByCurrency as $cur => $total) {
|
||||
$revenueByCurrency[] = [
|
||||
'currency' => $cur,
|
||||
'amount' => round($total, 2),
|
||||
];
|
||||
}
|
||||
|
||||
$cnb = CnbRates::getInstance();
|
||||
|
||||
$result['invoices'] = [
|
||||
'total' => (int) $invCounts['total'],
|
||||
'paid_this_month' => (int) $invCounts['paid_this_month'],
|
||||
'unpaid_count' => (int) $invCounts['unpaid_count'],
|
||||
'revenue_this_month' => $revenueByCurrency,
|
||||
'revenue_czk' => $cnb->sumToCzk($revCzkItems),
|
||||
];
|
||||
}
|
||||
|
||||
// --- Čekající žádosti (attendance.approve) ---
|
||||
if (hasPermission($authData, 'attendance.approve')) {
|
||||
$stmt = $pdo->query("
|
||||
SELECT COUNT(*) as count FROM leave_requests WHERE status = 'pending'
|
||||
");
|
||||
$pending = $stmt->fetch();
|
||||
|
||||
$result['leave_pending'] = [
|
||||
'count' => (int) $pending['count'],
|
||||
];
|
||||
}
|
||||
|
||||
// --- Poslední aktivita (settings.roles = admin přehled) ---
|
||||
if (hasPermission($authData, 'settings.roles')) {
|
||||
$stmt = $pdo->query("
|
||||
SELECT username, action, entity_type, description, created_at
|
||||
FROM audit_logs
|
||||
WHERE action IN ('create', 'update', 'delete', 'login')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 8
|
||||
");
|
||||
$result['recent_activity'] = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
jsonResponse($result);
|
||||
1096
api/admin/invoices-pdf.php
Normal file
1096
api/admin/invoices-pdf.php
Normal file
File diff suppressed because it is too large
Load Diff
803
api/admin/invoices.php
Normal file
803
api/admin/invoices.php
Normal file
@@ -0,0 +1,803 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Invoices CRUD API
|
||||
*
|
||||
* GET /api/admin/invoices.php - Seznam faktur
|
||||
* GET /api/admin/invoices.php?action=detail&id=X - Detail faktury
|
||||
* GET /api/admin/invoices.php?action=next_number - Dalsi cislo faktury
|
||||
* GET /api/admin/invoices.php?action=order_data&id=X - Data objednavky pro pre-fill
|
||||
* GET /api/admin/invoices.php?action=stats - KPI statistiky (month, year)
|
||||
* POST /api/admin/invoices.php - Vytvoreni faktury
|
||||
* PUT /api/admin/invoices.php?id=X - Uprava faktury / zmena stavu
|
||||
* DELETE /api/admin/invoices.php?id=X - Smazani faktury
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'invoices.view');
|
||||
switch ($action) {
|
||||
case 'detail':
|
||||
if (!$id) {
|
||||
errorResponse('ID faktury je povinné');
|
||||
}
|
||||
handleGetDetail($pdo, $id);
|
||||
break;
|
||||
case 'next_number':
|
||||
requirePermission($authData, 'invoices.create');
|
||||
handleGetNextNumber($pdo);
|
||||
break;
|
||||
case 'order_data':
|
||||
requirePermission($authData, 'invoices.create');
|
||||
if (!$id) {
|
||||
errorResponse('ID objednávky je povinné');
|
||||
}
|
||||
handleGetOrderData($pdo, $id);
|
||||
break;
|
||||
case 'stats':
|
||||
requirePermission($authData, 'invoices.view');
|
||||
handleGetStats($pdo);
|
||||
break;
|
||||
default:
|
||||
handleGetList($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'invoices.create');
|
||||
handleCreateInvoice($pdo, $authData);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'invoices.edit');
|
||||
if (!$id) {
|
||||
errorResponse('ID faktury je povinné');
|
||||
}
|
||||
handleUpdateInvoice($pdo, $id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'invoices.delete');
|
||||
if (!$id) {
|
||||
errorResponse('ID faktury je povinné');
|
||||
}
|
||||
handleDeleteInvoice($pdo, $id);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Invoices API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Status transitions ---
|
||||
|
||||
/** @return list<string> */
|
||||
function getValidTransitions(string $status): array
|
||||
{
|
||||
return match ($status) {
|
||||
'issued' => ['paid'],
|
||||
'overdue' => ['paid'],
|
||||
default => []
|
||||
};
|
||||
}
|
||||
|
||||
// --- Invoice number generation ---
|
||||
|
||||
function generateInvoiceNumber(PDO $pdo): string
|
||||
{
|
||||
$yy = date('y');
|
||||
|
||||
$settings = $pdo->query('SELECT invoice_type_code FROM company_settings LIMIT 1')->fetch();
|
||||
$typeCode = ($settings && !empty($settings['invoice_type_code'])) ? $settings['invoice_type_code'] : '81';
|
||||
|
||||
$prefix = $yy . $typeCode;
|
||||
$prefixLen = strlen($prefix);
|
||||
$likePattern = $prefix . '%';
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ? + 1) AS UNSIGNED)), 0)
|
||||
FROM invoices WHERE invoice_number LIKE ?
|
||||
');
|
||||
$stmt->execute([$prefixLen, $likePattern]);
|
||||
$max = (int) $stmt->fetchColumn();
|
||||
|
||||
return sprintf('%s%04d', $prefix, $max + 1);
|
||||
}
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
/**
|
||||
* Spocita celkovou castku faktur seskupenou podle meny + CZK prepocet dle kurzu k datu faktury.
|
||||
*
|
||||
* @param array<int, string|int|float> $params
|
||||
* @return array{amounts: array<int, array{amount: float, currency: string}>, count: int, total_czk: float}
|
||||
*/
|
||||
function sumInvoicesByCurrency(PDO $pdo, string $where, array $params): array
|
||||
{
|
||||
// Per-faktura pro presny prepocet kurzem k datu
|
||||
$perInvoiceSql = "
|
||||
SELECT i.id, i.currency, i.issue_date,
|
||||
COALESCE(SUM(ii.quantity * ii.unit_price), 0)
|
||||
+ COALESCE(SUM(CASE WHEN i.apply_vat
|
||||
THEN ii.quantity * ii.unit_price * ii.vat_rate / 100
|
||||
ELSE 0 END), 0) AS total
|
||||
FROM invoices i
|
||||
JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||
$where
|
||||
GROUP BY i.id, i.currency, i.issue_date
|
||||
";
|
||||
$stmt = $pdo->prepare($perInvoiceSql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
// Seskupit podle meny pro zobrazeni
|
||||
$byCurrency = [];
|
||||
$czkItems = [];
|
||||
foreach ($rows as $r) {
|
||||
$cur = $r['currency'];
|
||||
$amt = round((float) $r['total'], 2);
|
||||
$byCurrency[$cur] = ($byCurrency[$cur] ?? 0) + $amt;
|
||||
$czkItems[] = [
|
||||
'amount' => $amt,
|
||||
'currency' => $cur,
|
||||
'date' => $r['issue_date'],
|
||||
];
|
||||
}
|
||||
|
||||
$amounts = [];
|
||||
foreach ($byCurrency as $cur => $total) {
|
||||
$amounts[] = ['amount' => round($total, 2), 'currency' => $cur];
|
||||
}
|
||||
|
||||
$cnb = CnbRates::getInstance();
|
||||
$totalCzk = $cnb->sumToCzk($czkItems);
|
||||
|
||||
$countSql = "SELECT COUNT(*) FROM invoices i $where";
|
||||
$countStmt = $pdo->prepare($countSql);
|
||||
$countStmt->execute($params);
|
||||
|
||||
return [
|
||||
'amounts' => $amounts,
|
||||
'count' => (int) $countStmt->fetchColumn(),
|
||||
'total_czk' => $totalCzk,
|
||||
];
|
||||
}
|
||||
|
||||
function handleGetStats(PDO $pdo): void
|
||||
{
|
||||
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
||||
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
||||
|
||||
// Lazy overdue detekce
|
||||
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
||||
|
||||
$monthStart = sprintf('%04d-%02d-01', $year, $month);
|
||||
$monthEnd = date('Y-m-t', strtotime($monthStart));
|
||||
|
||||
// a) Uhrazeno v danem mesici (dle data vystaveni, ne uhrazeni)
|
||||
$paidWhere = "WHERE i.status = 'paid' AND i.issue_date BETWEEN ? AND ?";
|
||||
$paid = sumInvoicesByCurrency($pdo, $paidWhere, [$monthStart, $monthEnd]);
|
||||
|
||||
// b) Ceka uhrada (aktualni stav)
|
||||
$awaiting = sumInvoicesByCurrency($pdo, "WHERE i.status = 'issued'", []);
|
||||
|
||||
// c) Po splatnosti (aktualni stav)
|
||||
$overdue = sumInvoicesByCurrency($pdo, "WHERE i.status = 'overdue'", []);
|
||||
|
||||
// d) DPH v danem mesici - per faktura pro kurz k datu
|
||||
$vatSql = "
|
||||
SELECT i.id, i.currency, i.issue_date,
|
||||
COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0) AS vat_total
|
||||
FROM invoices i
|
||||
JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||
WHERE i.apply_vat = 1 AND i.issue_date BETWEEN ? AND ?
|
||||
GROUP BY i.id, i.currency, i.issue_date
|
||||
";
|
||||
$vatStmt = $pdo->prepare($vatSql);
|
||||
$vatStmt->execute([$monthStart, $monthEnd]);
|
||||
$vatRows = $vatStmt->fetchAll();
|
||||
|
||||
$vatByCurrency = [];
|
||||
$vatCzkItems = [];
|
||||
foreach ($vatRows as $r) {
|
||||
$cur = $r['currency'];
|
||||
$amt = round((float) $r['vat_total'], 2);
|
||||
$vatByCurrency[$cur] = ($vatByCurrency[$cur] ?? 0) + $amt;
|
||||
$vatCzkItems[] = [
|
||||
'amount' => $amt,
|
||||
'currency' => $cur,
|
||||
'date' => $r['issue_date'],
|
||||
];
|
||||
}
|
||||
|
||||
$vatAmounts = [];
|
||||
foreach ($vatByCurrency as $cur => $total) {
|
||||
$vatAmounts[] = ['amount' => round($total, 2), 'currency' => $cur];
|
||||
}
|
||||
|
||||
$cnb = CnbRates::getInstance();
|
||||
|
||||
successResponse([
|
||||
'paid_month' => $paid['amounts'],
|
||||
'paid_month_czk' => $paid['total_czk'],
|
||||
'paid_month_count' => $paid['count'],
|
||||
'awaiting' => $awaiting['amounts'],
|
||||
'awaiting_czk' => $awaiting['total_czk'],
|
||||
'awaiting_count' => $awaiting['count'],
|
||||
'overdue' => $overdue['amounts'],
|
||||
'overdue_czk' => $overdue['total_czk'],
|
||||
'overdue_count' => $overdue['count'],
|
||||
'vat_month' => $vatAmounts,
|
||||
'vat_month_czk' => $cnb->sumToCzk($vatCzkItems),
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function handleGetList(PDO $pdo): void
|
||||
{
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$statusFilter = trim($_GET['status'] ?? '');
|
||||
$sort = $_GET['sort'] ?? 'created_at';
|
||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||
|
||||
$sortMap = [
|
||||
'InvoiceNumber' => 'i.invoice_number',
|
||||
'invoice_number' => 'i.invoice_number',
|
||||
'CreatedAt' => 'i.created_at',
|
||||
'created_at' => 'i.created_at',
|
||||
'Status' => 'i.status',
|
||||
'status' => 'i.status',
|
||||
'DueDate' => 'i.due_date',
|
||||
'due_date' => 'i.due_date',
|
||||
'IssueDate' => 'i.issue_date',
|
||||
'issue_date' => 'i.issue_date',
|
||||
];
|
||||
if (!isset($sortMap[$sort])) {
|
||||
errorResponse('Neplatný parametr řazení', 400);
|
||||
}
|
||||
$sortCol = $sortMap[$sort];
|
||||
|
||||
// Lazy overdue detekce
|
||||
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
||||
|
||||
$where = 'WHERE 1=1';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
$where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)';
|
||||
$searchParam = "%{$search}%";
|
||||
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
|
||||
}
|
||||
|
||||
if ($statusFilter) {
|
||||
$statuses = array_filter(explode(',', $statusFilter));
|
||||
if ($statuses) {
|
||||
$placeholders = implode(',', array_fill(0, count($statuses), '?'));
|
||||
$where .= " AND i.status IN ($placeholders)";
|
||||
$params = array_merge($params, $statuses);
|
||||
}
|
||||
}
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency,
|
||||
i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat,
|
||||
c.name as customer_name,
|
||||
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
|
||||
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
|
||||
o.order_number
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
LEFT JOIN orders o ON i.order_id = o.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$invoices = $stmt->fetchAll();
|
||||
|
||||
// Dopocitat celkovou castku s DPH
|
||||
foreach ($invoices as &$inv) {
|
||||
$subtotal = (float) $inv['subtotal'];
|
||||
if ($inv['apply_vat']) {
|
||||
$vatStmt = $pdo->prepare('
|
||||
SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0)
|
||||
FROM invoice_items WHERE invoice_id = ?
|
||||
');
|
||||
$vatStmt->execute([$inv['id']]);
|
||||
$vatAmount = (float) $vatStmt->fetchColumn();
|
||||
$inv['total'] = $subtotal + $vatAmount;
|
||||
} else {
|
||||
$inv['total'] = $subtotal;
|
||||
}
|
||||
}
|
||||
unset($inv);
|
||||
|
||||
successResponse([
|
||||
'invoices' => $invoices,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
// Lazy overdue
|
||||
$pdo->prepare(
|
||||
"UPDATE invoices SET status = 'overdue' WHERE id = ? AND status = 'issued' AND due_date < CURDATE()"
|
||||
)->execute([$id]);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT i.*, c.name as customer_name, o.order_number
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
LEFT JOIN orders o ON i.order_id = o.id
|
||||
WHERE i.id = ?
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) {
|
||||
errorResponse('Faktura nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Polozky
|
||||
$stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position');
|
||||
$stmt->execute([$id]);
|
||||
$invoice['items'] = $stmt->fetchAll();
|
||||
|
||||
// Zakaznik
|
||||
if ($invoice['customer_id']) {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields
|
||||
FROM customers WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$invoice['customer_id']]);
|
||||
$invoice['customer'] = $stmt->fetch();
|
||||
}
|
||||
|
||||
$invoice['valid_transitions'] = getValidTransitions($invoice['status']);
|
||||
|
||||
successResponse($invoice);
|
||||
}
|
||||
|
||||
function handleGetNextNumber(PDO $pdo): void
|
||||
{
|
||||
$number = generateInvoiceNumber($pdo);
|
||||
successResponse(['number' => $number]);
|
||||
}
|
||||
|
||||
function handleGetOrderData(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT o.id, o.order_number, o.customer_id, o.status, o.currency,
|
||||
o.language, o.vat_rate, o.apply_vat, o.exchange_rate,
|
||||
o.created_at, o.modified_at,
|
||||
c.name as customer_name
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
WHERE o.id = ?
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$order = $stmt->fetch();
|
||||
|
||||
if (!$order) {
|
||||
errorResponse('Objednávka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Polozky objednavky
|
||||
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position');
|
||||
$stmt->execute([$id]);
|
||||
$order['items'] = $stmt->fetchAll();
|
||||
|
||||
successResponse($order);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $authData */
|
||||
function handleCreateInvoice(PDO $pdo, array $authData): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$customerId = isset($input['customer_id']) ? (int) $input['customer_id'] : null;
|
||||
$orderId = !empty($input['order_id']) ? (int) $input['order_id'] : null;
|
||||
$issueDate = trim($input['issue_date'] ?? '');
|
||||
$dueDate = trim($input['due_date'] ?? '');
|
||||
$taxDate = trim($input['tax_date'] ?? '');
|
||||
$currency = trim($input['currency'] ?? 'CZK');
|
||||
$applyVat = isset($input['apply_vat']) ? (int) $input['apply_vat'] : 1;
|
||||
$paymentMethod = trim($input['payment_method'] ?? 'Příkazem');
|
||||
$constantSymbol = trim($input['constant_symbol'] ?? '0308');
|
||||
$issuedBy = trim($input['issued_by'] ?? '');
|
||||
$notes = trim($input['notes'] ?? '');
|
||||
$items = $input['items'] ?? [];
|
||||
|
||||
// Bankovni udaje
|
||||
$bankName = trim($input['bank_name'] ?? '');
|
||||
$bankSwift = trim($input['bank_swift'] ?? '');
|
||||
$bankIban = trim($input['bank_iban'] ?? '');
|
||||
$bankAccount = trim($input['bank_account'] ?? '');
|
||||
|
||||
if (!$customerId) {
|
||||
errorResponse('Zákazník je povinný');
|
||||
}
|
||||
if (!$issueDate || !$dueDate || !$taxDate) {
|
||||
errorResponse('Všechna data (vystavení, splatnost, DÚZP) jsou povinná');
|
||||
}
|
||||
|
||||
// Validace formatu dat
|
||||
foreach (['issue_date' => $issueDate, 'due_date' => $dueDate, 'tax_date' => $taxDate] as $label => $date) {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
|
||||
errorResponse("Neplatný formát data: $label");
|
||||
}
|
||||
}
|
||||
|
||||
// Validace meny
|
||||
$validCurrencies = ['CZK', 'EUR', 'USD', 'GBP'];
|
||||
if (!in_array($currency, $validCurrencies)) {
|
||||
errorResponse('Neplatná měna');
|
||||
}
|
||||
|
||||
// Delkove limity
|
||||
if (mb_strlen($paymentMethod) > 50) {
|
||||
errorResponse('Forma úhrady je příliš dlouhá (max 50 znaků)');
|
||||
}
|
||||
if (mb_strlen($issuedBy) > 255) {
|
||||
errorResponse('Vystavil je příliš dlouhé (max 255 znaků)');
|
||||
}
|
||||
if (mb_strlen($notes) > 5000) {
|
||||
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
||||
}
|
||||
if (mb_strlen($bankName) > 255) {
|
||||
errorResponse('Název banky je příliš dlouhý');
|
||||
}
|
||||
if (mb_strlen($bankIban) > 50) {
|
||||
errorResponse('IBAN je příliš dlouhý');
|
||||
}
|
||||
if (mb_strlen($bankSwift) > 20) {
|
||||
errorResponse('BIC/SWIFT je příliš dlouhý');
|
||||
}
|
||||
if (mb_strlen($bankAccount) > 50) {
|
||||
errorResponse('Číslo účtu je příliš dlouhé');
|
||||
}
|
||||
if (!$bankAccount && !$bankIban) {
|
||||
errorResponse('Bankovní účet je povinný');
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
errorResponse('Faktura musí mít alespoň jednu položku');
|
||||
}
|
||||
|
||||
// Validace polozek
|
||||
foreach ($items as $i => $item) {
|
||||
$qty = $item['quantity'] ?? 1;
|
||||
$price = $item['unit_price'] ?? 0;
|
||||
$vatRate = $item['vat_rate'] ?? 21;
|
||||
if (!is_numeric($qty) || $qty < 0) {
|
||||
errorResponse('Položka #' . ($i + 1) . ': neplatné množství');
|
||||
}
|
||||
if (!is_numeric($price)) {
|
||||
errorResponse('Položka #' . ($i + 1) . ': neplatná cena');
|
||||
}
|
||||
if (!is_numeric($vatRate) || $vatRate < 0 || $vatRate > 100) {
|
||||
errorResponse('Položka #' . ($i + 1) . ': neplatná sazba DPH');
|
||||
}
|
||||
if (mb_strlen($item['description'] ?? '') > 500) {
|
||||
errorResponse('Položka #' . ($i + 1) . ': popis je příliš dlouhý (max 500 znaků)');
|
||||
}
|
||||
}
|
||||
|
||||
// Overit zakaznika
|
||||
$stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
||||
$stmt->execute([$customerId]);
|
||||
if (!$stmt->fetch()) {
|
||||
errorResponse('Zákazník nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
// Lock pro cislovani
|
||||
$locked = $pdo->query("SELECT GET_LOCK('boha_invoice_number', 5)")->fetchColumn();
|
||||
if (!$locked) {
|
||||
errorResponse('Nepodařilo se získat zámek pro číslo faktury, zkuste to znovu', 503);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$invoiceNumber = !empty($input['invoice_number'])
|
||||
? trim($input['invoice_number'])
|
||||
: generateInvoiceNumber($pdo);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO invoices (
|
||||
invoice_number, order_id, customer_id, status, currency,
|
||||
vat_rate, apply_vat, payment_method, constant_symbol,
|
||||
bank_name, bank_swift, bank_iban, bank_account,
|
||||
issue_date, due_date, tax_date, issued_by, notes,
|
||||
created_at, modified_at
|
||||
) VALUES (?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
$invoiceNumber,
|
||||
$orderId,
|
||||
$customerId,
|
||||
$currency,
|
||||
$input['vat_rate'] ?? 21,
|
||||
$applyVat,
|
||||
$paymentMethod,
|
||||
$constantSymbol,
|
||||
$bankName,
|
||||
$bankSwift,
|
||||
$bankIban,
|
||||
$bankAccount,
|
||||
$issueDate,
|
||||
$dueDate,
|
||||
$taxDate,
|
||||
$issuedBy,
|
||||
$notes,
|
||||
]);
|
||||
$invoiceId = (int) $pdo->lastInsertId();
|
||||
|
||||
// Vlozit polozky
|
||||
$itemStmt = $pdo->prepare('
|
||||
INSERT INTO invoice_items (
|
||||
invoice_id, description, quantity, unit, unit_price, vat_rate, position
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
foreach ($items as $i => $item) {
|
||||
$itemStmt->execute([
|
||||
$invoiceId,
|
||||
trim($item['description'] ?? ''),
|
||||
$item['quantity'] ?? 1,
|
||||
trim($item['unit'] ?? ''),
|
||||
$item['unit_price'] ?? 0,
|
||||
$item['vat_rate'] ?? 21,
|
||||
$item['position'] ?? $i,
|
||||
]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')");
|
||||
|
||||
AuditLog::logCreate('invoices_invoice', $invoiceId, [
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'customer_id' => $customerId,
|
||||
'order_id' => $orderId,
|
||||
], "Vytvořena faktura '$invoiceNumber'");
|
||||
|
||||
successResponse([
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
], 'Faktura byla vystavena');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateInvoice(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) {
|
||||
errorResponse('Faktura nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
$newStatus = $input['status'] ?? null;
|
||||
$isDraft = $invoice['status'] === 'issued';
|
||||
|
||||
// Zmena stavu
|
||||
if ($newStatus && $newStatus !== $invoice['status']) {
|
||||
$valid = getValidTransitions($invoice['status']);
|
||||
if (!in_array($newStatus, $valid)) {
|
||||
errorResponse("Neplatný přechod stavu z '{$invoice['status']}' na '$newStatus'");
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
if ($newStatus !== null && $newStatus !== $invoice['status']) {
|
||||
$updates[] = 'status = ?';
|
||||
$params[] = $newStatus;
|
||||
|
||||
if ($newStatus === 'paid') {
|
||||
$updates[] = 'paid_date = CURDATE()';
|
||||
}
|
||||
}
|
||||
|
||||
// V issued stavu lze editovat vsechna pole
|
||||
if ($isDraft) {
|
||||
// Validace dat
|
||||
foreach (['issue_date', 'due_date', 'tax_date'] as $dateField) {
|
||||
if (
|
||||
isset($input[$dateField])
|
||||
&& (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $input[$dateField]) || !strtotime($input[$dateField]))
|
||||
) {
|
||||
errorResponse("Neplatný formát data: $dateField");
|
||||
}
|
||||
}
|
||||
// Validace meny
|
||||
if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||
errorResponse('Neplatná měna');
|
||||
}
|
||||
// Validace DPH
|
||||
if (
|
||||
isset($input['vat_rate'])
|
||||
&& (!is_numeric($input['vat_rate']) || $input['vat_rate'] < 0 || $input['vat_rate'] > 100)
|
||||
) {
|
||||
errorResponse('Neplatná sazba DPH');
|
||||
}
|
||||
// Validace zakaznika
|
||||
if (isset($input['customer_id'])) {
|
||||
$custStmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
||||
$custStmt->execute([(int)$input['customer_id']]);
|
||||
if (!$custStmt->fetch()) {
|
||||
errorResponse('Zákazník nebyl nalezen', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$stringFields = [
|
||||
'issue_date' => 20, 'due_date' => 20, 'tax_date' => 20,
|
||||
'payment_method' => 50, 'constant_symbol' => 10,
|
||||
'bank_name' => 255, 'bank_swift' => 20, 'bank_iban' => 50, 'bank_account' => 50,
|
||||
'issued_by' => 255,
|
||||
];
|
||||
foreach ($stringFields as $field => $maxLen) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$val = trim((string)$input[$field]);
|
||||
if (mb_strlen($val) > $maxLen) {
|
||||
errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)");
|
||||
}
|
||||
$updates[] = "$field = ?";
|
||||
$params[] = $val;
|
||||
}
|
||||
}
|
||||
$numericFields = ['currency', 'vat_rate', 'apply_vat', 'customer_id'];
|
||||
foreach ($numericFields as $field) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$updates[] = "$field = ?";
|
||||
$params[] = $input[$field];
|
||||
}
|
||||
}
|
||||
|
||||
// Aktualizace polozek
|
||||
if (isset($input['items']) && is_array($input['items'])) {
|
||||
$pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]);
|
||||
|
||||
$itemStmt = $pdo->prepare('
|
||||
INSERT INTO invoice_items (
|
||||
invoice_id, description, quantity, unit, unit_price, vat_rate, position
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
foreach ($input['items'] as $i => $item) {
|
||||
$itemStmt->execute([
|
||||
$id,
|
||||
trim($item['description'] ?? ''),
|
||||
$item['quantity'] ?? 1,
|
||||
trim($item['unit'] ?? ''),
|
||||
$item['unit_price'] ?? 0,
|
||||
$item['vat_rate'] ?? 21,
|
||||
$item['position'] ?? $i,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poznamky lze editovat jen v issued/overdue stavu
|
||||
if ($isDraft || $invoice['status'] === 'overdue') {
|
||||
if (array_key_exists('notes', $input)) {
|
||||
$updates[] = 'notes = ?';
|
||||
$params[] = $input['notes'];
|
||||
}
|
||||
if (array_key_exists('internal_notes', $input)) {
|
||||
$updates[] = 'internal_notes = ?';
|
||||
$params[] = $input['internal_notes'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$updates[] = 'modified_at = NOW()';
|
||||
$params[] = $id;
|
||||
$sql = 'UPDATE invoices SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'invoices_invoice',
|
||||
$id,
|
||||
['status' => $invoice['status']],
|
||||
['status' => $newStatus ?? $invoice['status']],
|
||||
"Aktualizována faktura '{$invoice['invoice_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Faktura byla aktualizována');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteInvoice(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) {
|
||||
errorResponse('Faktura nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM invoices WHERE id = ?')->execute([$id]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logDelete('invoices_invoice', $id, [
|
||||
'invoice_number' => $invoice['invoice_number'],
|
||||
'customer_id' => $invoice['customer_id'],
|
||||
], "Smazána faktura '{$invoice['invoice_number']}'");
|
||||
|
||||
successResponse(null, 'Faktura byla smazána');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
537
api/admin/leave-requests.php
Normal file
537
api/admin/leave-requests.php
Normal file
@@ -0,0 +1,537 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Leave Requests API
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/admin/leave-requests.php - Get own leave requests
|
||||
* GET /api/admin/leave-requests.php?action=pending - Get all pending requests (approver)
|
||||
* GET /api/admin/leave-requests.php?action=all - Get all requests with filters (approver)
|
||||
* POST /api/admin/leave-requests.php - Submit new leave request
|
||||
* POST /api/admin/leave-requests.php?action=cancel - Cancel own pending request
|
||||
* POST /api/admin/leave-requests.php?action=approve - Approve a request (approver)
|
||||
* POST /api/admin/leave-requests.php?action=reject - Reject a request (approver)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||
require_once dirname(__DIR__) . '/includes/LeaveNotification.php';
|
||||
|
||||
// Set headers
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require authentication
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$userId = $authData['user_id'];
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($action === 'pending') {
|
||||
requirePermission($authData, 'attendance.approve');
|
||||
handleGetPending($pdo);
|
||||
} elseif ($action === 'all') {
|
||||
requirePermission($authData, 'attendance.approve');
|
||||
handleGetAll($pdo);
|
||||
} else {
|
||||
handleGetMyRequests($pdo, $userId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'cancel') {
|
||||
handleCancelRequest($pdo, $userId);
|
||||
} elseif ($action === 'approve') {
|
||||
requirePermission($authData, 'attendance.approve');
|
||||
handleApproveRequest($pdo, $userId, $authData);
|
||||
} elseif ($action === 'reject') {
|
||||
requirePermission($authData, 'attendance.approve');
|
||||
handleRejectRequest($pdo, $userId, $authData);
|
||||
} else {
|
||||
handleSubmitRequest($pdo, $userId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Nepodporovaná metoda', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Leave requests API error: ' . $e->getMessage());
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate number of business days between two dates (skip Sat/Sun)
|
||||
*/
|
||||
function calculateBusinessDays(string $dateFrom, string $dateTo): int
|
||||
{
|
||||
$start = new DateTime($dateFrom);
|
||||
$end = new DateTime($dateTo);
|
||||
$end->modify('+1 day'); // include the end date
|
||||
|
||||
$days = 0;
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
||||
foreach ($period as $date) {
|
||||
$dayOfWeek = (int)$date->format('N'); // 1=Mon, 7=Sun
|
||||
if ($dayOfWeek <= 5) {
|
||||
$days++;
|
||||
}
|
||||
}
|
||||
return $days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leave balance for user (reuse logic from attendance.php)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): array
|
||||
{
|
||||
$year = $year ?: (int)date('Y');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||
$stmt->execute([$userId, $year]);
|
||||
$balance = $stmt->fetch();
|
||||
|
||||
if (!$balance) {
|
||||
return [
|
||||
'vacation_total' => 160,
|
||||
'vacation_used' => 0,
|
||||
'vacation_remaining' => 160,
|
||||
'sick_used' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'vacation_total' => (float)$balance['vacation_total'],
|
||||
'vacation_used' => (float)$balance['vacation_used'],
|
||||
'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'],
|
||||
'sick_used' => (float)$balance['sick_used'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hours already locked in pending requests for vacation
|
||||
*/
|
||||
function getPendingVacationHours(PDO $pdo, int $userId, int $year): float
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(total_hours), 0) as pending_hours
|
||||
FROM leave_requests
|
||||
WHERE user_id = ? AND leave_type = 'vacation' AND status = 'pending'
|
||||
AND YEAR(date_from) = ?
|
||||
");
|
||||
$stmt->execute([$userId, $year]);
|
||||
return (float)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET - Own leave requests
|
||||
*/
|
||||
function handleGetMyRequests(PDO $pdo, int $userId): void
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT lr.*,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as reviewer_name
|
||||
FROM leave_requests lr
|
||||
LEFT JOIN users u ON lr.reviewer_id = u.id
|
||||
WHERE lr.user_id = ?
|
||||
ORDER BY lr.created_at DESC
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$requests = $stmt->fetchAll();
|
||||
|
||||
successResponse($requests);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - All pending requests (for approver)
|
||||
*/
|
||||
function handleGetPending(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT lr.*,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
|
||||
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
|
||||
FROM leave_requests lr
|
||||
JOIN users u ON lr.user_id = u.id
|
||||
LEFT JOIN users rv ON lr.reviewer_id = rv.id
|
||||
WHERE lr.status = 'pending'
|
||||
ORDER BY lr.created_at ASC
|
||||
");
|
||||
$stmt->execute();
|
||||
$requests = $stmt->fetchAll();
|
||||
|
||||
successResponse([
|
||||
'requests' => $requests,
|
||||
'count' => count($requests),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - All requests with filters (for approver)
|
||||
*/
|
||||
function handleGetAll(PDO $pdo): void
|
||||
{
|
||||
$status = $_GET['status'] ?? '';
|
||||
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($status && in_array($status, ['pending', 'approved', 'rejected', 'cancelled'])) {
|
||||
$where[] = 'lr.status = ?';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
if ($userId) {
|
||||
$where[] = 'lr.user_id = ?';
|
||||
$params[] = $userId;
|
||||
}
|
||||
|
||||
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT lr.*,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
|
||||
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
|
||||
FROM leave_requests lr
|
||||
JOIN users u ON lr.user_id = u.id
|
||||
LEFT JOIN users rv ON lr.reviewer_id = rv.id
|
||||
$whereClause
|
||||
ORDER BY lr.created_at DESC
|
||||
LIMIT 200
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$requests = $stmt->fetchAll();
|
||||
|
||||
successResponse($requests);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST - Submit new leave request
|
||||
*/
|
||||
function handleSubmitRequest(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$leaveType = $input['leave_type'] ?? '';
|
||||
$dateFrom = $input['date_from'] ?? '';
|
||||
$dateTo = $input['date_to'] ?? '';
|
||||
$notes = trim($input['notes'] ?? '');
|
||||
|
||||
if (!$leaveType || !$dateFrom || !$dateTo) {
|
||||
errorResponse('Vyplňte všechna povinná pole');
|
||||
}
|
||||
|
||||
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
|
||||
errorResponse('Neplatný typ nepřítomnosti');
|
||||
}
|
||||
|
||||
// Validate dates
|
||||
$from = new DateTime($dateFrom);
|
||||
$to = new DateTime($dateTo);
|
||||
if ($to < $from) {
|
||||
errorResponse('Datum "do" nesmí být před datem "od"');
|
||||
}
|
||||
|
||||
// Calculate business days
|
||||
$businessDays = calculateBusinessDays($dateFrom, $dateTo);
|
||||
if ($businessDays === 0) {
|
||||
errorResponse('Zvolené období neobsahuje žádné pracovní dny');
|
||||
}
|
||||
|
||||
$totalHours = $businessDays * 8;
|
||||
|
||||
// Check vacation balance
|
||||
if ($leaveType === 'vacation') {
|
||||
$year = (int)$from->format('Y');
|
||||
$balance = getLeaveBalanceForRequest($pdo, $userId, $year);
|
||||
$pendingHours = getPendingVacationHours($pdo, $userId, $year);
|
||||
$availableHours = $balance['vacation_remaining'] - $pendingHours;
|
||||
|
||||
if ($availableHours < $totalHours) {
|
||||
errorResponse(
|
||||
"Nemáte dostatek hodin dovolené. Dostupné: {$availableHours}h"
|
||||
. " (zbývá {$balance['vacation_remaining']}h, v čekajících žádostech: {$pendingHours}h),"
|
||||
. " požadujete: {$totalHours}h."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check overlapping requests
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id FROM leave_requests
|
||||
WHERE user_id = ? AND status IN ('pending', 'approved')
|
||||
AND date_from <= ? AND date_to >= ?
|
||||
");
|
||||
$stmt->execute([$userId, $dateTo, $dateFrom]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('Pro toto období již existuje žádost o nepřítomnost');
|
||||
}
|
||||
|
||||
// Insert request
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO leave_requests (user_id, leave_type, date_from, date_to, total_hours, total_days, notes, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
");
|
||||
$stmt->execute([$userId, $leaveType, $dateFrom, $dateTo, $totalHours, $businessDays, $notes ?: null]);
|
||||
|
||||
$requestId = (int)$pdo->lastInsertId();
|
||||
|
||||
AuditLog::logCreate('leave_request', $requestId, [
|
||||
'leave_type' => $leaveType,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'total_days' => $businessDays,
|
||||
'total_hours' => $totalHours,
|
||||
], "Podána žádost o nepřítomnost: $leaveType ($dateFrom - $dateTo)");
|
||||
|
||||
// Send email notification
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
$employeeName = $stmt->fetchColumn() ?: 'Neznámý';
|
||||
|
||||
LeaveNotification::notifyNewRequest([
|
||||
'leave_type' => $leaveType,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'total_days' => $businessDays,
|
||||
'total_hours' => $totalHours,
|
||||
'notes' => $notes,
|
||||
], $employeeName);
|
||||
} catch (\Exception $e) {
|
||||
error_log('Leave notification error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
successResponse(['id' => $requestId], 'Žádost byla odeslána ke schválení');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Cancel own pending request
|
||||
*/
|
||||
function handleCancelRequest(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$requestId = (int)($input['request_id'] ?? 0);
|
||||
|
||||
if (!$requestId) {
|
||||
errorResponse('ID žádosti je povinné');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ? AND user_id = ?');
|
||||
$stmt->execute([$requestId, $userId]);
|
||||
$request = $stmt->fetch();
|
||||
|
||||
if (!$request) {
|
||||
errorResponse('Žádost nebyla nalezena');
|
||||
}
|
||||
|
||||
if ($request['status'] !== 'pending') {
|
||||
errorResponse('Lze zrušit pouze čekající žádosti');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE leave_requests SET status = 'cancelled' WHERE id = ?");
|
||||
$stmt->execute([$requestId]);
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'leave_request',
|
||||
$requestId,
|
||||
['status' => 'pending'],
|
||||
['status' => 'cancelled'],
|
||||
'Žádost o nepřítomnost zrušena zaměstnancem'
|
||||
);
|
||||
|
||||
successResponse(null, 'Žádost byla zrušena');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Approve a leave request
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$requestId = (int)($input['request_id'] ?? 0);
|
||||
|
||||
if (!$requestId) {
|
||||
errorResponse('ID žádosti je povinné');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?');
|
||||
$stmt->execute([$requestId]);
|
||||
$request = $stmt->fetch();
|
||||
|
||||
if (!$request) {
|
||||
errorResponse('Žádost nebyla nalezena');
|
||||
}
|
||||
|
||||
if ($request['status'] !== 'pending') {
|
||||
errorResponse('Lze schválit pouze čekající žádosti');
|
||||
}
|
||||
|
||||
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
|
||||
errorResponse('Nemůžete schválit vlastní žádost', 403);
|
||||
}
|
||||
|
||||
// Re-check vacation balance
|
||||
if ($request['leave_type'] === 'vacation') {
|
||||
$year = (int)date('Y', strtotime($request['date_from']));
|
||||
$balance = getLeaveBalanceForRequest($pdo, (int)$request['user_id'], $year);
|
||||
|
||||
if ($balance['vacation_remaining'] < (float)$request['total_hours']) {
|
||||
errorResponse(
|
||||
"Zaměstnanec nemá dostatek hodin dovolené."
|
||||
. " Zbývá: {$balance['vacation_remaining']}h, požadováno: {$request['total_hours']}h."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
// Create attendance records for each business day
|
||||
$start = new DateTime($request['date_from']);
|
||||
$end = new DateTime($request['date_to']);
|
||||
$end->modify('+1 day');
|
||||
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
||||
$insertStmt = $pdo->prepare('
|
||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||
VALUES (?, ?, ?, 8, ?)
|
||||
');
|
||||
|
||||
$leaveNote = "Schválená žádost #$requestId";
|
||||
$totalBusinessDays = 0;
|
||||
|
||||
foreach ($period as $date) {
|
||||
$dayOfWeek = (int)$date->format('N');
|
||||
if ($dayOfWeek <= 5) {
|
||||
$shiftDate = $date->format('Y-m-d');
|
||||
$insertStmt->execute([
|
||||
$request['user_id'],
|
||||
$shiftDate,
|
||||
$request['leave_type'],
|
||||
$leaveNote,
|
||||
]);
|
||||
$totalBusinessDays++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update leave balance ONCE with total hours (was N queries, one per day)
|
||||
if ($totalBusinessDays > 0) {
|
||||
updateLeaveBalance(
|
||||
$pdo,
|
||||
(int)$request['user_id'],
|
||||
$request['date_from'],
|
||||
$request['leave_type'],
|
||||
(float)($totalBusinessDays * 8)
|
||||
);
|
||||
}
|
||||
|
||||
// Update request status
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE leave_requests
|
||||
SET status = 'approved', reviewer_id = ?, reviewed_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$reviewerId, $requestId]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'leave_request',
|
||||
$requestId,
|
||||
['status' => 'pending'],
|
||||
['status' => 'approved', 'reviewer_id' => $reviewerId],
|
||||
'Žádost o nepřítomnost schválena'
|
||||
);
|
||||
|
||||
successResponse(null, 'Žádost byla schválena');
|
||||
} catch (\Exception $e) {
|
||||
$pdo->rollBack();
|
||||
error_log('Approve request error: ' . $e->getMessage());
|
||||
errorResponse('Chyba při schvalování žádosti', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Reject a leave request
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$requestId = (int)($input['request_id'] ?? 0);
|
||||
$note = trim($input['note'] ?? '');
|
||||
|
||||
if (!$requestId) {
|
||||
errorResponse('ID žádosti je povinné');
|
||||
}
|
||||
|
||||
if (!$note) {
|
||||
errorResponse('Důvod zamítnutí je povinný');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?');
|
||||
$stmt->execute([$requestId]);
|
||||
$request = $stmt->fetch();
|
||||
|
||||
if (!$request) {
|
||||
errorResponse('Žádost nebyla nalezena');
|
||||
}
|
||||
|
||||
if ($request['status'] !== 'pending') {
|
||||
errorResponse('Lze zamítnout pouze čekající žádosti');
|
||||
}
|
||||
|
||||
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
|
||||
errorResponse('Nemůžete zamítnout vlastní žádost', 403);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE leave_requests
|
||||
SET status = 'rejected', reviewer_id = ?, reviewer_note = ?, reviewed_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$reviewerId, $note, $requestId]);
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'leave_request',
|
||||
$requestId,
|
||||
['status' => 'pending'],
|
||||
['status' => 'rejected', 'reviewer_id' => $reviewerId, 'reviewer_note' => $note],
|
||||
"Žádost o nepřítomnost zamítnuta: $note"
|
||||
);
|
||||
|
||||
successResponse(null, 'Žádost byla zamítnuta');
|
||||
}
|
||||
180
api/admin/login.php
Normal file
180
api/admin/login.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Admin Login API (JWT)
|
||||
*
|
||||
* POST /api/admin/login.php
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* "username": "string",
|
||||
* "password": "string",
|
||||
* "remember": boolean (optional)
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": boolean,
|
||||
* "data": { "access_token", "expires_in", "user" } | null,
|
||||
* "error": "string" | null
|
||||
* }
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$rateLimiter = new RateLimiter();
|
||||
$rateLimiter->setFailClosed();
|
||||
$rateLimiter->enforce('login', 10);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$username = trim($input['username'] ?? '');
|
||||
$password = $input['password'] ?? '';
|
||||
$remember = (bool) ($input['remember'] ?? false);
|
||||
|
||||
if (empty($username)) {
|
||||
errorResponse('Uživatelské jméno je povinné');
|
||||
}
|
||||
|
||||
if (empty($password)) {
|
||||
errorResponse('Heslo je povinné');
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
u.role_id, u.failed_login_attempts, u.locked_until, u.is_active, u.totp_enabled,
|
||||
r.name as role_name, r.display_name as role_display_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.username = ? OR u.email = ?
|
||||
');
|
||||
$stmt->execute([$username, $username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user) {
|
||||
AuditLog::logLoginFailed($username, 'invalid_credentials');
|
||||
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||
}
|
||||
|
||||
if (!$user['is_active']) {
|
||||
AuditLog::logLoginFailed($username, 'account_deactivated');
|
||||
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||
}
|
||||
|
||||
if ($user['locked_until'] && strtotime($user['locked_until']) > time()) {
|
||||
AuditLog::logLoginFailed($username, 'account_locked');
|
||||
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||
}
|
||||
|
||||
if (!password_verify($password, $user['password_hash'])) {
|
||||
$attempts = $user['failed_login_attempts'] + 1;
|
||||
$lockUntil = null;
|
||||
|
||||
if ($attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||
$lockUntil = date('Y-m-d H:i:s', time() + (LOCKOUT_MINUTES * 60));
|
||||
$attempts = 0; // Reset after lockout
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE users SET failed_login_attempts = ?, locked_until = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$attempts, $lockUntil, $user['id']]);
|
||||
|
||||
AuditLog::logLoginFailed($username, 'invalid_credentials');
|
||||
errorResponse('Neplatné uživatelské jméno nebo heslo', 401);
|
||||
}
|
||||
|
||||
$role = ['name' => $user['role_name'], 'display_name' => $user['role_display_name']];
|
||||
|
||||
// 2FA - neresit failed_attempts, az po overeni
|
||||
if ($user['totp_enabled']) {
|
||||
$loginToken = bin2hex(random_bytes(32));
|
||||
$hashedLoginToken = hash('sha256', $loginToken);
|
||||
$loginTokenExpiry = date('Y-m-d H:i:s', time() + 300);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()');
|
||||
$stmt->execute([$user['id']]);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO totp_login_tokens (user_id, token_hash, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$user['id'], $hashedLoginToken, $loginTokenExpiry]);
|
||||
|
||||
successResponse([
|
||||
'requires_2fa' => true,
|
||||
'login_token' => $loginToken,
|
||||
]);
|
||||
}
|
||||
|
||||
// Bez 2FA - reset failed attempts a pokracovat
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$user['id']]);
|
||||
|
||||
$userData = [
|
||||
'id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'email' => $user['email'],
|
||||
'first_name' => $user['first_name'],
|
||||
'last_name' => $user['last_name'],
|
||||
'role' => $role['name'] ?? null,
|
||||
'role_display' => $role['display_name'] ?? $role['name'] ?? null,
|
||||
'is_admin' => ($role['name'] ?? '') === 'admin',
|
||||
];
|
||||
|
||||
$accessToken = JWTAuth::generateAccessToken($userData);
|
||||
JWTAuth::generateRefreshToken($user['id'], $remember);
|
||||
AuditLog::logLogin($user['id'], $user['username']);
|
||||
$require2FA = false;
|
||||
try {
|
||||
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||
$require2FA = (bool) $stmt->fetchColumn();
|
||||
} catch (PDOException $e) {
|
||||
}
|
||||
|
||||
$permissions = JWTAuth::getUserPermissions($user['id']);
|
||||
|
||||
successResponse([
|
||||
'access_token' => $accessToken,
|
||||
'expires_in' => JWTAuth::getAccessTokenExpiry(),
|
||||
'user' => [
|
||||
'id' => $userData['id'],
|
||||
'username' => $userData['username'],
|
||||
'email' => $userData['email'],
|
||||
'full_name' => trim($userData['first_name'] . ' ' . $userData['last_name']),
|
||||
'role' => $userData['role'],
|
||||
'role_display' => $userData['role_display'],
|
||||
'is_admin' => $userData['is_admin'],
|
||||
'permissions' => $permissions,
|
||||
'totp_enabled' => false,
|
||||
'require_2fa' => $require2FA,
|
||||
],
|
||||
], 'Přihlášení úspěšné');
|
||||
} catch (PDOException $e) {
|
||||
error_log('Login PDO error: ' . $e->getMessage());
|
||||
errorResponse('Došlo k systémové chybě. Zkuste to prosím později.', 500);
|
||||
} catch (Exception $e) {
|
||||
error_log('Login error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
errorResponse('Došlo k systémové chybě. Zkuste to prosím později.', 500);
|
||||
}
|
||||
51
api/admin/logout.php
Normal file
51
api/admin/logout.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Admin Logout API (JWT)
|
||||
*
|
||||
* POST /api/admin/logout.php
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": true,
|
||||
* "message": "Logged out successfully"
|
||||
* }
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||
|
||||
// Set headers
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Rate limiting (30 requests/minute)
|
||||
$rateLimiter = new RateLimiter();
|
||||
$rateLimiter->enforce('logout', 30);
|
||||
|
||||
// Only accept POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
// Get user from access token if available (for audit logging)
|
||||
$authData = JWTAuth::optionalAuth();
|
||||
|
||||
// Log logout before revoking tokens
|
||||
if ($authData) {
|
||||
AuditLog::logLogout($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
}
|
||||
|
||||
// Revoke refresh token (from cookie)
|
||||
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||
if ($refreshToken) {
|
||||
JWTAuth::revokeRefreshToken($refreshToken);
|
||||
}
|
||||
|
||||
successResponse(null, 'Odhlášení úspěšné');
|
||||
858
api/admin/offers-pdf.php
Normal file
858
api/admin/offers-pdf.php
Normal file
@@ -0,0 +1,858 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Offers PDF Export (Print-ready HTML)
|
||||
*
|
||||
* Returns a self-contained HTML page that auto-triggers window.print().
|
||||
* The browser's "Save as PDF" produces the final PDF.
|
||||
*
|
||||
* GET /api/admin/offers-pdf.php?id=X - Returns print-ready HTML
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
requirePermission($authData, 'offers.export');
|
||||
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
if (!$id) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
errorResponse('ID nabídky je povinné');
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$quotation = $stmt->fetch();
|
||||
if (!$quotation) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
errorResponse('Nabídka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$customer = null;
|
||||
if ($quotation['customer_id']) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||
$stmt->execute([$quotation['customer_id']]);
|
||||
$customer = $stmt->fetch();
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
||||
$stmt->execute([$id]);
|
||||
$items = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
||||
$stmt->execute([$id]);
|
||||
$sections = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1');
|
||||
$settings = $stmt->fetch();
|
||||
|
||||
$logoBase64 = '';
|
||||
$logoMime = 'image/png';
|
||||
if (!empty($settings['logo_data'])) {
|
||||
$logoBase64 = base64_encode($settings['logo_data']);
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$detected = $finfo->buffer($settings['logo_data']);
|
||||
$allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
if ($detected && in_array($detected, $allowedMimes)) {
|
||||
$logoMime = $detected;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
$isCzech = ($quotation['language'] ?? 'EN') === 'CZ';
|
||||
$currency = $quotation['currency'] ?? 'EUR';
|
||||
|
||||
$translations = [
|
||||
'title' => ['EN' => 'PRICE QUOTATION', 'CZ' => 'CENOVÁ NABÍDKA'],
|
||||
'scope_title' => ['EN' => 'SCOPE OF THE PROJECT', 'CZ' => 'ROZSAH PROJEKTU'],
|
||||
'valid_until' => ['EN' => 'Valid until', 'CZ' => 'Platnost do'],
|
||||
'customer' => ['EN' => 'Customer', 'CZ' => 'Zákazník'],
|
||||
'supplier' => ['EN' => 'Supplier', 'CZ' => 'Dodavatel'],
|
||||
'no' => ['EN' => 'N.', 'CZ' => 'Č.'],
|
||||
'description' => ['EN' => 'Description', 'CZ' => 'Popis'],
|
||||
'qty' => ['EN' => 'Qty', 'CZ' => 'Mn.'],
|
||||
'unit_price' => ['EN' => 'Unit Price', 'CZ' => 'Jedn. cena'],
|
||||
'included' => ['EN' => 'Included', 'CZ' => 'Zahrnuto'],
|
||||
'total' => ['EN' => 'Total', 'CZ' => 'Celkem'],
|
||||
'subtotal' => ['EN' => 'Subtotal', 'CZ' => 'Mezisoučet'],
|
||||
'vat' => ['EN' => 'VAT', 'CZ' => 'DPH'],
|
||||
'total_to_pay' => ['EN' => 'Total to pay', 'CZ' => 'Celkem k úhradě'],
|
||||
'exchange_rate' => ['EN' => 'Exchange rate', 'CZ' => 'Směnný kurz'],
|
||||
'ico' => ['EN' => 'ID', 'CZ' => 'IČO'],
|
||||
'dic' => ['EN' => 'VAT ID', 'CZ' => 'DIČ'],
|
||||
'yes' => ['EN' => 'Yes', 'CZ' => 'Ano'],
|
||||
'no_val' => ['EN' => 'No', 'CZ' => 'Ne'],
|
||||
'page' => ['EN' => 'Page', 'CZ' => 'Strana'],
|
||||
'of' => ['EN' => 'of', 'CZ' => 'z'],
|
||||
];
|
||||
|
||||
$lang = $isCzech ? 'CZ' : 'EN';
|
||||
$t = function ($key) use ($translations, $lang) {
|
||||
return $translations[$key][$lang] ?? $key;
|
||||
};
|
||||
|
||||
$formatNum = function ($number, $decimals, $decSep = ',', $thousandSep = "\xC2\xA0") {
|
||||
$fixed = number_format(abs($number), $decimals, '.', '');
|
||||
$parts = explode('.', $fixed);
|
||||
$intPart = preg_replace('/\B(?=(\d{3})+(?!\d))/', $thousandSep, $parts[0]);
|
||||
$result = isset($parts[1]) ? $intPart . $decSep . $parts[1] : $intPart;
|
||||
return $number < 0 ? '-' . $result : $result;
|
||||
};
|
||||
|
||||
$formatCurrency = function ($amount) use ($currency, $formatNum) {
|
||||
$n = floatval($amount);
|
||||
switch ($currency) {
|
||||
case 'EUR':
|
||||
return $formatNum($n, 2, ',', "\xC2\xA0") . ' €';
|
||||
case 'USD':
|
||||
return '$' . $formatNum($n, 2, '.', ',');
|
||||
case 'CZK':
|
||||
return $formatNum($n, 2, ',', "\xC2\xA0") . ' Kč';
|
||||
case 'GBP':
|
||||
return '£' . $formatNum($n, 2, '.', ',');
|
||||
default:
|
||||
return $formatNum($n, 2, ',', "\xC2\xA0") . ' ' . $currency;
|
||||
}
|
||||
};
|
||||
|
||||
$formatDate = function ($dateStr) {
|
||||
if (!$dateStr) {
|
||||
return '';
|
||||
}
|
||||
$d = strtotime($dateStr);
|
||||
if ($d === false) {
|
||||
return $dateStr;
|
||||
}
|
||||
return date('d.m.Y', $d);
|
||||
};
|
||||
|
||||
$esc = function ($str) {
|
||||
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
|
||||
};
|
||||
|
||||
$buildAddressLines = function ($entity, $isSupplier) use ($t) {
|
||||
if (!$entity) {
|
||||
return ['name' => '', 'lines' => []];
|
||||
}
|
||||
|
||||
$nameKey = $isSupplier ? 'company_name' : 'name';
|
||||
$name = $entity[$nameKey] ?? '';
|
||||
|
||||
$cfData = [];
|
||||
$fieldOrder = null;
|
||||
$raw = $entity['custom_fields'] ?? null;
|
||||
if ($raw) {
|
||||
$parsed = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (isset($parsed['fields'])) {
|
||||
$cfData = $parsed['fields'] ?? [];
|
||||
$fieldOrder = $parsed['field_order'] ?? $parsed['fieldOrder'] ?? null;
|
||||
} elseif (is_array($parsed) && isset($parsed[0])) {
|
||||
$cfData = $parsed;
|
||||
}
|
||||
}
|
||||
// Zpetna kompatibilita - stare DB zaznamy maji PascalCase klice
|
||||
if (is_array($fieldOrder)) {
|
||||
$legacyMap = [
|
||||
'Name' => 'name', 'CompanyName' => 'company_name',
|
||||
'Street' => 'street', 'CityPostal' => 'city_postal',
|
||||
'Country' => 'country', 'CompanyId' => 'company_id', 'VatId' => 'vat_id',
|
||||
];
|
||||
$fieldOrder = array_map(fn ($k) => $legacyMap[$k] ?? $k, $fieldOrder);
|
||||
}
|
||||
|
||||
$fieldMap = [];
|
||||
if ($name) {
|
||||
$fieldMap[$nameKey] = $name;
|
||||
}
|
||||
if (!empty($entity['street'])) {
|
||||
$fieldMap['street'] = $entity['street'];
|
||||
}
|
||||
$cityParts = array_filter([$entity['city'] ?? '', $entity['postal_code'] ?? '']);
|
||||
$cityPostal = trim(implode(' ', $cityParts));
|
||||
if ($cityPostal) {
|
||||
$fieldMap['city_postal'] = $cityPostal;
|
||||
}
|
||||
if (!empty($entity['country'])) {
|
||||
$fieldMap['country'] = $entity['country'];
|
||||
}
|
||||
if (!empty($entity['company_id'])) {
|
||||
$fieldMap['company_id'] = $t('ico') . ': ' . $entity['company_id'];
|
||||
}
|
||||
if (!empty($entity['vat_id'])) {
|
||||
$fieldMap['vat_id'] = $t('dic') . ': ' . $entity['vat_id'];
|
||||
}
|
||||
|
||||
foreach ($cfData as $i => $cf) {
|
||||
$cfName = trim($cf['name'] ?? '');
|
||||
$cfValue = trim($cf['value'] ?? '');
|
||||
$showLabel = $cf['showLabel'] ?? true;
|
||||
if ($cfValue) {
|
||||
$fieldMap["custom_{$i}"] = ($showLabel && $cfName) ? "{$cfName}: {$cfValue}" : $cfValue;
|
||||
}
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
if (is_array($fieldOrder) && count($fieldOrder)) {
|
||||
foreach ($fieldOrder as $key) {
|
||||
if ($key === $nameKey) {
|
||||
continue;
|
||||
}
|
||||
if (isset($fieldMap[$key])) {
|
||||
$lines[] = $fieldMap[$key];
|
||||
}
|
||||
}
|
||||
foreach ($fieldMap as $key => $line) {
|
||||
if ($key === $nameKey) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($key, $fieldOrder)) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($fieldMap as $key => $line) {
|
||||
if ($key === $nameKey) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return ['name' => $name, 'lines' => $lines];
|
||||
};
|
||||
|
||||
$logoImg = '';
|
||||
if ($logoBase64) {
|
||||
$logoImg = '<img src="data:' . $esc($logoMime) . ';base64,' . $logoBase64 . '" class="logo" />';
|
||||
}
|
||||
|
||||
$subtotal = 0;
|
||||
foreach ($items as $item) {
|
||||
$included = $item['is_included_in_total'] ?? 1;
|
||||
if ($included) {
|
||||
$subtotal += (floatval($item['quantity']) ?: 0) * (floatval($item['unit_price']) ?: 0);
|
||||
}
|
||||
}
|
||||
|
||||
$applyVat = !empty($quotation['apply_vat']);
|
||||
$vatRate = floatval($quotation['vat_rate'] ?? 21) ?: 21;
|
||||
$vatAmount = $applyVat ? $subtotal * ($vatRate / 100) : 0;
|
||||
$totalToPay = $subtotal + $vatAmount;
|
||||
$exchangeRate = floatval($quotation['exchange_rate'] ?? 0);
|
||||
$vatMode = $applyVat ? 'standard' : 'exempt';
|
||||
|
||||
$hasScopeContent = false;
|
||||
foreach ($sections as $s) {
|
||||
if (trim($s['content'] ?? '') || trim($s['title'] ?? '')) {
|
||||
$hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$sectionTitle = function ($section) use ($isCzech) {
|
||||
if ($isCzech && !empty(trim($section['title_cz'] ?? ''))) {
|
||||
return $section['title_cz'];
|
||||
}
|
||||
return $section['title'] ?? '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge adjacent <span> tags with identical attributes produced by Quill.
|
||||
* Quill sometimes splits a single word across multiple <span> elements
|
||||
* when cursor operations occur mid-word, creating spurious line-break
|
||||
* opportunities in the PDF renderer.
|
||||
*/
|
||||
$cleanQuillHtml = function (string $html): string {
|
||||
// Sanitizace - povolit jen bezpecne HTML tagy z Quill editoru
|
||||
$allowedTags = '<p><br><strong><em><u><s><ul><ol><li>'
|
||||
. '<span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>';
|
||||
$html = strip_tags($html, $allowedTags);
|
||||
|
||||
// Odstranit event handlery (quoted i unquoted hodnoty)
|
||||
$html = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
|
||||
$html = preg_replace('/\s+on\w+\s*=\s*[^\s>]*/i', '', $html);
|
||||
// Odstranit javascript: v href a jinych atributech
|
||||
$html = preg_replace('/href\s*=\s*["\']?\s*javascript\s*:[^"\'>\s]*/i', 'href="#"', $html);
|
||||
$html = preg_replace('/\s+javascript\s*:/i', '', $html);
|
||||
|
||||
// Replace with regular spaces in text content (not inside tags)
|
||||
$html = preg_replace_callback(
|
||||
'/(<[^>]*>)|( )/u',
|
||||
function ($m) {
|
||||
if (!empty($m[1])) {
|
||||
return $m[1];
|
||||
}
|
||||
return ' ';
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
// Merge adjacent spans with the same attributes
|
||||
$prev = null;
|
||||
while ($prev !== $html) {
|
||||
$prev = $html;
|
||||
$html = preg_replace_callback(
|
||||
'/<span([^>]*)>(.*?)<\/span>\s*<span(\1)>/su',
|
||||
fn ($m) => '<span' . $m[1] . '>' . $m[2],
|
||||
$html
|
||||
);
|
||||
}
|
||||
return $html;
|
||||
};
|
||||
|
||||
$cust = $buildAddressLines($customer, false);
|
||||
$supp = $buildAddressLines($settings, true);
|
||||
|
||||
$indentCSS = '';
|
||||
for ($n = 1; $n <= 9; $n++) {
|
||||
$pad = $n * 3;
|
||||
$liPad = $n * 3 + 1.5;
|
||||
$indentCSS .= " .ql-indent-{$n} { padding-left: {$pad}em; }\n";
|
||||
$indentCSS .= " li.ql-indent-{$n} { padding-left: {$liPad}em; }\n";
|
||||
}
|
||||
|
||||
$itemsHtml = '';
|
||||
foreach ($items as $i => $item) {
|
||||
$lineTotal = (floatval($item['quantity']) ?: 0) * (floatval($item['unit_price']) ?: 0);
|
||||
$subDesc = $item['item_description'] ?? '';
|
||||
$rowNum = $i + 1;
|
||||
$evenClass = ($i % 2 === 1) ? ' class="even"' : '';
|
||||
$itemsHtml .= '<tr' . $evenClass . '>
|
||||
<td class="row-num">' . $rowNum . '</td>
|
||||
<td class="desc">' . $esc($item['description'] ?? '')
|
||||
. ($subDesc ? '<div class="item-subdesc">' . $esc($subDesc) . '</div>' : '') . '</td>
|
||||
<td class="center">' . $formatNum(floatval($item['quantity']) ?: 1, 0)
|
||||
. (!empty(trim($item['unit'] ?? '')) ? ' / ' . $esc(trim($item['unit'])) : '') . '</td>
|
||||
<td class="right">' . $formatCurrency($item['unit_price'] ?? 0) . '</td>
|
||||
<td class="right total-cell">' . $formatCurrency($lineTotal) . '</td>
|
||||
</tr>';
|
||||
}
|
||||
|
||||
$totalsHtml = '';
|
||||
if ($vatMode === 'standard') {
|
||||
$totalsHtml .= '<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">' . $esc($t('subtotal')) . ':</span>
|
||||
<span class="value">' . $formatCurrency($subtotal) . '</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">' . $esc($t('vat')) . ' (' . intval($vatRate) . '%):</span>
|
||||
<span class="value">' . $formatCurrency($vatAmount) . '</span>
|
||||
</div>
|
||||
</div>';
|
||||
}
|
||||
$totalsHtml .= '<div class="grand">
|
||||
<span class="label">' . $esc($t('total_to_pay')) . '</span>
|
||||
<span class="value">' . $formatCurrency($totalToPay) . '</span>
|
||||
</div>';
|
||||
if ($exchangeRate > 0) {
|
||||
$totalsHtml .= '<div class="exchange-rate">'
|
||||
. $esc($t('exchange_rate')) . ': ' . $formatNum($exchangeRate, 4) . '</div>';
|
||||
}
|
||||
|
||||
$scopeHtml = '';
|
||||
if ($hasScopeContent) {
|
||||
$scopeHtml .= '<div class="scope-page">';
|
||||
$scopeHtml .= '<div class="page-header">
|
||||
<div class="left">
|
||||
<div class="page-title">' . $esc($t('scope_title')) . '</div>';
|
||||
if (!empty($quotation['scope_title'])) {
|
||||
$scopeHtml .= '<div class="scope-subtitle">' . $esc($quotation['scope_title']) . '</div>';
|
||||
}
|
||||
if (!empty($quotation['scope_description'])) {
|
||||
$scopeHtml .= '<div class="scope-description">' . $esc($quotation['scope_description']) . '</div>';
|
||||
}
|
||||
$scopeHtml .= '</div>';
|
||||
if ($logoImg) {
|
||||
$scopeHtml .= '<div class="right"><div class="logo-header">' . $logoImg . '</div></div>';
|
||||
}
|
||||
$scopeHtml .= '</div>
|
||||
<hr class="separator" />';
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$title = $sectionTitle($section);
|
||||
$content = trim($section['content'] ?? '');
|
||||
if (!$title && !$content) {
|
||||
continue;
|
||||
}
|
||||
$scopeHtml .= '<div class="scope-section">';
|
||||
if ($title) {
|
||||
$scopeHtml .= '<div class="scope-section-title">' . $esc($title) . '</div>';
|
||||
}
|
||||
if ($content) {
|
||||
$scopeHtml .= '<div class="section-content">' . $cleanQuillHtml($content) . '</div>';
|
||||
}
|
||||
$scopeHtml .= '</div>';
|
||||
}
|
||||
$scopeHtml .= '</div>';
|
||||
}
|
||||
|
||||
$custLinesHtml = '';
|
||||
foreach ($cust['lines'] as $line) {
|
||||
$custLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
|
||||
}
|
||||
|
||||
$suppLinesHtml = '';
|
||||
foreach ($supp['lines'] as $line) {
|
||||
$suppLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
|
||||
}
|
||||
|
||||
$pageLabel = $t('page');
|
||||
$ofLabel = $t('of');
|
||||
$quotationNumber = $esc($quotation['quotation_number'] ?? '');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build final HTML
|
||||
// ---------------------------------------------------------------------------
|
||||
$html = '<!DOCTYPE html>
|
||||
<html lang="' . ($isCzech ? 'cs' : 'en') . '">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>' . $quotationNumber . '</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
<style>
|
||||
/* ---- Base ---- */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm 15mm 25mm 15mm;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
color: #1a1a1a;
|
||||
width: 180mm;
|
||||
}
|
||||
|
||||
/* Prevent any element from exceeding content width */
|
||||
img, table, pre, code { max-width: 100%; }
|
||||
|
||||
/* ---- Quill font classes ---- */
|
||||
.ql-font-arial { font-family: Arial, sans-serif; }
|
||||
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
|
||||
.ql-font-verdana { font-family: Verdana, sans-serif; }
|
||||
.ql-font-georgia { font-family: Georgia, serif; }
|
||||
.ql-font-times-new-roman { font-family: "Times New Roman", serif; }
|
||||
.ql-font-courier-new { font-family: "Courier New", monospace; }
|
||||
.ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; }
|
||||
.ql-font-impact { font-family: Impact, sans-serif; }
|
||||
.ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; }
|
||||
.ql-font-lucida-console { font-family: "Lucida Console", monospace; }
|
||||
.ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; }
|
||||
.ql-font-garamond { font-family: Garamond, serif; }
|
||||
|
||||
/* ---- Quill alignment ---- */
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
/* ---- Quill indentation ---- */
|
||||
' . $indentCSS . '
|
||||
|
||||
/* ---- Page header ---- */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
.page-header .left { flex: 1; }
|
||||
.page-header .right { flex-shrink: 0; margin-left: 10mm; }
|
||||
.logo { max-width: 42mm; max-height: 22mm; object-fit: contain; }
|
||||
|
||||
.page-title {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
.scope-page .page-title { font-size: 16pt; }
|
||||
.quotation-number {
|
||||
font-size: 12pt;
|
||||
color: #1a1a1a;
|
||||
margin: 1mm 0;
|
||||
}
|
||||
.project-code {
|
||||
font-size: 10pt;
|
||||
color: #646464;
|
||||
}
|
||||
.valid-until {
|
||||
font-size: 9pt;
|
||||
color: #646464;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
.scope-subtitle {
|
||||
font-size: 11pt;
|
||||
color: #646464;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
.scope-description {
|
||||
font-size: 9pt;
|
||||
color: #646464;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: none;
|
||||
border-top: 0.5pt solid #e0e0e0;
|
||||
margin: 3mm 0 5mm 0;
|
||||
}
|
||||
|
||||
/* ---- Addresses ---- */
|
||||
.addresses {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8mm;
|
||||
}
|
||||
.address-block { width: 48%; }
|
||||
.address-block.right { text-align: right; }
|
||||
.address-label {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
color: #646464;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.address-name {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.address-line {
|
||||
font-size: 9pt;
|
||||
color: #646464;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- Items table ---- */
|
||||
table.items {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
table.items thead th {
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: #646464;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1pt solid #1a1a1a;
|
||||
}
|
||||
table.items thead th.center { text-align: center; }
|
||||
table.items thead th.right { text-align: right; }
|
||||
|
||||
table.items tbody td {
|
||||
padding: 7px 8px;
|
||||
border-bottom: 0.5pt solid #e0e0e0;
|
||||
vertical-align: middle;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
|
||||
table.items tbody td.center { text-align: center; white-space: nowrap; }
|
||||
table.items tbody td.right { text-align: right; }
|
||||
table.items tbody td.row-num {
|
||||
text-align: center;
|
||||
color: #969696;
|
||||
font-size: 8pt;
|
||||
}
|
||||
table.items tbody td.desc {
|
||||
font-size: 10pt;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
table.items tbody td.total-cell {
|
||||
font-weight: 700;
|
||||
}
|
||||
.item-subdesc {
|
||||
font-size: 9pt;
|
||||
color: #646464;
|
||||
margin-top: 2px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---- Totals ---- */
|
||||
.totals-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
break-inside: avoid;
|
||||
margin-top: 8mm;
|
||||
}
|
||||
.totals {
|
||||
width: 80mm;
|
||||
}
|
||||
.totals .detail-rows {
|
||||
margin-bottom: 3mm;
|
||||
}
|
||||
.totals .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 8.5pt;
|
||||
color: #646464;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
.totals .row:last-child { margin-bottom: 0; }
|
||||
.totals .row .value {
|
||||
color: #1a1a1a;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
.totals .grand {
|
||||
border-top: 0.5pt solid #e0e0e0;
|
||||
padding-top: 4mm;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.totals .grand .label {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 400;
|
||||
color: #646464;
|
||||
align-self: center;
|
||||
}
|
||||
.totals .grand .value {
|
||||
font-size: 14pt;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 2.5pt solid #de3a3a;
|
||||
padding-bottom: 1mm;
|
||||
}
|
||||
.totals .exchange-rate {
|
||||
text-align: right;
|
||||
font-size: 7.5pt;
|
||||
color: #969696;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
/* ---- Scope sections ---- */
|
||||
.scope-page {
|
||||
page-break-before: always;
|
||||
}
|
||||
.scope-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 3mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.scope-section-title {
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
.section-content {
|
||||
font-size: 9pt;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.section-content p { margin: 0 0 0.4em 0; }
|
||||
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* ---- Repeating page header ---- */
|
||||
table.page-layout {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.page-layout > thead > tr > td,
|
||||
table.page-layout > tbody > tr > td {
|
||||
padding: 0;
|
||||
border: none;
|
||||
vertical-align: top;
|
||||
}
|
||||
.logo-header {
|
||||
text-align: right;
|
||||
padding-bottom: 4mm;
|
||||
}
|
||||
.first-content {
|
||||
margin-top: -26mm;
|
||||
}
|
||||
|
||||
/* ---- Page break helpers ---- */
|
||||
table.page-layout thead { display: table-header-group; }
|
||||
table.items tbody tr { break-inside: avoid; }
|
||||
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
@page {
|
||||
@bottom-center {
|
||||
content: "' . $esc($pageLabel) . ' " counter(page) " ' . $esc($ofLabel) . ' " counter(pages);
|
||||
font-size: 8pt;
|
||||
color: #969696;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Screen-only: A4 page preview ---- */
|
||||
@media screen {
|
||||
html {
|
||||
background: #525659;
|
||||
}
|
||||
body {
|
||||
width: 100vw !important;
|
||||
margin: 0;
|
||||
padding: 30px 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.quotation-page, .scope-page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 15mm;
|
||||
background: white;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* On screen: neutralize the table layout used for print repeating headers (quotation page only) */
|
||||
table.page-layout,
|
||||
table.page-layout > thead,
|
||||
table.page-layout > thead > tr,
|
||||
table.page-layout > thead > tr > td,
|
||||
table.page-layout > tbody,
|
||||
table.page-layout > tbody > tr,
|
||||
table.page-layout > tbody > tr > td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
/* On screen: undo print-specific hacks */
|
||||
.first-content {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
/* On screen: show logo-header normally as right-aligned block */
|
||||
.logo-header {
|
||||
text-align: right;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: -18mm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
|
||||
<div class="quotation-page">
|
||||
<table class="page-layout">
|
||||
<thead>
|
||||
<tr><td>
|
||||
<div class="logo-header">' . $logoImg . '</div>
|
||||
</td></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>
|
||||
<div class="first-content">
|
||||
<div class="page-header">
|
||||
<div class="left">
|
||||
<div class="page-title">' . $esc($t('title')) . '</div>
|
||||
<div class="quotation-number">' . $quotationNumber . '</div>
|
||||
' . (!empty($quotation['project_code'])
|
||||
? '<div class="project-code">' . $esc($quotation['project_code']) . '</div>'
|
||||
: '') . '
|
||||
<div class="valid-until">' . $esc($t('valid_until')) . ': '
|
||||
. $esc($formatDate($quotation['valid_until'] ?? '')) . '</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="separator" />
|
||||
|
||||
<div class="addresses">
|
||||
<div class="address-block left">
|
||||
<div class="address-label">' . $esc($t('customer')) . '</div>
|
||||
<div class="address-name">' . $esc($cust['name']) . '</div>
|
||||
' . $custLinesHtml . '
|
||||
</div>
|
||||
<div class="address-block right">
|
||||
<div class="address-label">' . $esc($t('supplier')) . '</div>
|
||||
<div class="address-name">' . $esc($supp['name']) . '</div>
|
||||
' . $suppLinesHtml . '
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:5%">' . $esc($t('no')) . '</th>
|
||||
<th style="width:44%">' . $esc($t('description')) . '</th>
|
||||
<th class="center" style="width:13%">' . $esc($t('qty')) . '</th>
|
||||
<th class="right" style="width:18%">' . $esc($t('unit_price')) . '</th>
|
||||
<th class="right" style="width:20%">' . $esc($t('total')) . '</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
' . $itemsHtml . '
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="totals-wrapper">
|
||||
<div class="totals">
|
||||
' . $totalsHtml . '
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
' . $scopeHtml . '
|
||||
|
||||
</body>
|
||||
</html>';
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo $html;
|
||||
exit();
|
||||
} catch (PDOException $e) {
|
||||
error_log('Offers PDF API error: ' . $e->getMessage());
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba generování PDF', 500);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('Offers PDF generation error: ' . $e->getMessage());
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba PDF: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba generování PDF', 500);
|
||||
}
|
||||
}
|
||||
360
api/admin/offers-templates.php
Normal file
360
api/admin/offers-templates.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Offers Templates API
|
||||
*
|
||||
* GET ?action=items - List item templates
|
||||
* GET ?action=scopes - List scope templates
|
||||
* GET ?action=scope_detail&id=X - Get scope template with sections
|
||||
* POST ?action=item - Create/update item template
|
||||
* POST ?action=scope - Create/update scope template
|
||||
* DELETE ?action=item&id=X - Delete item template
|
||||
* DELETE ?action=scope&id=X - Delete scope template
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'offers.view');
|
||||
switch ($action) {
|
||||
case 'items':
|
||||
handleGetItemTemplates($pdo);
|
||||
break;
|
||||
case 'scopes':
|
||||
handleGetScopeTemplates($pdo);
|
||||
break;
|
||||
case 'scope_detail':
|
||||
if (!$id) {
|
||||
errorResponse('ID šablony je povinné');
|
||||
}
|
||||
handleGetScopeDetail($pdo, $id);
|
||||
break;
|
||||
default:
|
||||
errorResponse('Neplatná akce');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'offers.settings');
|
||||
switch ($action) {
|
||||
case 'item':
|
||||
handleSaveItemTemplate($pdo);
|
||||
break;
|
||||
case 'scope':
|
||||
handleSaveScopeTemplate($pdo);
|
||||
break;
|
||||
default:
|
||||
errorResponse('Neplatná akce');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'offers.settings');
|
||||
if (!$id) {
|
||||
errorResponse('ID šablony je povinné');
|
||||
}
|
||||
switch ($action) {
|
||||
case 'item':
|
||||
handleDeleteItemTemplate($pdo, $id);
|
||||
break;
|
||||
case 'scope':
|
||||
handleDeleteScopeTemplate($pdo, $id);
|
||||
break;
|
||||
default:
|
||||
errorResponse('Neplatná akce');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Offers Templates API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Item Templates ---
|
||||
|
||||
function handleGetItemTemplates(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('SELECT * FROM item_templates ORDER BY category, name');
|
||||
successResponse(['templates' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
function handleSaveItemTemplate(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
if (empty($input['name'])) {
|
||||
errorResponse('Název šablony je povinný');
|
||||
}
|
||||
|
||||
$id = isset($input['id']) ? (int)$input['id'] : null;
|
||||
|
||||
if ($id) {
|
||||
// Update
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE item_templates SET
|
||||
name = ?, description = ?, default_price = ?, category = ?,
|
||||
modified_at = NOW(), sync_version = sync_version + 1
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([
|
||||
$input['name'],
|
||||
$input['description'] ?? '',
|
||||
$input['default_price'] ?? 0,
|
||||
$input['category'] ?? '',
|
||||
$id,
|
||||
]);
|
||||
successResponse(null, 'Šablona byla aktualizována');
|
||||
} else {
|
||||
// Create
|
||||
$uuid = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0x0fff) | 0x4000,
|
||||
random_int(0, 0x3fff) | 0x8000,
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff)
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO item_templates (name, description, default_price, category, uuid, modified_at, sync_version)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), 1)
|
||||
');
|
||||
$stmt->execute([
|
||||
$input['name'],
|
||||
$input['description'] ?? '',
|
||||
$input['default_price'] ?? 0,
|
||||
$input['category'] ?? '',
|
||||
$uuid,
|
||||
]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
AuditLog::logCreate(
|
||||
'offers_item_template',
|
||||
(int)$newId,
|
||||
['name' => $input['name']],
|
||||
"Vytvořena šablona položky '{$input['name']}'"
|
||||
);
|
||||
|
||||
successResponse(['id' => $newId], 'Šablona byla vytvořena');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteItemTemplate(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT name FROM item_templates WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$template = $stmt->fetch();
|
||||
|
||||
if (!$template) {
|
||||
errorResponse('Šablona nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM item_templates WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
|
||||
AuditLog::logDelete(
|
||||
'offers_item_template',
|
||||
$id,
|
||||
['name' => $template['name']],
|
||||
"Smazána šablona položky '{$template['name']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Šablona byla smazána');
|
||||
}
|
||||
|
||||
// --- Scope Templates ---
|
||||
|
||||
function handleGetScopeTemplates(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('SELECT * FROM scope_templates ORDER BY name');
|
||||
successResponse(['templates' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
function handleGetScopeDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM scope_templates WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$template = $stmt->fetch();
|
||||
|
||||
if (!$template) {
|
||||
errorResponse('Šablona nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM scope_template_sections WHERE scope_template_id = ? ORDER BY position');
|
||||
$stmt->execute([$id]);
|
||||
$template['sections'] = $stmt->fetchAll();
|
||||
|
||||
successResponse($template);
|
||||
}
|
||||
|
||||
function handleSaveScopeTemplate(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
if (empty($input['name'])) {
|
||||
errorResponse('Název šablony je povinný');
|
||||
}
|
||||
|
||||
$id = isset($input['id']) ? (int)$input['id'] : null;
|
||||
$sections = $input['sections'] ?? [];
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($id) {
|
||||
// Update template
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE scope_templates SET
|
||||
name = ?,
|
||||
title = ?,
|
||||
description = ?,
|
||||
modified_at = NOW(),
|
||||
sync_version = sync_version + 1
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([
|
||||
$input['name'],
|
||||
$input['title'] ?? '',
|
||||
$input['description'] ?? '',
|
||||
$id,
|
||||
]);
|
||||
} else {
|
||||
// Create template
|
||||
$uuid = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0x0fff) | 0x4000,
|
||||
random_int(0, 0x3fff) | 0x8000,
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff)
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO scope_templates (name, title, description, uuid, modified_at, sync_version)
|
||||
VALUES (?, ?, ?, ?, NOW(), 1)
|
||||
');
|
||||
$stmt->execute([
|
||||
$input['name'],
|
||||
$input['title'] ?? '',
|
||||
$input['description'] ?? '',
|
||||
$uuid,
|
||||
]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// Delete existing sections and re-insert
|
||||
$stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO scope_template_sections
|
||||
(scope_template_id, title, title_cz, content, position, uuid, modified_at, sync_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), 1)
|
||||
');
|
||||
|
||||
foreach ($sections as $i => $section) {
|
||||
$sectionUuid = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0x0fff) | 0x4000,
|
||||
random_int(0, 0x3fff) | 0x8000,
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff),
|
||||
random_int(0, 0xffff)
|
||||
);
|
||||
$stmt->execute([
|
||||
$id,
|
||||
$section['title'] ?? '',
|
||||
$section['title_cz'] ?? '',
|
||||
$section['content'] ?? '',
|
||||
$i + 1,
|
||||
$sectionUuid,
|
||||
]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logCreate(
|
||||
'offers_scope_template',
|
||||
$id,
|
||||
['name' => $input['name']],
|
||||
"Uložena šablona rozsahu '{$input['name']}'"
|
||||
);
|
||||
|
||||
successResponse(['id' => $id], 'Šablona rozsahu byla uložena');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteScopeTemplate(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT name FROM scope_templates WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$template = $stmt->fetch();
|
||||
|
||||
if (!$template) {
|
||||
errorResponse('Šablona nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Delete sections
|
||||
$stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// Delete template
|
||||
$stmt = $pdo->prepare('DELETE FROM scope_templates WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logDelete(
|
||||
'offers_scope_template',
|
||||
$id,
|
||||
['name' => $template['name']],
|
||||
"Smazána šablona rozsahu '{$template['name']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Šablona rozsahu byla smazána');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
679
api/admin/offers.php
Normal file
679
api/admin/offers.php
Normal file
@@ -0,0 +1,679 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Quotations CRUD API
|
||||
*
|
||||
* GET /api/admin/offers.php - List quotations
|
||||
* GET /api/admin/offers.php?action=detail&id=X - Get quotation detail
|
||||
* GET /api/admin/offers.php?action=next_number - Get next quotation number
|
||||
* POST /api/admin/offers.php - Create quotation
|
||||
* POST /api/admin/offers.php?action=duplicate&id=X - Duplicate quotation
|
||||
* POST /api/admin/offers.php?action=invalidate&id=X - Invalidate quotation
|
||||
* PUT /api/admin/offers.php?id=X - Update quotation
|
||||
* DELETE /api/admin/offers.php?id=X - Delete quotation
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'offers.view');
|
||||
switch ($action) {
|
||||
case 'detail':
|
||||
if (!$id) {
|
||||
errorResponse('ID nabídky je povinné');
|
||||
}
|
||||
handleGetDetail($pdo, $id);
|
||||
break;
|
||||
case 'next_number':
|
||||
handleGetNextNumber($pdo);
|
||||
break;
|
||||
default:
|
||||
handleGetList($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'invalidate' && $id) {
|
||||
requirePermission($authData, 'offers.edit');
|
||||
handleInvalidateOffer($pdo, $id);
|
||||
} elseif ($action === 'duplicate' && $id) {
|
||||
requirePermission($authData, 'offers.create');
|
||||
handleDuplicate($pdo, $id);
|
||||
} else {
|
||||
requirePermission($authData, 'offers.create');
|
||||
handleCreateOffer($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'offers.edit');
|
||||
if (!$id) {
|
||||
errorResponse('ID nabídky je povinné');
|
||||
}
|
||||
handleUpdateOffer($pdo, $id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'offers.delete');
|
||||
if (!$id) {
|
||||
errorResponse('ID nabídky je povinné');
|
||||
}
|
||||
handleDeleteQuotation($pdo, $id);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Offers API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGetList(PDO $pdo): void
|
||||
{
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$sort = $_GET['sort'] ?? 'created_at';
|
||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||
|
||||
$sortMap = [
|
||||
'Date' => 'q.created_at',
|
||||
'CreatedAt' => 'q.created_at',
|
||||
'created_at' => 'q.created_at',
|
||||
'QuotationNumber' => 'q.quotation_number',
|
||||
'quotation_number' => 'q.quotation_number',
|
||||
'ProjectCode' => 'q.project_code',
|
||||
'project_code' => 'q.project_code',
|
||||
'ValidUntil' => 'q.valid_until',
|
||||
'valid_until' => 'q.valid_until',
|
||||
'Currency' => 'q.currency',
|
||||
'currency' => 'q.currency',
|
||||
];
|
||||
if (!isset($sortMap[$sort])) {
|
||||
errorResponse('Neplatný parametr řazení', 400);
|
||||
}
|
||||
$sortCol = $sortMap[$sort];
|
||||
|
||||
$where = 'WHERE 1=1';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
$where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
|
||||
$searchParam = "%{$search}%";
|
||||
$params = [$searchParam, $searchParam, $searchParam];
|
||||
}
|
||||
|
||||
// Celkovy pocet pro pagination
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until,
|
||||
q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate,
|
||||
q.customer_id, q.order_id, q.status,
|
||||
c.name as customer_name,
|
||||
(SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0)
|
||||
FROM quotation_items qi WHERE qi.quotation_id = q.id) as total
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$quotations = $stmt->fetchAll();
|
||||
|
||||
successResponse([
|
||||
'quotations' => $quotations,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT q.*, c.name as customer_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = ?
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$quotation = $stmt->fetch();
|
||||
|
||||
if (!$quotation) {
|
||||
errorResponse('Nabídka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Get items
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT * FROM quotation_items
|
||||
WHERE quotation_id = ?
|
||||
ORDER BY position
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$quotation['items'] = $stmt->fetchAll();
|
||||
|
||||
// Get scope sections
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT * FROM scope_sections
|
||||
WHERE quotation_id = ?
|
||||
ORDER BY position
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$quotation['sections'] = $stmt->fetchAll();
|
||||
|
||||
// Get customer
|
||||
if ($quotation['customer_id']) {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields
|
||||
FROM customers WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$quotation['customer_id']]);
|
||||
$quotation['customer'] = $stmt->fetch();
|
||||
}
|
||||
|
||||
// Get linked order info
|
||||
if ($quotation['order_id']) {
|
||||
$stmt = $pdo->prepare('SELECT id, order_number, status FROM orders WHERE id = ?');
|
||||
$stmt->execute([$quotation['order_id']]);
|
||||
$quotation['order'] = $stmt->fetch() ?: null;
|
||||
} else {
|
||||
$quotation['order'] = null;
|
||||
}
|
||||
|
||||
successResponse($quotation);
|
||||
}
|
||||
|
||||
function handleGetNextNumber(PDO $pdo): void
|
||||
{
|
||||
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
|
||||
if (!$settings) {
|
||||
errorResponse('Nastavení firmy nenalezeno');
|
||||
}
|
||||
|
||||
$year = date('Y');
|
||||
$prefix = $settings['quotation_prefix'] ?: 'N';
|
||||
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
|
||||
|
||||
$formatted = sprintf('%s/%s/%03d', $year, $prefix, $number);
|
||||
|
||||
successResponse([
|
||||
'number' => $formatted,
|
||||
'raw_number' => $number,
|
||||
'prefix' => $prefix,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
function getMaxQuotationNumber(PDO $pdo, string $year, string $prefix): int
|
||||
{
|
||||
$likePattern = "{$year}/{$prefix}/%";
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0)
|
||||
FROM quotations
|
||||
WHERE quotation_number LIKE ?
|
||||
");
|
||||
$stmt->execute([$likePattern]);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function generateNextNumber(PDO $pdo): string
|
||||
{
|
||||
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
|
||||
|
||||
$year = date('Y');
|
||||
$prefix = $settings['quotation_prefix'] ?: 'N';
|
||||
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
|
||||
|
||||
return sprintf('%s/%s/%03d', $year, $prefix, $number);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $q */
|
||||
function validateQuotationInput(array $q): void
|
||||
{
|
||||
if (empty($q['customer_id'])) {
|
||||
errorResponse('Vyberte zákazníka');
|
||||
}
|
||||
if (empty($q['created_at'])) {
|
||||
errorResponse('Zadejte datum vytvoření');
|
||||
}
|
||||
if (empty($q['valid_until'])) {
|
||||
errorResponse('Zadejte datum platnosti');
|
||||
}
|
||||
if (!empty($q['created_at']) && !empty($q['valid_until']) && $q['valid_until'] < $q['created_at']) {
|
||||
errorResponse('Datum platnosti nesmí být před datem vytvoření');
|
||||
}
|
||||
if (empty($q['currency'])) {
|
||||
errorResponse('Vyberte měnu');
|
||||
}
|
||||
|
||||
// Validace formatu dat
|
||||
foreach (['created_at', 'valid_until'] as $dateField) {
|
||||
if (!empty($q[$dateField]) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $q[$dateField])) {
|
||||
errorResponse("Neplatný formát data: $dateField");
|
||||
}
|
||||
}
|
||||
// Validace meny a jazyka
|
||||
if (!in_array($q['currency'] ?? '', ['EUR', 'USD', 'CZK', 'GBP'])) {
|
||||
errorResponse('Neplatná měna');
|
||||
}
|
||||
if (!empty($q['language']) && !in_array($q['language'], ['EN', 'CZ'])) {
|
||||
errorResponse('Neplatný jazyk');
|
||||
}
|
||||
// Validace DPH
|
||||
if (isset($q['vat_rate'])) {
|
||||
$rate = floatval($q['vat_rate']);
|
||||
if ($rate < 0 || $rate > 100) {
|
||||
errorResponse('Sazba DPH musí být mezi 0 a 100');
|
||||
}
|
||||
}
|
||||
// Delkove limity
|
||||
if (!empty($q['project_code']) && mb_strlen($q['project_code']) > 100) {
|
||||
errorResponse('Kód projektu je příliš dlouhý (max 100 znaků)');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateOffer(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$quotation = $input['quotation'] ?? $input;
|
||||
$items = $input['items'] ?? [];
|
||||
$sections = $input['sections'] ?? [];
|
||||
|
||||
validateQuotationInput($quotation);
|
||||
|
||||
// Serialize number generation across concurrent requests
|
||||
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
|
||||
if (!$locked) {
|
||||
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$quotationNumber = generateNextNumber($pdo);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO quotations (
|
||||
quotation_number, project_code, customer_id, created_at, valid_until,
|
||||
currency, language, vat_rate, apply_vat, exchange_rate,
|
||||
scope_title, scope_description, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$quotationNumber,
|
||||
$quotation['project_code'] ?? '',
|
||||
$quotation['customer_id'] ? (int)$quotation['customer_id'] : null,
|
||||
$quotation['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
$quotation['valid_until'] ?? date('Y-m-d H:i:s', strtotime('+30 days')),
|
||||
$quotation['currency'] ?? 'EUR',
|
||||
$quotation['language'] ?? 'EN',
|
||||
$quotation['vat_rate'] ?? 21,
|
||||
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : 0,
|
||||
$quotation['exchange_rate'] ?? null,
|
||||
$quotation['scope_title'] ?? '',
|
||||
$quotation['scope_description'] ?? '',
|
||||
]);
|
||||
|
||||
$quotationId = (int)$pdo->lastInsertId();
|
||||
|
||||
saveItems($pdo, $quotationId, $items);
|
||||
saveSections($pdo, $quotationId, $sections);
|
||||
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||
|
||||
AuditLog::logCreate('offers_quotation', $quotationId, [
|
||||
'quotation_number' => $quotationNumber,
|
||||
'project_code' => $quotation['project_code'] ?? '',
|
||||
], "Vytvořena nabídka '$quotationNumber'");
|
||||
|
||||
successResponse([
|
||||
'id' => $quotationId,
|
||||
'number' => $quotationNumber,
|
||||
], 'Nabídka byla vytvořena');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateOffer(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
if (!$existing) {
|
||||
errorResponse('Nabídka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
if ($existing['status'] === 'invalidated') {
|
||||
errorResponse('Zneplatněnou nabídku nelze upravovat', 403);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
$quotation = $input['quotation'] ?? $input;
|
||||
$items = $input['items'] ?? [];
|
||||
$sections = $input['sections'] ?? [];
|
||||
|
||||
validateQuotationInput($quotation);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE quotations SET
|
||||
project_code = ?,
|
||||
customer_id = ?,
|
||||
created_at = ?,
|
||||
valid_until = ?,
|
||||
currency = ?,
|
||||
language = ?,
|
||||
vat_rate = ?,
|
||||
apply_vat = ?,
|
||||
exchange_rate = ?,
|
||||
scope_title = ?,
|
||||
scope_description = ?,
|
||||
modified_at = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$quotation['project_code'] ?? $existing['project_code'],
|
||||
isset($quotation['customer_id'])
|
||||
? ($quotation['customer_id'] ? (int)$quotation['customer_id'] : null)
|
||||
: $existing['customer_id'],
|
||||
$quotation['created_at'] ?? $existing['created_at'],
|
||||
$quotation['valid_until'] ?? $existing['valid_until'],
|
||||
$quotation['currency'] ?? $existing['currency'],
|
||||
$quotation['language'] ?? $existing['language'],
|
||||
$quotation['vat_rate'] ?? $existing['vat_rate'],
|
||||
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : $existing['apply_vat'],
|
||||
array_key_exists('exchange_rate', $quotation) ? $quotation['exchange_rate'] : $existing['exchange_rate'],
|
||||
$quotation['scope_title'] ?? $existing['scope_title'],
|
||||
$quotation['scope_description'] ?? $existing['scope_description'],
|
||||
$id,
|
||||
]);
|
||||
|
||||
// Replace items
|
||||
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
saveItems($pdo, $id, $items);
|
||||
|
||||
// Replace sections
|
||||
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
saveSections($pdo, $id, $sections);
|
||||
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'offers_quotation',
|
||||
$id,
|
||||
['quotation_number' => $existing['quotation_number']],
|
||||
['project_code' => $quotation['project_code'] ?? $existing['project_code']],
|
||||
"Upravena nabídka '{$existing['quotation_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Nabídka byla aktualizována');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDuplicate(PDO $pdo, int $sourceId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||
$stmt->execute([$sourceId]);
|
||||
$source = $stmt->fetch();
|
||||
|
||||
if (!$source) {
|
||||
errorResponse('Zdrojová nabídka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
||||
$stmt->execute([$sourceId]);
|
||||
$sourceItems = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
||||
$stmt->execute([$sourceId]);
|
||||
$sourceSections = $stmt->fetchAll();
|
||||
|
||||
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
|
||||
if (!$locked) {
|
||||
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$newNumber = generateNextNumber($pdo);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO quotations (
|
||||
quotation_number, project_code, customer_id, created_at, valid_until,
|
||||
currency, language, vat_rate, apply_vat, exchange_rate,
|
||||
scope_title, scope_description, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$newNumber,
|
||||
$source['project_code'],
|
||||
$source['customer_id'],
|
||||
date('Y-m-d H:i:s'),
|
||||
date('Y-m-d H:i:s', strtotime('+30 days')),
|
||||
$source['currency'],
|
||||
$source['language'],
|
||||
$source['vat_rate'],
|
||||
$source['apply_vat'],
|
||||
$source['exchange_rate'],
|
||||
$source['scope_title'],
|
||||
$source['scope_description'],
|
||||
]);
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
$items = array_map(function ($item) {
|
||||
return [
|
||||
'description' => $item['description'],
|
||||
'item_description' => $item['item_description'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'is_included_in_total' => $item['is_included_in_total'],
|
||||
'position' => $item['position'],
|
||||
];
|
||||
}, $sourceItems);
|
||||
saveItems($pdo, $newId, $items);
|
||||
|
||||
$sections = array_map(function ($section) {
|
||||
return [
|
||||
'title' => $section['title'],
|
||||
'title_cz' => $section['title_cz'],
|
||||
'content' => $section['content'],
|
||||
'position' => $section['position'],
|
||||
];
|
||||
}, $sourceSections);
|
||||
saveSections($pdo, $newId, $sections);
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||
|
||||
AuditLog::logCreate('offers_quotation', $newId, [
|
||||
'quotation_number' => $newNumber,
|
||||
'duplicated_from' => $source['quotation_number'],
|
||||
], "Duplikována nabídka '{$source['quotation_number']}' jako '$newNumber'");
|
||||
|
||||
successResponse([
|
||||
'id' => $newId,
|
||||
'number' => $newNumber,
|
||||
], 'Nabídka byla duplikována');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInvalidateOffer(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT quotation_number, status, order_id FROM quotations WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$quotation = $stmt->fetch();
|
||||
|
||||
if (!$quotation) {
|
||||
errorResponse('Nabídka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
if ($quotation['status'] === 'invalidated') {
|
||||
errorResponse('Nabídka je již zneplatněna', 400);
|
||||
}
|
||||
|
||||
if ($quotation['order_id']) {
|
||||
errorResponse('Nabídku s objednávkou nelze zneplatnit', 400);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE quotations SET status = ?, modified_at = NOW() WHERE id = ?');
|
||||
$stmt->execute(['invalidated', $id]);
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'offers_quotation',
|
||||
$id,
|
||||
['status' => 'active'],
|
||||
['status' => 'invalidated'],
|
||||
"Zneplatněna nabídka '{$quotation['quotation_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Nabídka byla zneplatněna');
|
||||
}
|
||||
|
||||
function handleDeleteQuotation(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT quotation_number FROM quotations WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$quotation = $stmt->fetch();
|
||||
|
||||
|
||||
if (!$quotation) {
|
||||
errorResponse('Nabídka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM quotations WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logDelete('offers_quotation', $id, [
|
||||
'quotation_number' => $quotation['quotation_number'],
|
||||
], "Smazána nabídka '{$quotation['quotation_number']}'");
|
||||
|
||||
successResponse(null, 'Nabídka byla smazána');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** @param list<array<string, mixed>> $items */
|
||||
function saveItems(PDO $pdo, int $quotationId, array $items): void
|
||||
{
|
||||
if (empty($items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO quotation_items (
|
||||
quotation_id, description, item_description, quantity, unit,
|
||||
unit_price, is_included_in_total, position, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
');
|
||||
|
||||
foreach ($items as $i => $item) {
|
||||
$stmt->execute([
|
||||
$quotationId,
|
||||
$item['description'] ?? '',
|
||||
$item['item_description'] ?? '',
|
||||
$item['quantity'] ?? 1,
|
||||
$item['unit'] ?? '',
|
||||
$item['unit_price'] ?? 0,
|
||||
isset($item['is_included_in_total']) ? ($item['is_included_in_total'] ? 1 : 0) : 1,
|
||||
$item['position'] ?? ($i + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $sections */
|
||||
function saveSections(PDO $pdo, int $quotationId, array $sections): void
|
||||
{
|
||||
if (empty($sections)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO scope_sections (
|
||||
quotation_id, title, title_cz, content, position, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, NOW())
|
||||
');
|
||||
|
||||
foreach ($sections as $i => $section) {
|
||||
$stmt->execute([
|
||||
$quotationId,
|
||||
$section['title'] ?? '',
|
||||
$section['title_cz'] ?? '',
|
||||
$section['content'] ?? '',
|
||||
$section['position'] ?? ($i + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
615
api/admin/orders.php
Normal file
615
api/admin/orders.php
Normal file
@@ -0,0 +1,615 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Orders CRUD API
|
||||
*
|
||||
* GET /api/admin/orders.php - List orders
|
||||
* GET /api/admin/orders.php?action=detail&id=X - Get order detail
|
||||
* POST /api/admin/orders.php - Create order from quotation
|
||||
* PUT /api/admin/orders.php?id=X - Update order status/notes
|
||||
* DELETE /api/admin/orders.php?id=X - Delete order + project
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'orders.view');
|
||||
switch ($action) {
|
||||
case 'detail':
|
||||
if (!$id) {
|
||||
errorResponse('ID objednávky je povinné');
|
||||
}
|
||||
handleGetDetail($pdo, $id);
|
||||
break;
|
||||
case 'attachment':
|
||||
if (!$id) {
|
||||
errorResponse('ID objednávky je povinné');
|
||||
}
|
||||
handleGetAttachment($pdo, $id);
|
||||
break;
|
||||
default:
|
||||
handleGetList($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'orders.create');
|
||||
handleCreateOrder($pdo);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'orders.edit');
|
||||
if (!$id) {
|
||||
errorResponse('ID objednávky je povinné');
|
||||
}
|
||||
handleUpdateOrder($pdo, $id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'orders.delete');
|
||||
if (!$id) {
|
||||
errorResponse('ID objednávky je povinné');
|
||||
}
|
||||
handleDeleteOrder($pdo, $id);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Orders API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Valid status transitions ---
|
||||
|
||||
/** @return list<string> */
|
||||
function getValidTransitions(string $currentStatus): array
|
||||
{
|
||||
$map = [
|
||||
'prijata' => ['v_realizaci', 'stornovana'],
|
||||
'v_realizaci' => ['dokoncena', 'stornovana'],
|
||||
'dokoncena' => [],
|
||||
'stornovana' => [],
|
||||
];
|
||||
return $map[$currentStatus] ?? [];
|
||||
}
|
||||
|
||||
// --- Number generation ---
|
||||
|
||||
function generateOrderNumber(PDO $pdo): string
|
||||
{
|
||||
return generateSharedNumber($pdo);
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function handleGetList(PDO $pdo): void
|
||||
{
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$sort = $_GET['sort'] ?? 'created_at';
|
||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||
|
||||
$sortMap = [
|
||||
'OrderNumber' => 'o.order_number',
|
||||
'order_number' => 'o.order_number',
|
||||
'CreatedAt' => 'o.created_at',
|
||||
'created_at' => 'o.created_at',
|
||||
'Status' => 'o.status',
|
||||
'status' => 'o.status',
|
||||
'Currency' => 'o.currency',
|
||||
'currency' => 'o.currency',
|
||||
];
|
||||
if (!isset($sortMap[$sort])) {
|
||||
errorResponse('Neplatný parametr řazení', 400);
|
||||
}
|
||||
$sortCol = $sortMap[$sort];
|
||||
|
||||
$where = 'WHERE 1=1';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
$where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
|
||||
$searchParam = "%{$search}%";
|
||||
$params = [$searchParam, $searchParam, $searchParam, $searchParam];
|
||||
}
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM orders o
|
||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency,
|
||||
o.created_at, o.apply_vat, o.vat_rate,
|
||||
q.quotation_number, q.project_code,
|
||||
c.name as customer_name,
|
||||
(SELECT COALESCE(SUM(CASE WHEN oi.is_included_in_total THEN oi.quantity * oi.unit_price ELSE 0 END), 0)
|
||||
FROM order_items oi WHERE oi.order_id = o.id) as total,
|
||||
(SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id,
|
||||
(SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number
|
||||
FROM orders o
|
||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$orders = $stmt->fetchAll();
|
||||
|
||||
successResponse([
|
||||
'orders' => $orders,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
// BLOB vynechany - stahuje se pres action=attachment
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT o.id, o.order_number, o.customer_order_number, o.attachment_name,
|
||||
o.quotation_id, o.customer_id, o.status, o.currency, o.language,
|
||||
o.vat_rate, o.apply_vat, o.exchange_rate, o.scope_title, o.scope_description,
|
||||
o.notes, o.created_at, o.modified_at,
|
||||
q.quotation_number, q.project_code,
|
||||
c.name as customer_name
|
||||
FROM orders o
|
||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
WHERE o.id = ?
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$order = $stmt->fetch();
|
||||
|
||||
if (!$order) {
|
||||
errorResponse('Objednávka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Get items
|
||||
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position');
|
||||
$stmt->execute([$id]);
|
||||
$order['items'] = $stmt->fetchAll();
|
||||
|
||||
// Get sections
|
||||
$stmt = $pdo->prepare('SELECT * FROM order_sections WHERE order_id = ? ORDER BY position');
|
||||
$stmt->execute([$id]);
|
||||
$order['sections'] = $stmt->fetchAll();
|
||||
|
||||
// Get customer
|
||||
if ($order['customer_id']) {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, name, company_id, vat_id, street, city,
|
||||
postal_code, country, custom_fields
|
||||
FROM customers WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$order['customer_id']]);
|
||||
$order['customer'] = $stmt->fetch();
|
||||
}
|
||||
|
||||
// Get linked project
|
||||
$stmt = $pdo->prepare('SELECT id, project_number, name, status FROM projects WHERE order_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$order['project'] = $stmt->fetch() ?: null;
|
||||
|
||||
// Get linked invoice
|
||||
$stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
$order['invoice'] = $stmt->fetch() ?: null;
|
||||
|
||||
// Valid transitions
|
||||
$order['valid_transitions'] = getValidTransitions($order['status']);
|
||||
|
||||
successResponse($order);
|
||||
}
|
||||
|
||||
function handleGetAttachment(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT attachment_data, attachment_name FROM orders WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row || !$row['attachment_data']) {
|
||||
errorResponse('Příloha nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->buffer($row['attachment_data']);
|
||||
if ($mime !== 'application/pdf') {
|
||||
errorResponse('Příloha není platný PDF soubor', 415);
|
||||
}
|
||||
|
||||
header_remove('Content-Type');
|
||||
header('Content-Type: application/pdf');
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['attachment_name'] ?: 'priloha.pdf'));
|
||||
header('Content-Disposition: attachment; filename="' . $safeName . '"');
|
||||
header('Content-Length: ' . strlen($row['attachment_data']));
|
||||
echo $row['attachment_data'];
|
||||
exit;
|
||||
}
|
||||
|
||||
function handleCreateOrder(PDO $pdo): void
|
||||
{
|
||||
// Podporuje JSON i FormData (kvuli nahravani prilohy)
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (str_contains($contentType, 'multipart/form-data')) {
|
||||
$quotationId = (int)($_POST['quotationId'] ?? 0);
|
||||
$customerOrderNumber = trim($_POST['customerOrderNumber'] ?? '');
|
||||
} else {
|
||||
$input = getJsonInput();
|
||||
$quotationId = (int)($input['quotationId'] ?? 0);
|
||||
$customerOrderNumber = trim($input['customerOrderNumber'] ?? '');
|
||||
}
|
||||
|
||||
if (!$quotationId) {
|
||||
errorResponse('ID nabídky je povinné');
|
||||
}
|
||||
|
||||
if ($customerOrderNumber === '') {
|
||||
errorResponse('Číslo objednávky zákazníka je povinné');
|
||||
}
|
||||
if (mb_strlen($customerOrderNumber) > 100) {
|
||||
errorResponse('Číslo objednávky zákazníka je příliš dlouhé (max 100 znaků)');
|
||||
}
|
||||
|
||||
// Validace prilohy
|
||||
$attachmentData = null;
|
||||
$attachmentName = null;
|
||||
if (!empty($_FILES['attachment']['tmp_name'])) {
|
||||
$file = $_FILES['attachment'];
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
errorResponse('Chyba při nahrávání souboru');
|
||||
}
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
if ($mime !== 'application/pdf') {
|
||||
errorResponse('Příloha musí být ve formátu PDF');
|
||||
}
|
||||
if ($file['size'] > 10 * 1024 * 1024) {
|
||||
errorResponse('Příloha nesmí být větší než 10 MB');
|
||||
}
|
||||
$attachmentData = file_get_contents($file['tmp_name']);
|
||||
$attachmentName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name']));
|
||||
}
|
||||
|
||||
// Verify quotation exists and has no order yet
|
||||
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||
$stmt->execute([$quotationId]);
|
||||
$quotation = $stmt->fetch();
|
||||
|
||||
if (!$quotation) {
|
||||
errorResponse('Nabídka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
if ($quotation['order_id']) {
|
||||
errorResponse('Tato nabídka již má objednávku');
|
||||
}
|
||||
|
||||
// Get quotation items and sections
|
||||
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
||||
$stmt->execute([$quotationId]);
|
||||
$quotationItems = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
||||
$stmt->execute([$quotationId]);
|
||||
$quotationSections = $stmt->fetchAll();
|
||||
|
||||
// Lock for concurrent number generation
|
||||
$locked = $pdo->query("SELECT GET_LOCK('boha_order_number', 5)")->fetchColumn();
|
||||
if (!$locked) {
|
||||
errorResponse('Nepodařilo se získat zámek pro číslo objednávky, zkuste to znovu', 503);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$orderNumber = generateOrderNumber($pdo);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO orders (
|
||||
order_number, customer_order_number, attachment_data, attachment_name,
|
||||
quotation_id, customer_id, status,
|
||||
currency, language, vat_rate, apply_vat, exchange_rate,
|
||||
scope_title, scope_description, created_at, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'prijata', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
$orderNumber,
|
||||
$customerOrderNumber,
|
||||
$attachmentData,
|
||||
$attachmentName,
|
||||
$quotationId,
|
||||
$quotation['customer_id'],
|
||||
$quotation['currency'] ?? 'EUR',
|
||||
$quotation['language'] ?? 'EN',
|
||||
$quotation['vat_rate'] ?? 0,
|
||||
$quotation['apply_vat'] ?? 0,
|
||||
$quotation['exchange_rate'],
|
||||
$quotation['scope_title'] ?? '',
|
||||
$quotation['scope_description'] ?? '',
|
||||
]);
|
||||
$orderId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Copy items
|
||||
if (!empty($quotationItems)) {
|
||||
$itemStmt = $pdo->prepare('
|
||||
INSERT INTO order_items (
|
||||
order_id, description, item_description, quantity, unit,
|
||||
unit_price, is_included_in_total, position, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
');
|
||||
foreach ($quotationItems as $item) {
|
||||
$itemStmt->execute([
|
||||
$orderId,
|
||||
$item['description'] ?? '',
|
||||
$item['item_description'] ?? '',
|
||||
$item['quantity'] ?? 1,
|
||||
$item['unit'] ?? '',
|
||||
$item['unit_price'] ?? 0,
|
||||
$item['is_included_in_total'] ?? 1,
|
||||
$item['position'] ?? 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy sections
|
||||
if (!empty($quotationSections)) {
|
||||
$sectionStmt = $pdo->prepare('
|
||||
INSERT INTO order_sections (
|
||||
order_id, title, title_cz, content, position, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, NOW())
|
||||
');
|
||||
foreach ($quotationSections as $section) {
|
||||
$sectionStmt->execute([
|
||||
$orderId,
|
||||
$section['title'] ?? '',
|
||||
$section['title_cz'] ?? '',
|
||||
$section['content'] ?? '',
|
||||
$section['position'] ?? 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create project with same number
|
||||
$projectName = $quotation['project_code'] ?: ($quotation['customer_name'] ?? 'Projekt ' . $orderNumber);
|
||||
// Need customer name
|
||||
if (!$quotation['project_code'] && $quotation['customer_id']) {
|
||||
$custStmt = $pdo->prepare('SELECT name FROM customers WHERE id = ?');
|
||||
$custStmt->execute([$quotation['customer_id']]);
|
||||
$custName = $custStmt->fetchColumn();
|
||||
if ($custName) {
|
||||
$projectName = $custName;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO projects (
|
||||
project_number, name, customer_id, quotation_id, order_id,
|
||||
status, start_date, created_at, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'aktivni', CURDATE(), NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
$orderNumber,
|
||||
$projectName,
|
||||
$quotation['customer_id'],
|
||||
$quotationId,
|
||||
$orderId,
|
||||
]);
|
||||
$projectId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Update quotation with back-reference
|
||||
$stmt = $pdo->prepare('UPDATE quotations SET order_id = ?, modified_at = NOW() WHERE id = ?');
|
||||
$stmt->execute([$orderId, $quotationId]);
|
||||
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
|
||||
|
||||
AuditLog::logCreate('orders_order', $orderId, [
|
||||
'order_number' => $orderNumber,
|
||||
'quotation_number' => $quotation['quotation_number'],
|
||||
'project_id' => $projectId,
|
||||
], "Vytvořena objednávka '$orderNumber' z nabídky '{$quotation['quotation_number']}'");
|
||||
|
||||
successResponse([
|
||||
'order_id' => $orderId,
|
||||
'order_number' => $orderNumber,
|
||||
'project_id' => $projectId,
|
||||
'project_number' => $orderNumber,
|
||||
], 'Objednávka byla vytvořena');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateOrder(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$order = $stmt->fetch();
|
||||
|
||||
if (!$order) {
|
||||
errorResponse('Objednávka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
$newStatus = $input['status'] ?? null;
|
||||
$notes = $input['notes'] ?? null;
|
||||
$newOrderNumber = isset($input['order_number']) ? trim($input['order_number']) : null;
|
||||
|
||||
// Delkove limity
|
||||
if ($notes !== null && mb_strlen($notes) > 5000) {
|
||||
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
||||
}
|
||||
if ($newOrderNumber !== null && mb_strlen($newOrderNumber) > 50) {
|
||||
errorResponse('Číslo objednávky je příliš dlouhé (max 50 znaků)');
|
||||
}
|
||||
|
||||
// Validate status transition
|
||||
if ($newStatus && $newStatus !== $order['status']) {
|
||||
$valid = getValidTransitions($order['status']);
|
||||
if (!in_array($newStatus, $valid)) {
|
||||
errorResponse("Neplatný přechod stavu z '{$order['status']}' na '$newStatus'");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate order number uniqueness
|
||||
if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) {
|
||||
if (empty($newOrderNumber)) {
|
||||
errorResponse('Číslo objednávky nesmí být prázdné');
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ? AND id != ?');
|
||||
$stmt->execute([$newOrderNumber, $id]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('Toto číslo objednávky již existuje');
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) {
|
||||
$updates[] = 'order_number = ?';
|
||||
$params[] = $newOrderNumber;
|
||||
|
||||
// Sync project number
|
||||
$stmt = $pdo->prepare('UPDATE projects SET project_number = ?, modified_at = NOW() WHERE order_id = ?');
|
||||
$stmt->execute([$newOrderNumber, $id]);
|
||||
}
|
||||
if ($newStatus !== null) {
|
||||
$updates[] = 'status = ?';
|
||||
$params[] = $newStatus;
|
||||
}
|
||||
if ($notes !== null) {
|
||||
$updates[] = 'notes = ?';
|
||||
$params[] = $notes;
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$updates[] = 'modified_at = NOW()';
|
||||
$params[] = $id;
|
||||
$sql = 'UPDATE orders SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
// Sync project status with order status
|
||||
if ($newStatus && $newStatus !== $order['status']) {
|
||||
$projectStatus = null;
|
||||
if ($newStatus === 'stornovana') {
|
||||
$projectStatus = 'zruseny';
|
||||
} elseif ($newStatus === 'dokoncena') {
|
||||
$projectStatus = 'dokonceny';
|
||||
} elseif ($newStatus === 'v_realizaci') {
|
||||
$projectStatus = 'aktivni';
|
||||
}
|
||||
|
||||
if ($projectStatus) {
|
||||
$stmt = $pdo->prepare('UPDATE projects SET status = ?, modified_at = NOW() WHERE order_id = ?');
|
||||
$stmt->execute([$projectStatus, $id]);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'orders_order',
|
||||
$id,
|
||||
['status' => $order['status'], 'notes' => $order['notes']],
|
||||
['status' => $newStatus ?? $order['status'], 'notes' => $notes ?? $order['notes']],
|
||||
"Upravena objednávka '{$order['order_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Objednávka byla aktualizována');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteOrder(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$order = $stmt->fetch();
|
||||
|
||||
if (!$order) {
|
||||
errorResponse('Objednávka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Delete project linked to this order
|
||||
$stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// Delete order items and sections
|
||||
$stmt = $pdo->prepare('DELETE FROM order_items WHERE order_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM order_sections WHERE order_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// Delete order
|
||||
$stmt = $pdo->prepare('DELETE FROM orders WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// Remove back-reference from quotation
|
||||
$stmt = $pdo->prepare('UPDATE quotations SET order_id = NULL, modified_at = NOW() WHERE order_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logDelete('orders_order', $id, [
|
||||
'order_number' => $order['order_number'],
|
||||
'quotation_id' => $order['quotation_id'],
|
||||
], "Smazána objednávka '{$order['order_number']}'");
|
||||
|
||||
successResponse(null, 'Objednávka byla smazána');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
113
api/admin/profile.php
Normal file
113
api/admin/profile.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Profile API
|
||||
*
|
||||
* Allows any authenticated user to update their own profile
|
||||
*
|
||||
* PUT /api/admin/profile.php - Update own profile
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
// Set headers
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require authentication
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method !== 'PUT') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$userId = $authData['user_id'];
|
||||
|
||||
// Get existing user
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$existingUser = $stmt->fetch();
|
||||
|
||||
if (!$existingUser) {
|
||||
errorResponse('Uživatel nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$username = isset($input['username']) ? sanitize($input['username']) : $existingUser['username'];
|
||||
$email = isset($input['email']) ? sanitize($input['email']) : $existingUser['email'];
|
||||
$firstName = isset($input['first_name']) ? sanitize($input['first_name']) : $existingUser['first_name'];
|
||||
$lastName = isset($input['last_name']) ? sanitize($input['last_name']) : $existingUser['last_name'];
|
||||
|
||||
// Validate email format
|
||||
if (!isValidEmail($email)) {
|
||||
errorResponse('Neplatný formát e-mailu');
|
||||
}
|
||||
|
||||
// Check username uniqueness (excluding current user)
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ? AND id != ?');
|
||||
$stmt->execute([$username, $userId]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('Uživatelské jméno již existuje');
|
||||
}
|
||||
|
||||
// Check email uniqueness (excluding current user)
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND id != ?');
|
||||
$stmt->execute([$email, $userId]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('E-mail již existuje');
|
||||
}
|
||||
|
||||
// Update user
|
||||
if (!empty($input['password'])) {
|
||||
// Validate password length
|
||||
if (strlen($input['password']) < 8) {
|
||||
errorResponse('Heslo musí mít alespoň 8 znaků');
|
||||
}
|
||||
|
||||
$passwordHash = password_hash($input['password'], PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE users
|
||||
SET username = ?, email = ?, password_hash = ?, first_name = ?, last_name = ?, password_changed_at = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $userId]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE users
|
||||
SET username = ?, email = ?, first_name = ?, last_name = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$username, $email, $firstName, $lastName, $userId]);
|
||||
}
|
||||
|
||||
// Audit log
|
||||
AuditLog::logUpdate('user', $userId, [
|
||||
'username' => $existingUser['username'],
|
||||
'email' => $existingUser['email'],
|
||||
'first_name' => $existingUser['first_name'],
|
||||
'last_name' => $existingUser['last_name'],
|
||||
], [
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
], 'Uživatel aktualizoval svůj profil');
|
||||
|
||||
successResponse(null, 'Profil byl úspěšně aktualizován');
|
||||
} catch (PDOException $e) {
|
||||
error_log('Profile API error: ' . $e->getMessage());
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
533
api/admin/projects.php
Normal file
533
api/admin/projects.php
Normal file
@@ -0,0 +1,533 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Projects API
|
||||
*
|
||||
* GET /api/admin/projects.php - List projects
|
||||
* GET /api/admin/projects.php?action=detail&id=X - Get project detail
|
||||
* GET /api/admin/projects.php?action=notes&id=X - Get project notes
|
||||
* GET /api/admin/projects.php?action=next_number - Get next available project number
|
||||
* POST /api/admin/projects.php - Create new project (manual)
|
||||
* POST /api/admin/projects.php?action=add_note&id=X - Add note to project
|
||||
* PUT /api/admin/projects.php?id=X - Update project
|
||||
* DELETE /api/admin/projects.php?action=delete_note¬eId=X - Delete note (admin)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'projects.view');
|
||||
switch ($action) {
|
||||
case 'detail':
|
||||
if (!$id) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
handleGetDetail($pdo, $id);
|
||||
break;
|
||||
case 'notes':
|
||||
if (!$id) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
handleGetNotes($pdo, $id);
|
||||
break;
|
||||
case 'next_number':
|
||||
requirePermission($authData, 'projects.create');
|
||||
handleGetNextNumber($pdo);
|
||||
break;
|
||||
default:
|
||||
handleGetList($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'add_note') {
|
||||
requirePermission($authData, 'projects.view');
|
||||
if (!$id) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
handleAddNote($pdo, $id, $authData);
|
||||
} elseif (!$action) {
|
||||
requirePermission($authData, 'projects.create');
|
||||
handleCreateProject($pdo);
|
||||
} else {
|
||||
errorResponse('Neznámá akce', 400);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'projects.edit');
|
||||
if (!$id) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
handleUpdateProject($pdo, $id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if ($action === 'delete_note') {
|
||||
requirePermission($authData, 'projects.edit');
|
||||
$noteId = isset($_GET['noteId']) ? (int) $_GET['noteId'] : null;
|
||||
if (!$noteId) {
|
||||
errorResponse('ID poznámky je povinné');
|
||||
}
|
||||
handleDeleteNote($pdo, $noteId, $authData);
|
||||
} elseif (!$action && $id) {
|
||||
requirePermission($authData, 'projects.delete');
|
||||
handleDeleteProject($pdo, $id);
|
||||
} else {
|
||||
errorResponse('Neznámá akce', 400);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Projects API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Number generation ---
|
||||
|
||||
function generateProjectNumber(PDO $pdo): string
|
||||
{
|
||||
return generateSharedNumber($pdo);
|
||||
}
|
||||
|
||||
function handleGetNextNumber(PDO $pdo): void
|
||||
{
|
||||
$number = generateProjectNumber($pdo);
|
||||
successResponse(['number' => $number]);
|
||||
}
|
||||
|
||||
function handleCreateProject(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$name = trim($input['name'] ?? '');
|
||||
if (!$name) {
|
||||
errorResponse('Název projektu je povinný');
|
||||
}
|
||||
if (mb_strlen($name) > 255) {
|
||||
errorResponse('Název projektu je příliš dlouhý (max 255 znaků)');
|
||||
}
|
||||
|
||||
$customerId = isset($input['customer_id']) ? (int)$input['customer_id'] : null;
|
||||
if (!$customerId) {
|
||||
errorResponse('Zákazník je povinný');
|
||||
}
|
||||
|
||||
// Verify customer exists
|
||||
$stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
||||
$stmt->execute([$customerId]);
|
||||
if (!$stmt->fetch()) {
|
||||
errorResponse('Zákazník nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$startDate = $input['start_date'] ?? date('Y-m-d');
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
|
||||
errorResponse('Neplatný formát data zahájení');
|
||||
}
|
||||
|
||||
$projectNumber = trim($input['project_number'] ?? '');
|
||||
if ($projectNumber && mb_strlen($projectNumber) > 50) {
|
||||
errorResponse('Číslo projektu je příliš dlouhé (max 50 znaků)');
|
||||
}
|
||||
|
||||
// Lock for concurrent number generation
|
||||
$locked = $pdo->query("SELECT GET_LOCK('boha_project_number', 5)")->fetchColumn();
|
||||
if (!$locked) {
|
||||
errorResponse('Nepodařilo se získat zámek pro číslo projektu, zkuste to znovu', 503);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Generate or validate number
|
||||
if (!$projectNumber) {
|
||||
$projectNumber = generateProjectNumber($pdo);
|
||||
} else {
|
||||
// Validate uniqueness against both tables
|
||||
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ?');
|
||||
$stmt->execute([$projectNumber]);
|
||||
if ($stmt->fetch()) {
|
||||
$pdo->rollBack();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||
errorResponse('Číslo projektu je již použito jako číslo objednávky');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM projects WHERE project_number = ?');
|
||||
$stmt->execute([$projectNumber]);
|
||||
if ($stmt->fetch()) {
|
||||
$pdo->rollBack();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||
errorResponse('Číslo projektu je již použito');
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO projects (
|
||||
project_number, name, customer_id,
|
||||
status, start_date, created_at, modified_at
|
||||
) VALUES (?, ?, ?, 'aktivni', ?, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
$projectNumber,
|
||||
$name,
|
||||
$customerId,
|
||||
$startDate,
|
||||
]);
|
||||
$projectId = (int)$pdo->lastInsertId();
|
||||
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||
|
||||
AuditLog::logCreate('projects_project', $projectId, [
|
||||
'project_number' => $projectNumber,
|
||||
'name' => $name,
|
||||
'customer_id' => $customerId,
|
||||
], "Ručně vytvořen projekt '$projectNumber'");
|
||||
|
||||
successResponse([
|
||||
'project_id' => $projectId,
|
||||
'project_number' => $projectNumber,
|
||||
], 'Projekt byl vytvořen');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteProject(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$project = $stmt->fetch();
|
||||
|
||||
if (!$project) {
|
||||
errorResponse('Projekt nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
// Only manually created projects (without order_id) can be deleted
|
||||
if (!empty($project['order_id'])) {
|
||||
errorResponse('Projekt propojený s objednávkou nelze smazat. Smažte objednávku.', 400);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Delete project notes
|
||||
$stmt = $pdo->prepare('DELETE FROM project_notes WHERE project_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// Delete project
|
||||
$stmt = $pdo->prepare('DELETE FROM projects WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'projects_project',
|
||||
$id,
|
||||
['status' => $project['status']],
|
||||
['status' => 'deleted'],
|
||||
"Smazán ruční projekt '{$project['project_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Projekt byl smazán');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGetList(PDO $pdo): void
|
||||
{
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$sort = $_GET['sort'] ?? 'created_at';
|
||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||
|
||||
$sortMap = [
|
||||
'ProjectNumber' => 'p.project_number',
|
||||
'project_number' => 'p.project_number',
|
||||
'Name' => 'p.name',
|
||||
'name' => 'p.name',
|
||||
'Status' => 'p.status',
|
||||
'status' => 'p.status',
|
||||
'StartDate' => 'p.start_date',
|
||||
'start_date' => 'p.start_date',
|
||||
'EndDate' => 'p.end_date',
|
||||
'end_date' => 'p.end_date',
|
||||
'CreatedAt' => 'p.created_at',
|
||||
'created_at' => 'p.created_at',
|
||||
];
|
||||
if (!isset($sortMap[$sort])) {
|
||||
errorResponse('Neplatný parametr řazení', 400);
|
||||
}
|
||||
$sortCol = $sortMap[$sort];
|
||||
|
||||
$where = 'WHERE 1=1';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
$where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)';
|
||||
$searchParam = "%{$search}%";
|
||||
$params = [$searchParam, $searchParam, $searchParam];
|
||||
}
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date,
|
||||
p.order_id, p.quotation_id, p.created_at,
|
||||
c.name as customer_name,
|
||||
o.order_number
|
||||
FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$projects = $stmt->fetchAll();
|
||||
|
||||
successResponse([
|
||||
'projects' => $projects,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT p.*,
|
||||
c.name as customer_name,
|
||||
o.order_number, o.status as order_status,
|
||||
q.quotation_number
|
||||
FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
LEFT JOIN quotations q ON p.quotation_id = q.id
|
||||
WHERE p.id = ?
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$project = $stmt->fetch();
|
||||
|
||||
if (!$project) {
|
||||
errorResponse('Projekt nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
successResponse($project);
|
||||
}
|
||||
|
||||
function handleUpdateProject(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$project = $stmt->fetch();
|
||||
|
||||
if (!$project) {
|
||||
errorResponse('Projekt nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
// Validace statusu
|
||||
if (isset($input['status'])) {
|
||||
$validStatuses = ['aktivni', 'dokonceny', 'zruseny'];
|
||||
if (!in_array($input['status'], $validStatuses)) {
|
||||
errorResponse('Neplatný stav projektu');
|
||||
}
|
||||
}
|
||||
|
||||
// Validace dat
|
||||
if (
|
||||
isset($input['start_date'])
|
||||
&& $input['start_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue
|
||||
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['start_date'])
|
||||
) {
|
||||
errorResponse('Neplatný formát data zahájení');
|
||||
}
|
||||
if (
|
||||
isset($input['end_date'])
|
||||
&& $input['end_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue
|
||||
&& $input['end_date'] !== ''
|
||||
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['end_date'])
|
||||
) {
|
||||
errorResponse('Neplatný formát data ukončení');
|
||||
}
|
||||
|
||||
// Delkove limity
|
||||
$name = $input['name'] ?? $project['name'];
|
||||
if (mb_strlen($name) > 255) {
|
||||
errorResponse('Název projektu je příliš dlouhý (max 255 znaků)');
|
||||
}
|
||||
$notes = $input['notes'] ?? $project['notes'];
|
||||
if ($notes !== null && mb_strlen($notes) > 5000) {
|
||||
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE projects SET
|
||||
name = ?,
|
||||
status = ?,
|
||||
start_date = ?,
|
||||
end_date = ?,
|
||||
notes = ?,
|
||||
modified_at = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([
|
||||
$name,
|
||||
$input['status'] ?? $project['status'],
|
||||
$input['start_date'] ?? $project['start_date'],
|
||||
$input['end_date'] ?? $project['end_date'],
|
||||
$notes,
|
||||
$id,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'projects_project',
|
||||
$id,
|
||||
['name' => $project['name'], 'status' => $project['status']],
|
||||
['name' => $input['name'] ?? $project['name'], 'status' => $input['status'] ?? $project['status']],
|
||||
"Upraven projekt '{$project['project_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Projekt byl aktualizován');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGetNotes(PDO $pdo, int $projectId): void
|
||||
{
|
||||
// Verify project exists
|
||||
$stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?');
|
||||
$stmt->execute([$projectId]);
|
||||
if (!$stmt->fetch()) {
|
||||
errorResponse('Projekt nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, project_id, user_id, user_name, content, created_at
|
||||
FROM project_notes
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at DESC
|
||||
');
|
||||
$stmt->execute([$projectId]);
|
||||
$notes = $stmt->fetchAll();
|
||||
|
||||
successResponse(['notes' => $notes]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $authData */
|
||||
function handleAddNote(PDO $pdo, int $projectId, array $authData): void
|
||||
{
|
||||
// Verify project exists
|
||||
$stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?');
|
||||
$stmt->execute([$projectId]);
|
||||
if (!$stmt->fetch()) {
|
||||
errorResponse('Projekt nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
$content = trim($input['content'] ?? '');
|
||||
|
||||
if (!$content) {
|
||||
errorResponse('Text poznámky je povinný');
|
||||
}
|
||||
|
||||
if (mb_strlen($content) > 5000) {
|
||||
errorResponse('Poznámka je příliš dlouhá (max 5000 znaků)');
|
||||
}
|
||||
|
||||
$userName = $authData['user']['full_name'] ?? $authData['user']['username'] ?? 'Neznámý';
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO project_notes (project_id, user_id, user_name, content, created_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
');
|
||||
$stmt->execute([$projectId, $authData['user_id'], $userName, $content]);
|
||||
|
||||
$noteId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Fetch the new note
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, project_id, user_id, user_name, content, created_at FROM project_notes WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$noteId]);
|
||||
$note = $stmt->fetch();
|
||||
|
||||
successResponse(['note' => $note], 'Poznámka byla přidána');
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $authData */
|
||||
function handleDeleteNote(PDO $pdo, int $noteId, array $authData): void
|
||||
{
|
||||
// Only admins can delete notes
|
||||
$isAdmin = $authData['user']['is_admin'] ?? false;
|
||||
if (!$isAdmin) {
|
||||
errorResponse('Pouze administrátoři mohou mazat poznámky', 403);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, project_id, content FROM project_notes WHERE id = ?');
|
||||
$stmt->execute([$noteId]);
|
||||
$note = $stmt->fetch();
|
||||
|
||||
if (!$note) {
|
||||
errorResponse('Poznámka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM project_notes WHERE id = ?');
|
||||
$stmt->execute([$noteId]);
|
||||
|
||||
successResponse(null, 'Poznámka byla smazána');
|
||||
}
|
||||
597
api/admin/received-invoices.php
Normal file
597
api/admin/received-invoices.php
Normal file
@@ -0,0 +1,597 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Received Invoices API - přijaté faktury (upload, CRUD, stats)
|
||||
*
|
||||
* GET ?action=list&month=X&year=Y - Seznam přijatých faktur
|
||||
* GET ?action=stats&month=X&year=Y - KPI statistiky
|
||||
* GET ?action=detail&id=X - Detail záznamu (bez BLOB)
|
||||
* GET ?action=file&id=X - Stažení/zobrazení souboru
|
||||
* POST (FormData) - Bulk upload: files[] + invoices JSON
|
||||
* PUT ?id=X - Update metadat / změna stavu
|
||||
* DELETE ?id=X - Smazání záznamu
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'invoices.view');
|
||||
switch ($action) {
|
||||
case 'stats':
|
||||
handleGetStats($pdo);
|
||||
break;
|
||||
case 'detail':
|
||||
if (!$id) {
|
||||
errorResponse('ID je povinné');
|
||||
}
|
||||
handleGetDetail($pdo, $id);
|
||||
break;
|
||||
case 'file':
|
||||
if (!$id) {
|
||||
errorResponse('ID je povinné');
|
||||
}
|
||||
handleGetFile($pdo, $id);
|
||||
break;
|
||||
default:
|
||||
handleGetList($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'invoices.create');
|
||||
handleBulkUpload($pdo, $authData);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'invoices.edit');
|
||||
if (!$id) {
|
||||
errorResponse('ID je povinné');
|
||||
}
|
||||
handleUpdateReceivedInvoice($pdo, $id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'invoices.delete');
|
||||
if (!$id) {
|
||||
errorResponse('ID je povinné');
|
||||
}
|
||||
handleDeleteReceivedInvoice($pdo, $id);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Received Invoices API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Allowed MIME types ---
|
||||
|
||||
/** @return list<string> */
|
||||
function getAllowedMimes(): array
|
||||
{
|
||||
return ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
}
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
function handleGetStats(PDO $pdo): void
|
||||
{
|
||||
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
||||
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
||||
|
||||
$monthStart = sprintf('%04d-%02d-01', $year, $month);
|
||||
$monthEnd = date('Y-m-t', strtotime($monthStart));
|
||||
|
||||
// Celkem v měsíci (issue_date)
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT currency, SUM(amount) as total, SUM(vat_amount) as vat_total, COUNT(*) as cnt
|
||||
FROM received_invoices
|
||||
WHERE issue_date BETWEEN ? AND ?
|
||||
GROUP BY currency
|
||||
');
|
||||
$stmt->execute([$monthStart, $monthEnd]);
|
||||
$monthRows = $stmt->fetchAll();
|
||||
|
||||
$totalAmounts = [];
|
||||
$vatAmounts = [];
|
||||
$czkItems = [];
|
||||
$vatCzkItems = [];
|
||||
$monthCount = 0;
|
||||
|
||||
foreach ($monthRows as $r) {
|
||||
$totalAmounts[$r['currency']] = round((float) $r['total'], 2);
|
||||
$vatAmounts[$r['currency']] = round((float) $r['vat_total'], 2);
|
||||
$monthCount += (int) $r['cnt'];
|
||||
$czkItems[] = [
|
||||
'amount' => round((float) $r['total'], 2),
|
||||
'currency' => $r['currency'],
|
||||
'date' => $monthStart,
|
||||
];
|
||||
$vatCzkItems[] = [
|
||||
'amount' => round((float) $r['vat_total'], 2),
|
||||
'currency' => $r['currency'],
|
||||
'date' => $monthStart,
|
||||
];
|
||||
}
|
||||
|
||||
$totalArr = [];
|
||||
foreach ($totalAmounts as $cur => $amt) {
|
||||
$totalArr[] = ['amount' => $amt, 'currency' => $cur];
|
||||
}
|
||||
$vatArr = [];
|
||||
foreach ($vatAmounts as $cur => $amt) {
|
||||
$vatArr[] = ['amount' => $amt, 'currency' => $cur];
|
||||
}
|
||||
|
||||
// Neuhrazeno celkově
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT currency, SUM(amount) as total, COUNT(*) as cnt
|
||||
FROM received_invoices WHERE status = ?
|
||||
GROUP BY currency
|
||||
');
|
||||
$stmt->execute(['unpaid']);
|
||||
$unpaidRows = $stmt->fetchAll();
|
||||
|
||||
$unpaidAmounts = [];
|
||||
$unpaidCzkItems = [];
|
||||
$unpaidCount = 0;
|
||||
foreach ($unpaidRows as $r) {
|
||||
$unpaidAmounts[] = ['amount' => round((float) $r['total'], 2), 'currency' => $r['currency']];
|
||||
$unpaidCount += (int) $r['cnt'];
|
||||
$unpaidCzkItems[] = [
|
||||
'amount' => round((float) $r['total'], 2),
|
||||
'currency' => $r['currency'],
|
||||
'date' => date('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
$cnb = CnbRates::getInstance();
|
||||
|
||||
successResponse([
|
||||
'total_month' => $totalArr,
|
||||
'total_month_czk' => $cnb->sumToCzk($czkItems),
|
||||
'vat_month' => $vatArr,
|
||||
'vat_month_czk' => $cnb->sumToCzk($vatCzkItems),
|
||||
'unpaid' => $unpaidAmounts,
|
||||
'unpaid_czk' => $cnb->sumToCzk($unpaidCzkItems),
|
||||
'unpaid_count' => $unpaidCount,
|
||||
'month_count' => $monthCount,
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
function handleGetList(PDO $pdo): void
|
||||
{
|
||||
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
||||
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$sort = $_GET['sort'] ?? 'created_at';
|
||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
$sortMap = [
|
||||
'supplier_name' => 'supplier_name',
|
||||
'invoice_number' => 'invoice_number',
|
||||
'status' => 'status',
|
||||
'issue_date' => 'issue_date',
|
||||
'due_date' => 'due_date',
|
||||
'amount' => 'amount',
|
||||
'created_at' => 'created_at',
|
||||
];
|
||||
if (!isset($sortMap[$sort])) {
|
||||
errorResponse('Neplatný parametr řazení', 400);
|
||||
}
|
||||
$sortCol = $sortMap[$sort];
|
||||
|
||||
$where = 'WHERE month = ? AND year = ?';
|
||||
$params = [$month, $year];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
$where .= ' AND (supplier_name LIKE ? OR invoice_number LIKE ?)';
|
||||
$searchParam = "%{$search}%";
|
||||
$params[] = $searchParam;
|
||||
$params[] = $searchParam;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT id, supplier_name, invoice_number, description,
|
||||
amount, currency, vat_rate, vat_amount,
|
||||
issue_date, due_date, paid_date, status,
|
||||
file_name, file_mime, file_size, notes,
|
||||
created_at, modified_at
|
||||
FROM received_invoices
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$invoices = $stmt->fetchAll();
|
||||
|
||||
successResponse(['invoices' => $invoices]);
|
||||
}
|
||||
|
||||
// --- Detail ---
|
||||
|
||||
function handleGetDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, supplier_name, invoice_number, description,
|
||||
amount, currency, vat_rate, vat_amount,
|
||||
issue_date, due_date, paid_date, status,
|
||||
file_name, file_mime, file_size, notes,
|
||||
uploaded_by, created_at, modified_at
|
||||
FROM received_invoices WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$id]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) {
|
||||
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
successResponse($invoice);
|
||||
}
|
||||
|
||||
// --- File streaming ---
|
||||
|
||||
function handleGetFile(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT file_data, file_name, file_mime, file_size FROM received_invoices WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row || !$row['file_data']) {
|
||||
errorResponse('Soubor nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$safeFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['file_name']));
|
||||
header('Content-Type: ' . $row['file_mime']);
|
||||
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . $row['file_size']);
|
||||
header_remove('X-Content-Type-Options');
|
||||
echo $row['file_data'];
|
||||
exit();
|
||||
}
|
||||
|
||||
// --- Bulk upload ---
|
||||
|
||||
/** @param array<string, mixed> $authData */
|
||||
function handleBulkUpload(PDO $pdo, array $authData): void
|
||||
{
|
||||
$invoicesJson = $_POST['invoices'] ?? '[]';
|
||||
$invoicesMeta = json_decode($invoicesJson, true);
|
||||
|
||||
if (!is_array($invoicesMeta)) {
|
||||
errorResponse('Neplatná metadata');
|
||||
}
|
||||
if (count($invoicesMeta) === 0) {
|
||||
errorResponse('Žádné faktury k nahrání');
|
||||
}
|
||||
if (count($invoicesMeta) > 20) {
|
||||
errorResponse('Maximálně 20 faktur najednou');
|
||||
}
|
||||
|
||||
$files = $_FILES['files'] ?? [];
|
||||
$fileCount = is_array($files['tmp_name'] ?? null) ? count($files['tmp_name']) : 0;
|
||||
|
||||
if ($fileCount !== count($invoicesMeta)) {
|
||||
errorResponse('Počet souborů neodpovídá počtu metadat');
|
||||
}
|
||||
|
||||
$allowedMimes = getAllowedMimes();
|
||||
$validCurrencies = ['CZK', 'EUR', 'USD', 'GBP'];
|
||||
$validVatRates = [0, 10, 12, 15, 21];
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$created = [];
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO received_invoices (
|
||||
month, year, supplier_name, invoice_number, description,
|
||||
amount, currency, vat_rate, vat_amount,
|
||||
issue_date, due_date, status,
|
||||
file_data, file_name, file_mime, file_size,
|
||||
notes, uploaded_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
for ($i = 0; $i < $fileCount; $i++) {
|
||||
$meta = $invoicesMeta[$i];
|
||||
$tmpName = $files['tmp_name'][$i];
|
||||
$fileError = $files['error'][$i];
|
||||
$fileSize = $files['size'][$i];
|
||||
$fileName = $files['name'][$i];
|
||||
|
||||
if ($fileError !== UPLOAD_ERR_OK) {
|
||||
errorResponse("Chyba při nahrávání souboru #" . ($i + 1));
|
||||
}
|
||||
if ($fileSize > 10 * 1024 * 1024) {
|
||||
errorResponse("Soubor #" . ($i + 1) . " je větší než 10 MB");
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($tmpName);
|
||||
if (!in_array($mime, $allowedMimes)) {
|
||||
errorResponse("Soubor #" . ($i + 1) . ": nepodporovaný formát (povoleno: PDF, JPEG, PNG)");
|
||||
}
|
||||
|
||||
$supplierName = trim($meta['supplier_name'] ?? '');
|
||||
if ($supplierName === '') {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": dodavatel je povinný");
|
||||
}
|
||||
if (mb_strlen($supplierName) > 255) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": název dodavatele je příliš dlouhý");
|
||||
}
|
||||
|
||||
$amount = (float) ($meta['amount'] ?? 0);
|
||||
if ($amount <= 0) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": částka musí být větší než 0");
|
||||
}
|
||||
|
||||
$currency = trim($meta['currency'] ?? 'CZK');
|
||||
if (!in_array($currency, $validCurrencies)) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": neplatná měna");
|
||||
}
|
||||
|
||||
$vatRate = (float) ($meta['vat_rate'] ?? 21);
|
||||
if (!in_array((int) $vatRate, $validVatRates)) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": neplatná sazba DPH");
|
||||
}
|
||||
|
||||
$vatAmount = round($amount * $vatRate / 100, 2);
|
||||
$invoiceNumber = trim($meta['invoice_number'] ?? '');
|
||||
$description = trim($meta['description'] ?? '');
|
||||
$issueDate = trim($meta['issue_date'] ?? '');
|
||||
$dueDate = trim($meta['due_date'] ?? '');
|
||||
$notes = trim($meta['notes'] ?? '');
|
||||
|
||||
// Validace dat
|
||||
if ($issueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $issueDate) || !strtotime($issueDate))) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data vystavení");
|
||||
}
|
||||
if ($dueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dueDate) || !strtotime($dueDate))) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data splatnosti");
|
||||
}
|
||||
|
||||
// Délkové limity
|
||||
if (mb_strlen($invoiceNumber) > 100) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": číslo faktury je příliš dlouhé");
|
||||
}
|
||||
if (mb_strlen($description) > 500) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": popis je příliš dlouhý");
|
||||
}
|
||||
if (mb_strlen($notes) > 5000) {
|
||||
errorResponse("Faktura #" . ($i + 1) . ": poznámka je příliš dlouhá");
|
||||
}
|
||||
|
||||
// Určit month/year z issue_date nebo aktuální
|
||||
if ($issueDate) {
|
||||
$dt = new DateTime($issueDate);
|
||||
$month = (int) $dt->format('n');
|
||||
$year = (int) $dt->format('Y');
|
||||
} else {
|
||||
$month = (int) date('n');
|
||||
$year = (int) date('Y');
|
||||
}
|
||||
|
||||
$fileData = file_get_contents($tmpName);
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName));
|
||||
|
||||
$stmt->execute([
|
||||
$month,
|
||||
$year,
|
||||
$supplierName,
|
||||
$invoiceNumber ?: null,
|
||||
$description ?: null,
|
||||
$amount,
|
||||
$currency,
|
||||
$vatRate,
|
||||
$vatAmount,
|
||||
$issueDate ?: null,
|
||||
$dueDate ?: null,
|
||||
'unpaid',
|
||||
$fileData,
|
||||
$safeName,
|
||||
$mime,
|
||||
$fileSize,
|
||||
$notes ?: null,
|
||||
$authData['user_id'],
|
||||
]);
|
||||
|
||||
$created[] = (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logCreate('received_invoices', $created[0], [
|
||||
'count' => count($created),
|
||||
'ids' => $created,
|
||||
], 'Nahráno ' . count($created) . ' přijatých faktur');
|
||||
|
||||
successResponse(['ids' => $created], 'Faktury byly nahrány');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update ---
|
||||
|
||||
function handleUpdateReceivedInvoice(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM received_invoices WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) {
|
||||
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
$stringFields = [
|
||||
'supplier_name' => 255,
|
||||
'invoice_number' => 100,
|
||||
'description' => 500,
|
||||
'notes' => 5000,
|
||||
];
|
||||
foreach ($stringFields as $field => $maxLen) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$val = trim((string) $input[$field]);
|
||||
if ($field === 'supplier_name' && $val === '') {
|
||||
errorResponse('Dodavatel je povinný');
|
||||
}
|
||||
if (mb_strlen($val) > $maxLen) {
|
||||
errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)");
|
||||
}
|
||||
$updates[] = "$field = ?";
|
||||
$params[] = $val ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('amount', $input)) {
|
||||
$amount = (float) $input['amount'];
|
||||
if ($amount <= 0) {
|
||||
errorResponse('Částka musí být větší než 0');
|
||||
}
|
||||
$updates[] = 'amount = ?';
|
||||
$params[] = $amount;
|
||||
}
|
||||
|
||||
if (array_key_exists('currency', $input)) {
|
||||
if (!in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||
errorResponse('Neplatná měna');
|
||||
}
|
||||
$updates[] = 'currency = ?';
|
||||
$params[] = $input['currency'];
|
||||
}
|
||||
|
||||
if (array_key_exists('vat_rate', $input)) {
|
||||
$vatRate = (float) $input['vat_rate'];
|
||||
if (!in_array((int) $vatRate, [0, 10, 12, 15, 21])) {
|
||||
errorResponse('Neplatná sazba DPH');
|
||||
}
|
||||
$updates[] = 'vat_rate = ?';
|
||||
$params[] = $vatRate;
|
||||
|
||||
$amount = (float) ($input['amount'] ?? $invoice['amount']);
|
||||
$updates[] = 'vat_amount = ?';
|
||||
$params[] = round($amount * $vatRate / 100, 2);
|
||||
} elseif (array_key_exists('amount', $input)) {
|
||||
$vatRate = (float) ($input['vat_rate'] ?? $invoice['vat_rate']);
|
||||
$updates[] = 'vat_amount = ?';
|
||||
$params[] = round((float) $input['amount'] * $vatRate / 100, 2);
|
||||
}
|
||||
|
||||
foreach (['issue_date', 'due_date'] as $dateField) {
|
||||
if (array_key_exists($dateField, $input)) {
|
||||
$val = trim((string) $input[$dateField]);
|
||||
if ($val && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $val) || !strtotime($val))) {
|
||||
errorResponse("Neplatný formát data: $dateField");
|
||||
}
|
||||
$updates[] = "$dateField = ?";
|
||||
$params[] = $val ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Aktualizace month/year pokud se změní issue_date
|
||||
if (array_key_exists('issue_date', $input) && $input['issue_date']) {
|
||||
$dt = new DateTime($input['issue_date']);
|
||||
$updates[] = 'month = ?';
|
||||
$params[] = (int) $dt->format('n');
|
||||
$updates[] = 'year = ?';
|
||||
$params[] = (int) $dt->format('Y');
|
||||
}
|
||||
|
||||
// Změna stavu - pouze unpaid -> paid (jednosmerny prechod)
|
||||
if (array_key_exists('status', $input)) {
|
||||
$newStatus = $input['status'];
|
||||
if (!in_array($newStatus, ['unpaid', 'paid'])) {
|
||||
errorResponse('Neplatný stav');
|
||||
}
|
||||
if ($invoice['status'] === 'paid' && $newStatus !== 'paid') {
|
||||
errorResponse('Uhrazenou fakturu nelze vrátit do stavu neuhrazená');
|
||||
}
|
||||
if ($newStatus !== $invoice['status']) {
|
||||
$updates[] = 'status = ?';
|
||||
$params[] = $newStatus;
|
||||
if ($newStatus === 'paid') {
|
||||
$updates[] = 'paid_date = CURDATE()';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
errorResponse('Žádné změny k uložení');
|
||||
}
|
||||
|
||||
$updates[] = 'modified_at = NOW()';
|
||||
$params[] = $id;
|
||||
$sql = 'UPDATE received_invoices SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||
$pdo->prepare($sql)->execute($params);
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'received_invoices',
|
||||
$id,
|
||||
['status' => $invoice['status']],
|
||||
['status' => $input['status'] ?? $invoice['status']],
|
||||
"Aktualizována přijatá faktura #{$id}"
|
||||
);
|
||||
|
||||
successResponse(null, 'Faktura byla aktualizována');
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
function handleDeleteReceivedInvoice(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, supplier_name, invoice_number FROM received_invoices WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) {
|
||||
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM received_invoices WHERE id = ?')->execute([$id]);
|
||||
|
||||
AuditLog::logDelete('received_invoices', $id, [
|
||||
'supplier_name' => $invoice['supplier_name'],
|
||||
'invoice_number' => $invoice['invoice_number'],
|
||||
], "Smazána přijatá faktura #{$id}");
|
||||
|
||||
successResponse(null, 'Faktura byla smazána');
|
||||
}
|
||||
59
api/admin/refresh.php
Normal file
59
api/admin/refresh.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Token Refresh Endpoint
|
||||
*
|
||||
* Uses the httpOnly refresh_token cookie to issue a new access token.
|
||||
* Called silently on page load and when access token expires.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
$rateLimiter = new RateLimiter();
|
||||
$rateLimiter->enforce('refresh', 30);
|
||||
|
||||
// Check for refresh token in cookie
|
||||
if (!isset($_COOKIE['refresh_token'])) {
|
||||
errorResponse('No refresh token', 401);
|
||||
}
|
||||
|
||||
// Attempt to refresh tokens
|
||||
$result = JWTAuth::refreshTokens();
|
||||
|
||||
if (!$result) {
|
||||
errorResponse('Invalid or expired refresh token', 401);
|
||||
}
|
||||
|
||||
// Add 2FA info to user data
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare('SELECT totp_enabled FROM users WHERE id = ?');
|
||||
$stmt->execute([$result['user']['id']]);
|
||||
$u = $stmt->fetch();
|
||||
$result['user']['totp_enabled'] = (bool) ($u['totp_enabled'] ?? false);
|
||||
|
||||
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||
$result['user']['require_2fa'] = (bool) $stmt->fetchColumn();
|
||||
} catch (PDOException $e) {
|
||||
$result['user']['totp_enabled'] = false;
|
||||
$result['user']['require_2fa'] = false;
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'access_token' => $result['access_token'],
|
||||
'expires_in' => $result['expires_in'],
|
||||
'user' => $result['user'],
|
||||
], 'Token refreshed');
|
||||
300
api/admin/roles.php
Normal file
300
api/admin/roles.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Roles API
|
||||
*
|
||||
* GET /api/admin/roles.php - List all roles with permissions
|
||||
* POST /api/admin/roles.php - Create new role
|
||||
* PUT /api/admin/roles.php?id=X - Update role
|
||||
* DELETE /api/admin/roles.php?id=X - Delete role
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
// Set headers
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require authentication
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
// Require settings.roles permission
|
||||
requirePermission($authData, 'settings.roles');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$roleId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
handleGetRole($pdo);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
handleCreateRole($pdo);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$roleId) {
|
||||
errorResponse('ID role je povinné');
|
||||
}
|
||||
handleUpdateRole($pdo, $roleId);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$roleId) {
|
||||
errorResponse('ID role je povinné');
|
||||
}
|
||||
handleDeleteRole($pdo, $roleId);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Roles API error: ' . $e->getMessage());
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - List all roles with their permissions + all available permissions
|
||||
*/
|
||||
function handleGetRole(PDO $pdo): void
|
||||
{
|
||||
// Get all roles with user count (LEFT JOIN instead of correlated subquery)
|
||||
$stmt = $pdo->query('
|
||||
SELECT r.*, COUNT(u.id) as user_count
|
||||
FROM roles r
|
||||
LEFT JOIN users u ON u.role_id = r.id
|
||||
GROUP BY r.id
|
||||
ORDER BY r.id
|
||||
');
|
||||
$roles = $stmt->fetchAll();
|
||||
|
||||
// Batch fetch all role-permission mappings in one query (was N+1)
|
||||
$stmt = $pdo->query('
|
||||
SELECT rp.role_id, p.name
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
');
|
||||
$allRolePerms = $stmt->fetchAll();
|
||||
|
||||
// Group permissions by role_id
|
||||
$permsByRole = [];
|
||||
foreach ($allRolePerms as $rp) {
|
||||
$permsByRole[$rp['role_id']][] = $rp['name'];
|
||||
}
|
||||
|
||||
foreach ($roles as &$role) {
|
||||
$role['permissions'] = $permsByRole[$role['id']] ?? [];
|
||||
$role['permission_count'] = count($role['permissions']);
|
||||
}
|
||||
unset($role);
|
||||
|
||||
// Get all available permissions grouped by module
|
||||
$stmt = $pdo->query('SELECT id, name, display_name, description FROM permissions ORDER BY id');
|
||||
$allPermissions = $stmt->fetchAll();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($allPermissions as $perm) {
|
||||
$parts = explode('.', $perm['name'], 2);
|
||||
$module = $parts[0];
|
||||
if (!isset($grouped[$module])) {
|
||||
$grouped[$module] = [];
|
||||
}
|
||||
$grouped[$module][] = $perm;
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'roles' => $roles,
|
||||
'permissions' => $allPermissions,
|
||||
'permission_groups' => $grouped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Create new role
|
||||
*/
|
||||
function handleCreateRole(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$name = trim($input['name'] ?? '');
|
||||
$displayName = trim($input['display_name'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
$permissions = $input['permissions'] ?? [];
|
||||
|
||||
if (!$name) {
|
||||
errorResponse('Název role je povinný');
|
||||
}
|
||||
|
||||
if (!$displayName) {
|
||||
errorResponse('Zobrazovaný název je povinný');
|
||||
}
|
||||
|
||||
// Validate name format (slug)
|
||||
if (!preg_match('/^[a-z0-9_-]+$/', $name)) {
|
||||
errorResponse('Název role může obsahovat pouze malá písmena, čísla, pomlčky a podtržítka');
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
$stmt = $pdo->prepare('SELECT id FROM roles WHERE name = ?');
|
||||
$stmt->execute([$name]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('Role s tímto názvem již existuje');
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
// Create role
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO roles (name, display_name, description)
|
||||
VALUES (?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$name, $displayName, $description ?: null]);
|
||||
$newRoleId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Assign permissions
|
||||
if (!empty($permissions)) {
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT ?, id FROM permissions WHERE name = ?
|
||||
');
|
||||
foreach ($permissions as $permName) {
|
||||
$stmt->execute([$newRoleId, $permName]);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logCreate('role', $newRoleId, [
|
||||
'name' => $name,
|
||||
'display_name' => $displayName,
|
||||
'permissions' => $permissions,
|
||||
], "Vytvořena role '$displayName'");
|
||||
|
||||
successResponse(['id' => $newRoleId], 'Role byla vytvořena');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT - Update role
|
||||
*/
|
||||
function handleUpdateRole(PDO $pdo, int $roleId): void
|
||||
{
|
||||
// Get existing role
|
||||
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
$role = $stmt->fetch();
|
||||
|
||||
if (!$role) {
|
||||
errorResponse('Role nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Block editing admin role name
|
||||
if ($role['name'] === 'admin') {
|
||||
errorResponse('Roli administrátora nelze upravovat');
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$displayName = trim($input['display_name'] ?? $role['display_name']);
|
||||
$description = trim($input['description'] ?? $role['description'] ?? '');
|
||||
$permissions = $input['permissions'] ?? null;
|
||||
|
||||
if (!$displayName) {
|
||||
errorResponse('Zobrazovaný název je povinný');
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
// Update role
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE roles SET display_name = ?, description = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$displayName, $description ?: null, $roleId]);
|
||||
|
||||
// Update permissions if provided
|
||||
if ($permissions !== null) {
|
||||
// Remove existing permissions
|
||||
$stmt = $pdo->prepare('DELETE FROM role_permissions WHERE role_id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
|
||||
// Add new permissions
|
||||
if (!empty($permissions)) {
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT ?, id FROM permissions WHERE name = ?
|
||||
');
|
||||
foreach ($permissions as $permName) {
|
||||
$stmt->execute([$roleId, $permName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
AuditLog::logUpdate('role', $roleId, [
|
||||
'display_name' => $role['display_name'],
|
||||
], [
|
||||
'display_name' => $displayName,
|
||||
'permissions' => $permissions,
|
||||
], "Upravena role '$displayName'");
|
||||
|
||||
successResponse(null, 'Role byla aktualizována');
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete role
|
||||
*/
|
||||
function handleDeleteRole(PDO $pdo, int $roleId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
$role = $stmt->fetch();
|
||||
|
||||
if (!$role) {
|
||||
errorResponse('Role nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Block deleting admin role
|
||||
if ($role['name'] === 'admin') {
|
||||
errorResponse('Roli administrátora nelze smazat');
|
||||
}
|
||||
|
||||
// Check if role has users
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role_id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
$userCount = $stmt->fetchColumn();
|
||||
|
||||
if ($userCount > 0) {
|
||||
errorResponse("Nelze smazat roli s {$userCount} přiřazenými uživateli. Nejprve změňte roli těmto uživatelům.");
|
||||
}
|
||||
|
||||
// Delete role (cascade deletes role_permissions)
|
||||
$stmt = $pdo->prepare('DELETE FROM roles WHERE id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
|
||||
AuditLog::logDelete('role', $roleId, $role, "Smazána role '{$role['display_name']}'");
|
||||
|
||||
successResponse(null, 'Role byla smazána');
|
||||
}
|
||||
111
api/admin/session.php
Normal file
111
api/admin/session.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Session Check API (JWT)
|
||||
*
|
||||
* GET /api/admin/session.php
|
||||
*
|
||||
* Checks if the user has a valid session by:
|
||||
* 1. First checking the Authorization header for a valid access token
|
||||
* 2. If no valid access token, tries to refresh using the refresh_token cookie
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": {
|
||||
* "authenticated": boolean,
|
||||
* "user": { ... } | null,
|
||||
* "access_token": "string" | null,
|
||||
* "expires_in": int | null
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// 200 req/min - vola se pri kazde zmene route
|
||||
$rateLimiter = new RateLimiter();
|
||||
$rateLimiter->enforce('session', 200);
|
||||
|
||||
// Cleanup expired refresh tokenu (0.1% sance)
|
||||
if (rand(1, 1000) === 1) {
|
||||
try {
|
||||
JWTAuth::cleanupExpiredTokens();
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'])) {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function get2FAInfo(PDO $pdo, int $userId): array
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT totp_enabled FROM users WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
$r2fa = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||
return [
|
||||
'totp_enabled' => (bool) ($row['totp_enabled'] ?? false),
|
||||
'require_2fa' => (bool) $r2fa->fetchColumn(),
|
||||
];
|
||||
} catch (PDOException $e) {
|
||||
return ['totp_enabled' => false, 'require_2fa' => false];
|
||||
}
|
||||
}
|
||||
|
||||
$authData = JWTAuth::optionalAuth();
|
||||
|
||||
if ($authData) {
|
||||
$userData = $authData['user'];
|
||||
$userData['permissions'] = JWTAuth::getUserPermissions($authData['user_id']);
|
||||
|
||||
$twoFA = get2FAInfo(db(), $authData['user_id']);
|
||||
$userData['totp_enabled'] = $twoFA['totp_enabled'];
|
||||
$userData['require_2fa'] = $twoFA['require_2fa'];
|
||||
|
||||
successResponse([
|
||||
'authenticated' => true,
|
||||
'user' => $userData,
|
||||
'access_token' => null,
|
||||
'expires_in' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||
|
||||
if ($refreshToken) {
|
||||
$result = JWTAuth::refreshTokens();
|
||||
|
||||
if ($result) {
|
||||
$twoFA = get2FAInfo(db(), $result['user']['id']);
|
||||
$result['user']['totp_enabled'] = $twoFA['totp_enabled'];
|
||||
$result['user']['require_2fa'] = $twoFA['require_2fa'];
|
||||
|
||||
successResponse([
|
||||
'authenticated' => true,
|
||||
'user' => $result['user'],
|
||||
'access_token' => $result['access_token'],
|
||||
'expires_in' => $result['expires_in'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'authenticated' => false,
|
||||
'user' => null,
|
||||
'access_token' => null,
|
||||
'expires_in' => null,
|
||||
]);
|
||||
243
api/admin/sessions.php
Normal file
243
api/admin/sessions.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Sessions API
|
||||
*
|
||||
* Allows users to view and manage their active sessions (logged-in devices)
|
||||
*
|
||||
* GET /api/admin/sessions.php - List all active sessions for current user
|
||||
* DELETE /api/admin/sessions.php?id=X - Delete a specific session
|
||||
* DELETE /api/admin/sessions.php?action=all - Delete all sessions except current
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
|
||||
// Set headers
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require authentication
|
||||
$authData = JWTAuth::requireAuth();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$sessionId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
$action = $_GET['action'] ?? null;
|
||||
$currentUserId = $authData['user_id'];
|
||||
|
||||
// Get current refresh token hash for identifying current session
|
||||
$currentTokenHash = null;
|
||||
if (isset($_COOKIE['refresh_token'])) {
|
||||
$currentTokenHash = hash('sha256', $_COOKIE['refresh_token']);
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
handleGetSession($pdo, $currentUserId, $currentTokenHash);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if ($action === 'all') {
|
||||
handleDeleteAllSessions($pdo, $currentUserId, $currentTokenHash);
|
||||
} elseif ($sessionId) {
|
||||
handleDeleteSession($pdo, $sessionId, $currentUserId, $currentTokenHash);
|
||||
} else {
|
||||
errorResponse('ID relace nebo akce je povinná');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Sessions API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - List all active sessions for current user
|
||||
*/
|
||||
function handleGetSession(PDO $pdo, int $userId, ?string $currentTokenHash): void
|
||||
{
|
||||
// Cleanup: expirované + rotované tokeny po grace period
|
||||
$stmt = $pdo->prepare(
|
||||
'DELETE FROM refresh_tokens WHERE user_id = ? AND (expires_at < NOW()'
|
||||
. ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL '
|
||||
. JWTAuth::getGracePeriod() . ' SECOND)))'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
// Jen aktivní sessions (nereplacované)
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT
|
||||
id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
created_at,
|
||||
expires_at,
|
||||
token_hash
|
||||
FROM refresh_tokens
|
||||
WHERE user_id = ? AND replaced_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
$sessions = $stmt->fetchAll();
|
||||
|
||||
// Process sessions to add is_current flag and parse user agent
|
||||
$processedSessions = array_map(function ($session) use ($currentTokenHash) {
|
||||
return [
|
||||
'id' => (int) $session['id'],
|
||||
'ip_address' => $session['ip_address'],
|
||||
'user_agent' => $session['user_agent'],
|
||||
'device_info' => parseUserAgent($session['user_agent']),
|
||||
'created_at' => $session['created_at'],
|
||||
'expires_at' => $session['expires_at'],
|
||||
'is_current' => $currentTokenHash && $session['token_hash'] === $currentTokenHash,
|
||||
];
|
||||
}, $sessions);
|
||||
|
||||
successResponse([
|
||||
'sessions' => $processedSessions,
|
||||
'total' => count($processedSessions),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete a specific session
|
||||
*/
|
||||
function handleDeleteSession(PDO $pdo, int $sessionId, int $userId, ?string $currentTokenHash): void
|
||||
{
|
||||
// Verify the session belongs to the current user
|
||||
$stmt = $pdo->prepare('SELECT token_hash FROM refresh_tokens WHERE id = ? AND user_id = ?');
|
||||
$stmt->execute([$sessionId, $userId]);
|
||||
$session = $stmt->fetch();
|
||||
|
||||
if (!$session) {
|
||||
errorResponse('Relace nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Check if trying to delete current session
|
||||
if ($currentTokenHash && $session['token_hash'] === $currentTokenHash) {
|
||||
// Check if force parameter is set
|
||||
$input = getJsonInput();
|
||||
if (!($input['force'] ?? false)) {
|
||||
errorResponse('Nelze smazat aktuální relaci. Použijte tlačítko odhlášení.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the session
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?');
|
||||
$stmt->execute([$sessionId, $userId]);
|
||||
|
||||
successResponse(null, 'Relace byla úspěšně ukončena');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete all sessions except current
|
||||
*/
|
||||
function handleDeleteAllSessions(PDO $pdo, int $userId, ?string $currentTokenHash): void
|
||||
{
|
||||
if (!$currentTokenHash) {
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$deleted = $stmt->rowCount();
|
||||
} else {
|
||||
// Ponechat aktuální session, smazat ostatní (včetně replaced)
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND token_hash != ?');
|
||||
$stmt->execute([$userId, $currentTokenHash]);
|
||||
$deleted = $stmt->rowCount();
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'deleted' => $deleted,
|
||||
], $deleted > 0 ? 'Ostatní relace byly úspěšně ukončeny' : 'Žádné další relace k ukončení');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user agent string to extract device/browser info
|
||||
*
|
||||
* @return array{browser: string, os: string}
|
||||
*/
|
||||
function parseUserAgent(?string $userAgent): array
|
||||
{
|
||||
if (empty($userAgent)) {
|
||||
return [
|
||||
'browser' => 'Neznámý prohlížeč',
|
||||
'os' => 'Neznámý systém',
|
||||
'device' => 'Neznámé zařízení',
|
||||
'icon' => 'device',
|
||||
];
|
||||
}
|
||||
|
||||
$browser = 'Neznámý prohlížeč';
|
||||
$os = 'Neznámý systém';
|
||||
$device = 'desktop';
|
||||
$icon = 'desktop';
|
||||
|
||||
// Detect browser
|
||||
if (preg_match('/Edg(e|A|iOS)?\/[\d.]+/i', $userAgent)) {
|
||||
$browser = 'Microsoft Edge';
|
||||
} elseif (preg_match('/OPR\/[\d.]+|Opera/i', $userAgent)) {
|
||||
$browser = 'Opera';
|
||||
} elseif (preg_match('/Chrome\/[\d.]+/i', $userAgent) && !preg_match('/Chromium/i', $userAgent)) {
|
||||
$browser = 'Google Chrome';
|
||||
} elseif (preg_match('/Firefox\/[\d.]+/i', $userAgent)) {
|
||||
$browser = 'Mozilla Firefox';
|
||||
} elseif (preg_match('/Safari\/[\d.]+/i', $userAgent) && !preg_match('/Chrome/i', $userAgent)) {
|
||||
$browser = 'Safari';
|
||||
} elseif (preg_match('/MSIE|Trident/i', $userAgent)) {
|
||||
$browser = 'Internet Explorer';
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
if (preg_match('/Windows NT 10/i', $userAgent)) {
|
||||
$os = 'Windows 10/11';
|
||||
} elseif (preg_match('/Windows NT 6\.3/i', $userAgent)) {
|
||||
$os = 'Windows 8.1';
|
||||
} elseif (preg_match('/Windows NT 6\.2/i', $userAgent)) {
|
||||
$os = 'Windows 8';
|
||||
} elseif (preg_match('/Windows NT 6\.1/i', $userAgent)) {
|
||||
$os = 'Windows 7';
|
||||
} elseif (preg_match('/Windows/i', $userAgent)) {
|
||||
$os = 'Windows';
|
||||
} elseif (preg_match('/Macintosh|Mac OS X/i', $userAgent)) {
|
||||
$os = 'macOS';
|
||||
} elseif (preg_match('/Linux/i', $userAgent) && !preg_match('/Android/i', $userAgent)) {
|
||||
$os = 'Linux';
|
||||
} elseif (preg_match('/iPhone/i', $userAgent)) {
|
||||
$os = 'iOS';
|
||||
$device = 'mobile';
|
||||
$icon = 'smartphone';
|
||||
} elseif (preg_match('/iPad/i', $userAgent)) {
|
||||
$os = 'iPadOS';
|
||||
$device = 'tablet';
|
||||
$icon = 'tablet';
|
||||
} elseif (preg_match('/Android/i', $userAgent)) {
|
||||
$os = 'Android';
|
||||
if (preg_match('/Mobile/i', $userAgent)) {
|
||||
$device = 'mobile';
|
||||
$icon = 'smartphone';
|
||||
} else {
|
||||
$device = 'tablet';
|
||||
$icon = 'tablet';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'browser' => $browser,
|
||||
'os' => $os,
|
||||
'device' => $device,
|
||||
'icon' => $icon,
|
||||
];
|
||||
}
|
||||
488
api/admin/totp.php
Normal file
488
api/admin/totp.php
Normal file
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - TOTP 2FA API
|
||||
*
|
||||
* GET ?action=status - 2FA status
|
||||
* POST ?action=setup - generovat secret + QR
|
||||
* POST ?action=enable - overit kod a aktivovat 2FA
|
||||
* POST ?action=disable - deaktivovat 2FA
|
||||
* POST ?action=verify - overit TOTP kod pri loginu (pre-auth)
|
||||
* POST ?action=backup_verify - overit zalozhni kod pri loginu (pre-auth)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||
require_once dirname(__DIR__) . '/includes/Encryption.php';
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
|
||||
use RobThree\Auth\TwoFactorAuth;
|
||||
use RobThree\Auth\TwoFactorAuthException;
|
||||
use RobThree\Auth\Providers\Qr\QRServerProvider;
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
/** Lazy init - QRServerProvider dela externi HTTP, nepotrebujeme ho pro kazdy request */
|
||||
function getTfa(): TwoFactorAuth
|
||||
{
|
||||
static $tfa = null;
|
||||
if ($tfa === null) {
|
||||
$tfa = new TwoFactorAuth(new QRServerProvider(), 'BOHA Automation');
|
||||
}
|
||||
return $tfa;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($action) {
|
||||
case 'status':
|
||||
handleStatus($pdo);
|
||||
break;
|
||||
case 'setup':
|
||||
handleSetup($pdo, getTfa());
|
||||
break;
|
||||
case 'enable':
|
||||
handleEnable($pdo, getTfa());
|
||||
break;
|
||||
case 'disable':
|
||||
handleDisable($pdo, getTfa());
|
||||
break;
|
||||
case 'verify':
|
||||
handleVerify($pdo, getTfa());
|
||||
break;
|
||||
case 'backup_verify':
|
||||
handleBackupVerify($pdo);
|
||||
break;
|
||||
case 'get_required':
|
||||
handleGetRequired($pdo);
|
||||
break;
|
||||
case 'set_required':
|
||||
handleSetRequired($pdo);
|
||||
break;
|
||||
default:
|
||||
errorResponse('Neplatná akce', 400);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('TOTP API error: ' . $e->getMessage());
|
||||
errorResponse('Chyba databáze', 500);
|
||||
} catch (Exception $e) {
|
||||
error_log('TOTP error: ' . $e->getMessage());
|
||||
errorResponse('Došlo k chybě', 500);
|
||||
}
|
||||
|
||||
/** GET ?action=status */
|
||||
function handleStatus(PDO $pdo): void
|
||||
{
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
$userId = $authData['user_id'];
|
||||
|
||||
$stmt = $pdo->prepare('SELECT totp_enabled FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
successResponse([
|
||||
'totp_enabled' => (bool) ($user['totp_enabled'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST ?action=setup - vygenerovat secret + QR URI (jeste neaktivuje 2FA) */
|
||||
function handleSetup(PDO $pdo, TwoFactorAuth $tfa): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
$userId = $authData['user_id'];
|
||||
|
||||
$stmt = $pdo->prepare('SELECT totp_enabled, username, email FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user['totp_enabled']) {
|
||||
errorResponse('2FA je již aktivní. Nejdříve ji deaktivujte.');
|
||||
}
|
||||
|
||||
$secret = $tfa->createSecret();
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?');
|
||||
$stmt->execute([Encryption::encrypt($secret), $userId]);
|
||||
|
||||
$label = $user['email'] ?: $user['username'];
|
||||
$qrUri = $tfa->getQRText($label, $secret);
|
||||
|
||||
successResponse([
|
||||
'secret' => $secret,
|
||||
'qr_uri' => $qrUri,
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST ?action=enable { "code": "123456" } */
|
||||
function handleEnable(PDO $pdo, TwoFactorAuth $tfa): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
$userId = $authData['user_id'];
|
||||
$input = getJsonInput();
|
||||
$code = trim($input['code'] ?? '');
|
||||
|
||||
if (empty($code)) {
|
||||
errorResponse('Ověřovací kód je povinný');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user['totp_secret']) {
|
||||
errorResponse('Nejprve vygenerujte tajný klíč (setup)');
|
||||
}
|
||||
|
||||
if ($user['totp_enabled']) {
|
||||
errorResponse('2FA je již aktivní');
|
||||
}
|
||||
|
||||
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
||||
if (!$tfa->verifyCode($decryptedSecret, $code)) {
|
||||
errorResponse('Neplatný ověřovací kód. Zkontrolujte čas na telefonu.');
|
||||
}
|
||||
|
||||
$backupCodes = generateBackupCodes();
|
||||
$hashedCodes = array_map(fn ($c) => password_hash($c, PASSWORD_BCRYPT, ['cost' => 10]), $backupCodes);
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE users SET totp_enabled = 1, totp_backup_codes = ? WHERE id = ?');
|
||||
$stmt->execute([json_encode($hashedCodes), $userId]);
|
||||
|
||||
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 0], ['totp_enabled' => 1], 'Uživatel aktivoval 2FA');
|
||||
|
||||
successResponse([
|
||||
'backup_codes' => $backupCodes,
|
||||
], '2FA bylo úspěšně aktivováno');
|
||||
}
|
||||
|
||||
/** POST ?action=disable { "code": "123456" } */
|
||||
function handleDisable(PDO $pdo, TwoFactorAuth $tfa): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
$userId = $authData['user_id'];
|
||||
$input = getJsonInput();
|
||||
$code = trim($input['code'] ?? '');
|
||||
|
||||
if (empty($code)) {
|
||||
errorResponse('Ověřovací kód je povinný');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user['totp_enabled']) {
|
||||
errorResponse('2FA není aktivní');
|
||||
}
|
||||
|
||||
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
||||
if (!$tfa->verifyCode($decryptedSecret, $code)) {
|
||||
errorResponse('Neplatný ověřovací kód');
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE users SET totp_enabled = 0, totp_secret = NULL,
|
||||
totp_backup_codes = NULL WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 1], ['totp_enabled' => 0], 'Uživatel deaktivoval 2FA');
|
||||
|
||||
successResponse(null, '2FA bylo deaktivováno');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST ?action=verify - overeni TOTP kodu pri loginu (pre-auth)
|
||||
* Body: { "login_token": "...", "code": "123456", "remember": false }
|
||||
*/
|
||||
function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$rateLimiter = new RateLimiter();
|
||||
$rateLimiter->setFailClosed();
|
||||
$rateLimiter->enforce('totp_2fa', 5);
|
||||
|
||||
$input = getJsonInput();
|
||||
$loginToken = $input['login_token'] ?? '';
|
||||
$code = trim($input['code'] ?? '');
|
||||
$remember = (bool) ($input['remember'] ?? false);
|
||||
|
||||
if (empty($loginToken) || empty($code)) {
|
||||
errorResponse('Přihlašovací token a ověřovací kód jsou povinné');
|
||||
}
|
||||
|
||||
$tokenData = verifyLoginToken($pdo, $loginToken);
|
||||
if (!$tokenData) {
|
||||
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
|
||||
}
|
||||
|
||||
$userId = $tokenData['user_id'];
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT u.*, r.name as role_name, r.display_name as role_display_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = ? AND u.totp_enabled = 1
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user) {
|
||||
errorResponse('Uživatel nenalezen nebo 2FA není aktivní', 401);
|
||||
}
|
||||
|
||||
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
||||
if (!$tfa->verifyCode($decryptedSecret, $code, 1)) {
|
||||
errorResponse('Neplatný ověřovací kód');
|
||||
}
|
||||
|
||||
deleteLoginToken($pdo, $loginToken);
|
||||
completeLogin($pdo, $user, $remember);
|
||||
}
|
||||
|
||||
/** POST ?action=backup_verify { "login_token": "...", "code": "XXXXXXXX", "remember": false } */
|
||||
function handleBackupVerify(PDO $pdo): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$rateLimiter = new RateLimiter();
|
||||
$rateLimiter->setFailClosed();
|
||||
$rateLimiter->enforce('totp_2fa', 5);
|
||||
|
||||
$input = getJsonInput();
|
||||
$loginToken = $input['login_token'] ?? '';
|
||||
$code = strtoupper(trim($input['code'] ?? ''));
|
||||
$remember = (bool) ($input['remember'] ?? false);
|
||||
|
||||
if (empty($loginToken) || empty($code)) {
|
||||
errorResponse('Přihlašovací token a záložní kód jsou povinné');
|
||||
}
|
||||
|
||||
$tokenData = verifyLoginToken($pdo, $loginToken);
|
||||
if (!$tokenData) {
|
||||
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
|
||||
}
|
||||
|
||||
$userId = $tokenData['user_id'];
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT u.*, r.name as role_name, r.display_name as role_display_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = ? AND u.totp_enabled = 1
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user || !$user['totp_backup_codes']) {
|
||||
errorResponse('Uživatel nenalezen nebo nemá záložní kódy', 401);
|
||||
}
|
||||
|
||||
$hashedCodes = json_decode($user['totp_backup_codes'], true);
|
||||
$matched = false;
|
||||
$remainingCodes = [];
|
||||
|
||||
foreach ($hashedCodes as $hashed) {
|
||||
if (!$matched && password_verify($code, $hashed)) {
|
||||
$matched = true;
|
||||
} else {
|
||||
$remainingCodes[] = $hashed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$matched) {
|
||||
errorResponse('Neplatný záložní kód');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE users SET totp_backup_codes = ? WHERE id = ?');
|
||||
$stmt->execute([json_encode($remainingCodes), $userId]);
|
||||
|
||||
deleteLoginToken($pdo, $loginToken);
|
||||
completeLogin($pdo, $user, $remember);
|
||||
}
|
||||
|
||||
/** GET ?action=get_required (admin only) */
|
||||
function handleGetRequired(PDO $pdo): void
|
||||
{
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
requirePermission($authData, 'settings.security');
|
||||
|
||||
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||
|
||||
successResponse([
|
||||
'require_2fa' => (bool) $stmt->fetchColumn(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST ?action=set_required { "required": true/false } (admin only) */
|
||||
function handleSetRequired(PDO $pdo): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
requirePermission($authData, 'settings.security');
|
||||
|
||||
$input = getJsonInput();
|
||||
$required = (bool) ($input['required'] ?? false);
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE company_settings SET require_2fa = ? LIMIT 1");
|
||||
$stmt->execute([$required ? 1 : 0]);
|
||||
|
||||
successResponse([
|
||||
'require_2fa' => $required,
|
||||
], $required ? '2FA je nyní povinná pro všechny uživatele' : '2FA již není povinná');
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
/** Desifrovat TOTP secret z DB (zpetne kompatibilni s plaintextem pred migraci) */
|
||||
function decryptTotpSecret(string $value): string
|
||||
{
|
||||
if (Encryption::isEncrypted($value)) {
|
||||
return Encryption::decrypt($value);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generovat 8 nahodnych backup kodu
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function generateBackupCodes(int $count = 8): array
|
||||
{
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$codes[] = strtoupper(bin2hex(random_bytes(4))); // 8-char hex
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/** Docasny login token pro 2FA (5 min) */
|
||||
function createLoginToken(PDO $pdo, int $userId): string
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$hashedToken = hash('sha256', $token);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO totp_login_tokens (user_id, token_hash, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$userId, $hashedToken, $expiresAt]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overit login token
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
function verifyLoginToken(PDO $pdo, string $token): ?array
|
||||
{
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT * FROM totp_login_tokens
|
||||
WHERE token_hash = ? AND expires_at > NOW()
|
||||
');
|
||||
$stmt->execute([$hashedToken]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Smazat login token po pouziti */
|
||||
function deleteLoginToken(PDO $pdo, string $token): void
|
||||
{
|
||||
$hashedToken = hash('sha256', $token);
|
||||
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE token_hash = ?');
|
||||
$stmt->execute([$hashedToken]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dokoncit login po uspesnem 2FA - vydat JWT + refresh token
|
||||
*
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
function completeLogin(PDO $pdo, array $user, bool $remember): void
|
||||
{
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$user['id']]);
|
||||
|
||||
$userData = [
|
||||
'id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'email' => $user['email'],
|
||||
'first_name' => $user['first_name'],
|
||||
'last_name' => $user['last_name'],
|
||||
'role' => $user['role_name'] ?? null,
|
||||
'role_display' => $user['role_display_name'] ?? $user['role_name'] ?? null,
|
||||
'is_admin' => ($user['role_name'] ?? '') === 'admin',
|
||||
];
|
||||
|
||||
$accessToken = JWTAuth::generateAccessToken($userData);
|
||||
JWTAuth::generateRefreshToken($user['id'], $remember);
|
||||
|
||||
AuditLog::logLogin($user['id'], $user['username']);
|
||||
|
||||
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||
$require2FA = (bool) $stmt->fetchColumn();
|
||||
|
||||
successResponse([
|
||||
'access_token' => $accessToken,
|
||||
'expires_in' => JWTAuth::getAccessTokenExpiry(),
|
||||
'user' => [
|
||||
'id' => $userData['id'],
|
||||
'username' => $userData['username'],
|
||||
'email' => $userData['email'],
|
||||
'full_name' => trim($userData['first_name'] . ' ' . $userData['last_name']),
|
||||
'role' => $userData['role'],
|
||||
'role_display' => $userData['role_display'],
|
||||
'is_admin' => $userData['is_admin'],
|
||||
'permissions' => JWTAuth::getUserPermissions($user['id']),
|
||||
'totp_enabled' => true,
|
||||
'require_2fa' => $require2FA,
|
||||
],
|
||||
], 'Přihlášení úspěšné');
|
||||
}
|
||||
788
api/admin/trips.php
Normal file
788
api/admin/trips.php
Normal file
@@ -0,0 +1,788 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Trips API - Kniha jízd
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/admin/trips.php - Get current user's trips for month
|
||||
* GET /api/admin/trips.php?action=history - Get trip history with filters
|
||||
* GET /api/admin/trips.php?action=admin - Get all trips (admin)
|
||||
* GET /api/admin/trips.php?action=print - Get print data for trips (admin)
|
||||
* GET /api/admin/trips.php?action=vehicles - Get all vehicles (admin)
|
||||
* GET /api/admin/trips.php?action=active_vehicles - Get active vehicles
|
||||
* GET /api/admin/trips.php?action=last_km&vehicle_id=X - Get last km for vehicle
|
||||
* POST /api/admin/trips.php - Create trip
|
||||
* POST /api/admin/trips.php?action=vehicle - Create/update vehicle (admin)
|
||||
* PUT /api/admin/trips.php?id=X - Update trip
|
||||
* DELETE /api/admin/trips.php?id=X - Delete trip
|
||||
* DELETE /api/admin/trips.php?action=vehicle&id=X - Delete vehicle (admin)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||
|
||||
// Set headers
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Handle preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Require authentication
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$userId = $authData['user_id'];
|
||||
$isAdmin = $authData['user']['is_admin'] ?? false;
|
||||
$pdo = db();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Route request
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
$action = $_GET['action'] ?? 'current';
|
||||
|
||||
switch ($action) {
|
||||
case 'history':
|
||||
requirePermission($authData, 'trips.history');
|
||||
handleGetHistory($pdo, $userId);
|
||||
break;
|
||||
case 'admin':
|
||||
requirePermission($authData, 'trips.admin');
|
||||
handleGetAdmin($pdo);
|
||||
break;
|
||||
case 'print':
|
||||
requirePermission($authData, 'trips.admin');
|
||||
handleGetPrint($pdo);
|
||||
break;
|
||||
case 'vehicles':
|
||||
requirePermission($authData, 'trips.vehicles');
|
||||
handleGetVehicles($pdo);
|
||||
break;
|
||||
case 'active_vehicles':
|
||||
requirePermission($authData, 'trips.record');
|
||||
handleGetActiveVehicles($pdo);
|
||||
break;
|
||||
case 'last_km':
|
||||
requirePermission($authData, 'trips.record');
|
||||
handleGetLastKm($pdo);
|
||||
break;
|
||||
default:
|
||||
requirePermission($authData, 'trips.record');
|
||||
handleGetCurrent($pdo, $userId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
if ($action === 'vehicle') {
|
||||
requirePermission($authData, 'trips.vehicles');
|
||||
handleVehicle($pdo);
|
||||
} else {
|
||||
requirePermission($authData, 'trips.record');
|
||||
handleCreateTrip($pdo, $userId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) {
|
||||
errorResponse('ID je povinné');
|
||||
}
|
||||
handleUpdateTrip($pdo, $id, $userId, $authData);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
if ($action === 'vehicle') {
|
||||
requirePermission($authData, 'trips.vehicles');
|
||||
handleDeleteVehicle($pdo, $id);
|
||||
} else {
|
||||
if (!$id) {
|
||||
errorResponse('ID je povinné');
|
||||
}
|
||||
handleDeleteTrip($pdo, $id, $userId, $authData);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Nepodporovaná metoda', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Trips API Error: ' . $e->getMessage());
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function getLastKmForVehicle(PDO $pdo, int $vehicleId): int
|
||||
{
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT COALESCE(
|
||||
(SELECT MAX(end_km) FROM trips WHERE vehicle_id = ?),
|
||||
(SELECT initial_km FROM vehicles WHERE id = ?),
|
||||
0
|
||||
) as last_km
|
||||
');
|
||||
$stmt->execute([$vehicleId, $vehicleId]);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
return $result ? (int)$result['last_km'] : 0;
|
||||
}
|
||||
|
||||
function formatKm(int $km): string
|
||||
{
|
||||
return number_format($km, 0, ',', ' ') . ' km';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET - Current month trips (filtered to current user)
|
||||
*/
|
||||
function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
{
|
||||
$month = validateMonth();
|
||||
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$sql = "
|
||||
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||
FROM trips t
|
||||
JOIN vehicles v ON t.vehicle_id = v.id
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.trip_date BETWEEN ? AND ?
|
||||
AND t.user_id = ?
|
||||
";
|
||||
$params = [$startDate, $endDate, $userId];
|
||||
|
||||
if ($vehicleId) {
|
||||
$sql .= ' AND t.vehicle_id = ?';
|
||||
$params[] = $vehicleId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$trips = $stmt->fetchAll();
|
||||
|
||||
// Get active vehicles for selection
|
||||
$stmt = $pdo->query('SELECT id, spz, name, brand, model FROM vehicles WHERE is_active = 1 ORDER BY name');
|
||||
$vehicles = $stmt->fetchAll();
|
||||
|
||||
// Calculate totals
|
||||
$totalDistance = 0;
|
||||
$businessDistance = 0;
|
||||
$privateDistance = 0;
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$totalDistance += $trip['distance'];
|
||||
if ($trip['is_business']) {
|
||||
$businessDistance += $trip['distance'];
|
||||
} else {
|
||||
$privateDistance += $trip['distance'];
|
||||
}
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'trips' => $trips,
|
||||
'vehicles' => $vehicles,
|
||||
'month' => $month,
|
||||
'totals' => [
|
||||
'total' => $totalDistance,
|
||||
'business' => $businessDistance,
|
||||
'private' => $privateDistance,
|
||||
'count' => count($trips),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Trip history with filters (filtered to current user)
|
||||
*/
|
||||
function handleGetHistory(PDO $pdo, int $userId): void
|
||||
{
|
||||
$month = validateMonth();
|
||||
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
||||
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$sql = "
|
||||
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||
FROM trips t
|
||||
JOIN vehicles v ON t.vehicle_id = v.id
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.trip_date BETWEEN ? AND ?
|
||||
AND t.user_id = ?
|
||||
";
|
||||
$params = [$startDate, $endDate, $userId];
|
||||
|
||||
if ($vehicleId) {
|
||||
$sql .= ' AND t.vehicle_id = ?';
|
||||
$params[] = $vehicleId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$trips = $stmt->fetchAll();
|
||||
|
||||
// Get vehicles for filter
|
||||
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles WHERE is_active = 1 ORDER BY name');
|
||||
$vehicles = $stmt->fetchAll();
|
||||
|
||||
// Calculate totals
|
||||
$totalDistance = 0;
|
||||
$businessDistance = 0;
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$totalDistance += $trip['distance'];
|
||||
if ($trip['is_business']) {
|
||||
$businessDistance += $trip['distance'];
|
||||
}
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'trips' => $trips,
|
||||
'vehicles' => $vehicles,
|
||||
'month' => $month,
|
||||
'totals' => [
|
||||
'total' => $totalDistance,
|
||||
'business' => $businessDistance,
|
||||
'count' => count($trips),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Admin view of all trips
|
||||
*/
|
||||
function handleGetAdmin(PDO $pdo): void
|
||||
{
|
||||
$dateFrom = $_GET['date_from'] ?? null;
|
||||
$dateTo = $_GET['date_to'] ?? null;
|
||||
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
||||
$filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
||||
|
||||
// Default to current month if no dates provided
|
||||
if (!$dateFrom || !$dateTo) {
|
||||
$month = date('Y-m');
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
} else {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
||||
errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)');
|
||||
}
|
||||
$startDate = $dateFrom;
|
||||
$endDate = $dateTo;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT t.*, v.spz, v.name as vehicle_name,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||
FROM trips t
|
||||
JOIN vehicles v ON t.vehicle_id = v.id
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.trip_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$startDate, $endDate];
|
||||
|
||||
if ($vehicleId) {
|
||||
$sql .= ' AND t.vehicle_id = ?';
|
||||
$params[] = $vehicleId;
|
||||
}
|
||||
|
||||
if ($filterUserId) {
|
||||
$sql .= ' AND t.user_id = ?';
|
||||
$params[] = $filterUserId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$trips = $stmt->fetchAll();
|
||||
|
||||
// Get vehicles for filter
|
||||
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name');
|
||||
$vehicles = $stmt->fetchAll();
|
||||
|
||||
// Get users for filter
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
// Calculate totals
|
||||
$totalDistance = 0;
|
||||
$businessDistance = 0;
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$totalDistance += $trip['distance'];
|
||||
if ($trip['is_business']) {
|
||||
$businessDistance += $trip['distance'];
|
||||
}
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'trips' => $trips,
|
||||
'vehicles' => $vehicles,
|
||||
'users' => $users,
|
||||
'date_from' => $startDate,
|
||||
'date_to' => $endDate,
|
||||
'totals' => [
|
||||
'total' => $totalDistance,
|
||||
'business' => $businessDistance,
|
||||
'count' => count($trips),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - All vehicles (admin)
|
||||
*/
|
||||
function handleGetVehicles(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('
|
||||
SELECT v.*, COUNT(t.id) as trip_count,
|
||||
COALESCE(MAX(t.end_km), v.initial_km) as current_km
|
||||
FROM vehicles v
|
||||
LEFT JOIN trips t ON t.vehicle_id = v.id
|
||||
GROUP BY v.id
|
||||
ORDER BY v.is_active DESC, v.name
|
||||
');
|
||||
$vehicles = $stmt->fetchAll();
|
||||
|
||||
successResponse(['vehicles' => $vehicles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Active vehicles for selection
|
||||
*/
|
||||
function handleGetActiveVehicles(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('
|
||||
SELECT v.id, v.spz, v.name, v.brand, v.model,
|
||||
COALESCE(MAX(t.end_km), v.initial_km) as current_km
|
||||
FROM vehicles v
|
||||
LEFT JOIN trips t ON t.vehicle_id = v.id
|
||||
WHERE v.is_active = 1
|
||||
GROUP BY v.id
|
||||
ORDER BY v.name
|
||||
');
|
||||
$vehicles = $stmt->fetchAll();
|
||||
|
||||
successResponse(['vehicles' => $vehicles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Last km for vehicle
|
||||
*/
|
||||
function handleGetLastKm(PDO $pdo): void
|
||||
{
|
||||
$vehicleId = (int)($_GET['vehicle_id'] ?? 0);
|
||||
if (!$vehicleId) {
|
||||
errorResponse('Vehicle ID je povinné');
|
||||
}
|
||||
|
||||
$lastKm = getLastKmForVehicle($pdo, $vehicleId);
|
||||
successResponse(['last_km' => $lastKm]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST - Create trip
|
||||
*/
|
||||
function handleCreateTrip(PDO $pdo, int $userId): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$vehicleId = (int)($input['vehicle_id'] ?? 0);
|
||||
$tripDate = $input['trip_date'] ?? '';
|
||||
$startKm = (int)($input['start_km'] ?? 0);
|
||||
$endKm = (int)($input['end_km'] ?? 0);
|
||||
$routeFrom = trim($input['route_from'] ?? '');
|
||||
$routeTo = trim($input['route_to'] ?? '');
|
||||
$isBusiness = (int)($input['is_business'] ?? 1);
|
||||
$notes = trim($input['notes'] ?? '');
|
||||
|
||||
// Validation
|
||||
if (!$vehicleId) {
|
||||
errorResponse('Vyberte vozidlo');
|
||||
}
|
||||
if (!$tripDate) {
|
||||
errorResponse('Datum jízdy je povinné');
|
||||
}
|
||||
if (!$startKm) {
|
||||
errorResponse('Počáteční stav km je povinný');
|
||||
}
|
||||
if (!$endKm) {
|
||||
errorResponse('Konečný stav km je povinný');
|
||||
}
|
||||
if (!$routeFrom) {
|
||||
errorResponse('Místo odjezdu je povinné');
|
||||
}
|
||||
if (!$routeTo) {
|
||||
errorResponse('Místo příjezdu je povinné');
|
||||
}
|
||||
if ($endKm <= $startKm) {
|
||||
errorResponse('Konečný stav km musí být větší než počáteční');
|
||||
}
|
||||
|
||||
// Check vehicle exists
|
||||
$stmt = $pdo->prepare('SELECT id FROM vehicles WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$vehicleId]);
|
||||
if (!$stmt->fetch()) {
|
||||
errorResponse('Vozidlo neexistuje nebo není aktivní');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO trips (vehicle_id, user_id, trip_date, start_km, end_km, route_from, route_to, is_business, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([
|
||||
$vehicleId, $userId, $tripDate, $startKm, $endKm,
|
||||
$routeFrom, $routeTo, $isBusiness, $notes ?: null,
|
||||
]);
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
AuditLog::logCreate('trips', $newId, $input, 'Vytvořen záznam jízdy');
|
||||
|
||||
successResponse(['id' => $newId], 'Jízda byla zaznamenána');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Create/update vehicle (admin)
|
||||
*/
|
||||
function handleVehicle(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$spz = strtoupper(trim($input['spz'] ?? ''));
|
||||
$name = trim($input['name'] ?? '');
|
||||
$brand = trim($input['brand'] ?? '');
|
||||
$model = trim($input['model'] ?? '');
|
||||
$initialKm = (int)($input['initial_km'] ?? 0);
|
||||
$isActive = isset($input['is_active']) ? (int)$input['is_active'] : 1;
|
||||
|
||||
if (!$spz) {
|
||||
errorResponse('SPZ je povinná');
|
||||
}
|
||||
if (!$name) {
|
||||
errorResponse('Název je povinný');
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
// Update
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE vehicles
|
||||
SET spz = ?, name = ?, brand = ?, model = ?, initial_km = ?, is_active = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive, $id]);
|
||||
|
||||
AuditLog::logUpdate('vehicles', $id, [], $input, 'Upraveno vozidlo');
|
||||
successResponse(null, 'Vozidlo bylo aktualizováno');
|
||||
} else {
|
||||
// Create
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO vehicles (spz, name, brand, model, initial_km, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
try {
|
||||
$stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
AuditLog::logCreate('vehicles', $newId, $input, 'Vytvořeno vozidlo');
|
||||
successResponse(['id' => $newId], 'Vozidlo bylo vytvořeno');
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() == 23000) {
|
||||
errorResponse('Vozidlo s touto SPZ již existuje');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUT Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* PUT - Update trip
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$trip = $stmt->fetch();
|
||||
|
||||
if (!$trip) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
// Check permission - own trips or trips.admin
|
||||
if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) {
|
||||
errorResponse('Nemáte oprávnění upravit tento záznam', 403);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$vehicleId = (int)($input['vehicle_id'] ?? $trip['vehicle_id']);
|
||||
$tripDate = $input['trip_date'] ?? $trip['trip_date'];
|
||||
$startKm = (int)($input['start_km'] ?? $trip['start_km']);
|
||||
$endKm = (int)($input['end_km'] ?? $trip['end_km']);
|
||||
$routeFrom = trim($input['route_from'] ?? $trip['route_from']);
|
||||
$routeTo = trim($input['route_to'] ?? $trip['route_to']);
|
||||
$isBusiness = isset($input['is_business']) ? (int)$input['is_business'] : $trip['is_business'];
|
||||
$notes = trim($input['notes'] ?? $trip['notes'] ?? '');
|
||||
|
||||
if ($endKm <= $startKm) {
|
||||
errorResponse('Konečný stav km musí být větší než počáteční');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE trips
|
||||
SET vehicle_id = ?, trip_date = ?, start_km = ?, end_km = ?,
|
||||
route_from = ?, route_to = ?, is_business = ?, notes = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$vehicleId, $tripDate, $startKm, $endKm, $routeFrom, $routeTo, $isBusiness, $notes ?: null, $id]);
|
||||
|
||||
AuditLog::logUpdate('trips', $id, $trip, $input, 'Upraven záznam jízdy');
|
||||
|
||||
successResponse(null, 'Záznam byl aktualizován');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DELETE Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* DELETE - Delete trip
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleDeleteTrip(PDO $pdo, int $id, int $userId, array $authData): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$trip = $stmt->fetch();
|
||||
|
||||
if (!$trip) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
// Check permission - own trips or trips.admin
|
||||
if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) {
|
||||
errorResponse('Nemáte oprávnění smazat tento záznam', 403);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM trips WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
AuditLog::logDelete('trips', $id, $trip, 'Smazán záznam jízdy');
|
||||
|
||||
successResponse(null, 'Záznam byl smazán');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete vehicle (admin)
|
||||
*/
|
||||
function handleDeleteVehicle(PDO $pdo, int $id): void
|
||||
{
|
||||
if (!$id) {
|
||||
errorResponse('ID je povinné');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM vehicles WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$vehicle = $stmt->fetch();
|
||||
|
||||
if (!$vehicle) {
|
||||
errorResponse('Vozidlo nebylo nalezeno', 404);
|
||||
}
|
||||
|
||||
// Check if vehicle has trips
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM trips WHERE vehicle_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$tripCount = $stmt->fetchColumn();
|
||||
|
||||
if ($tripCount > 0) {
|
||||
errorResponse(
|
||||
"Nelze smazat vozidlo s {$tripCount} záznamy jízd. Nejprve smažte záznamy jízd nebo deaktivujte vozidlo."
|
||||
);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM vehicles WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
AuditLog::logDelete('vehicles', $id, $vehicle, 'Smazáno vozidlo');
|
||||
|
||||
successResponse(null, 'Vozidlo bylo smazáno');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Print Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format date range for display
|
||||
*/
|
||||
function formatPeriodName(string $startDate, string $endDate): string
|
||||
{
|
||||
$start = new DateTime($startDate);
|
||||
$end = new DateTime($endDate);
|
||||
|
||||
// If same month
|
||||
if ($start->format('Y-m') === $end->format('Y-m')) {
|
||||
return getCzechMonthName((int)$start->format('n')) . ' ' . $start->format('Y');
|
||||
}
|
||||
|
||||
// If same year
|
||||
if ($start->format('Y') === $end->format('Y')) {
|
||||
return $start->format('j.n.') . ' - ' . $end->format('j.n.Y');
|
||||
}
|
||||
|
||||
// Different years
|
||||
return $start->format('j.n.Y') . ' - ' . $end->format('j.n.Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Print data for trips (admin)
|
||||
*/
|
||||
function handleGetPrint(PDO $pdo): void
|
||||
{
|
||||
$dateFrom = $_GET['date_from'] ?? null;
|
||||
$dateTo = $_GET['date_to'] ?? null;
|
||||
$vehicleId = isset($_GET['vehicle_id']) && $_GET['vehicle_id'] !== '' ? (int)$_GET['vehicle_id'] : null;
|
||||
$filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null;
|
||||
|
||||
// Default to current month if no dates provided
|
||||
if (!$dateFrom || !$dateTo) {
|
||||
$startDate = date('Y-m-01');
|
||||
$endDate = date('Y-m-t');
|
||||
} else {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
||||
errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)');
|
||||
}
|
||||
$startDate = $dateFrom;
|
||||
$endDate = $dateTo;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||
FROM trips t
|
||||
JOIN vehicles v ON t.vehicle_id = v.id
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.trip_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$startDate, $endDate];
|
||||
|
||||
if ($vehicleId) {
|
||||
$sql .= ' AND t.vehicle_id = ?';
|
||||
$params[] = $vehicleId;
|
||||
}
|
||||
|
||||
if ($filterUserId) {
|
||||
$sql .= ' AND t.user_id = ?';
|
||||
$params[] = $filterUserId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY t.trip_date ASC, t.start_km ASC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$trips = $stmt->fetchAll();
|
||||
|
||||
// Get vehicles for filter
|
||||
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name');
|
||||
$vehicles = $stmt->fetchAll();
|
||||
|
||||
// Get users for filter
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
// Calculate totals
|
||||
$totalDistance = 0;
|
||||
$businessDistance = 0;
|
||||
$privateDistance = 0;
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$totalDistance += $trip['distance'];
|
||||
if ($trip['is_business']) {
|
||||
$businessDistance += $trip['distance'];
|
||||
} else {
|
||||
$privateDistance += $trip['distance'];
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected vehicle/user names for header
|
||||
$selectedVehicleName = '';
|
||||
if ($vehicleId) {
|
||||
$stmt = $pdo->prepare("SELECT CONCAT(spz, ' - ', name) as name FROM vehicles WHERE id = ?");
|
||||
$stmt->execute([$vehicleId]);
|
||||
$v = $stmt->fetch();
|
||||
$selectedVehicleName = $v ? $v['name'] : '';
|
||||
}
|
||||
|
||||
$selectedUserName = '';
|
||||
if ($filterUserId) {
|
||||
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
||||
$stmt->execute([$filterUserId]);
|
||||
$u = $stmt->fetch();
|
||||
$selectedUserName = $u ? $u['name'] : '';
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'trips' => $trips,
|
||||
'vehicles' => $vehicles,
|
||||
'users' => $users,
|
||||
'date_from' => $startDate,
|
||||
'date_to' => $endDate,
|
||||
'period_name' => formatPeriodName($startDate, $endDate),
|
||||
'selected_vehicle' => $vehicleId,
|
||||
'selected_vehicle_name' => $selectedVehicleName,
|
||||
'selected_user' => $filterUserId,
|
||||
'selected_user_name' => $selectedUserName,
|
||||
'totals' => [
|
||||
'total' => $totalDistance,
|
||||
'business' => $businessDistance,
|
||||
'private' => $privateDistance,
|
||||
'count' => count($trips),
|
||||
],
|
||||
]);
|
||||
}
|
||||
342
api/admin/users.php
Normal file
342
api/admin/users.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Users API
|
||||
*
|
||||
* GET /api/admin/users.php - List all users
|
||||
* POST /api/admin/users.php - Create new user
|
||||
* PUT /api/admin/users.php?id=X - Update user
|
||||
* DELETE /api/admin/users.php?id=X - Delete user
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
|
||||
// Set headers
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require authentication
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$userId = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
$currentUserId = $authData['user_id'];
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'users.view');
|
||||
handleGetUser($pdo);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'users.create');
|
||||
handleCreateUser($pdo, $authData);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'users.edit');
|
||||
if (!$userId) {
|
||||
errorResponse('ID uživatele je povinné');
|
||||
}
|
||||
handleUpdateUser($pdo, $userId, $currentUserId, $authData);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'users.delete');
|
||||
if (!$userId) {
|
||||
errorResponse('ID uživatele je povinné');
|
||||
}
|
||||
handleDeleteUser($pdo, $userId, $currentUserId);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Users API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - List all users
|
||||
*/
|
||||
function handleGetUser(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query('
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.email,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.role_id,
|
||||
u.is_active,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
r.name as role_name,
|
||||
r.display_name as role_display_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
ORDER BY u.created_at DESC
|
||||
');
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
// Get roles for dropdown
|
||||
$stmt = $pdo->query('SELECT id, name, display_name FROM roles ORDER BY id');
|
||||
$roles = $stmt->fetchAll();
|
||||
|
||||
successResponse([
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Create new user
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleCreateUser(PDO $pdo, array $authData): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
// Validate required fields
|
||||
$requiredFields = [
|
||||
'username' => 'Uživatelské jméno',
|
||||
'email' => 'E-mail',
|
||||
'password' => 'Heslo',
|
||||
'first_name' => 'Jméno',
|
||||
'last_name' => 'Příjmení',
|
||||
'role_id' => 'Role',
|
||||
];
|
||||
foreach ($requiredFields as $field => $label) {
|
||||
if (empty($input[$field])) {
|
||||
errorResponse("$label je povinné");
|
||||
}
|
||||
}
|
||||
|
||||
$username = sanitize($input['username']);
|
||||
$email = sanitize($input['email']);
|
||||
$password = $input['password'];
|
||||
$firstName = sanitize($input['first_name']);
|
||||
$lastName = sanitize($input['last_name']);
|
||||
$roleId = (int) $input['role_id'];
|
||||
$isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : 1;
|
||||
|
||||
// Non-admin nesmí přiřadit admin roli
|
||||
if (!($authData['user']['is_admin'] ?? false)) {
|
||||
$stmt = $pdo->prepare('SELECT name FROM roles WHERE id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
$targetRole = $stmt->fetch();
|
||||
if ($targetRole && $targetRole['name'] === 'admin') {
|
||||
errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!isValidEmail($email)) {
|
||||
errorResponse('Neplatný formát e-mailu');
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (strlen($password) < 8) {
|
||||
errorResponse('Heslo musí mít alespoň 8 znaků');
|
||||
}
|
||||
|
||||
// Check username uniqueness
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?');
|
||||
$stmt->execute([$username]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('Uživatelské jméno již existuje');
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
|
||||
$stmt->execute([$email]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('E-mail již existuje');
|
||||
}
|
||||
|
||||
// Validate role exists
|
||||
$stmt = $pdo->prepare('SELECT id FROM roles WHERE id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
if (!$stmt->fetch()) {
|
||||
errorResponse('Neplatná role');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
$passwordHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
|
||||
|
||||
// Insert user
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO users (username, email, password_hash, first_name, last_name, role_id, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive]);
|
||||
|
||||
$newUserId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Audit log
|
||||
AuditLog::logCreate('user', $newUserId, [
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'role_id' => $roleId,
|
||||
'is_active' => $isActive,
|
||||
], "Vytvořen uživatel '$username'");
|
||||
|
||||
successResponse(['id' => $newUserId], 'Uživatel byl úspěšně vytvořen');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT - Update user
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void
|
||||
{
|
||||
// Get existing user
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$existingUser = $stmt->fetch();
|
||||
|
||||
if (!$existingUser) {
|
||||
errorResponse('Uživatel nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$username = isset($input['username']) ? sanitize($input['username']) : $existingUser['username'];
|
||||
$email = isset($input['email']) ? sanitize($input['email']) : $existingUser['email'];
|
||||
$firstName = isset($input['first_name']) ? sanitize($input['first_name']) : $existingUser['first_name'];
|
||||
$lastName = isset($input['last_name']) ? sanitize($input['last_name']) : $existingUser['last_name'];
|
||||
$roleId = isset($input['role_id']) ? (int) $input['role_id'] : $existingUser['role_id'];
|
||||
$isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : $existingUser['is_active'];
|
||||
|
||||
// Validate email format
|
||||
if (!isValidEmail($email)) {
|
||||
errorResponse('Neplatný formát e-mailu');
|
||||
}
|
||||
|
||||
// Check username uniqueness (excluding current user)
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ? AND id != ?');
|
||||
$stmt->execute([$username, $userId]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('Uživatelské jméno již existuje');
|
||||
}
|
||||
|
||||
// Check email uniqueness (excluding current user)
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND id != ?');
|
||||
$stmt->execute([$email, $userId]);
|
||||
if ($stmt->fetch()) {
|
||||
errorResponse('E-mail již existuje');
|
||||
}
|
||||
|
||||
// Validate role exists
|
||||
$stmt = $pdo->prepare('SELECT id, name FROM roles WHERE id = ?');
|
||||
$stmt->execute([$roleId]);
|
||||
$targetRole = $stmt->fetch();
|
||||
if (!$targetRole) {
|
||||
errorResponse('Neplatná role');
|
||||
}
|
||||
|
||||
// Non-admin nesmí přiřadit admin roli
|
||||
if (!($authData['user']['is_admin'] ?? false) && $targetRole['name'] === 'admin') {
|
||||
errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403);
|
||||
}
|
||||
|
||||
// Update user
|
||||
if (!empty($input['password'])) {
|
||||
// Validate password length
|
||||
if (strlen($input['password']) < 8) {
|
||||
errorResponse('Heslo musí mít alespoň 8 znaků');
|
||||
}
|
||||
|
||||
$passwordHash = password_hash($input['password'], PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE users
|
||||
SET username = ?, email = ?, password_hash = ?,
|
||||
first_name = ?, last_name = ?, role_id = ?,
|
||||
is_active = ?, password_changed_at = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive, $userId]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE users
|
||||
SET username = ?, email = ?, first_name = ?, last_name = ?, role_id = ?, is_active = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$username, $email, $firstName, $lastName, $roleId, $isActive, $userId]);
|
||||
}
|
||||
|
||||
// Note: With JWT, user data is in the token - no session to update
|
||||
|
||||
// Audit log
|
||||
AuditLog::logUpdate('user', $userId, [
|
||||
'username' => $existingUser['username'],
|
||||
'email' => $existingUser['email'],
|
||||
'first_name' => $existingUser['first_name'],
|
||||
'last_name' => $existingUser['last_name'],
|
||||
'role_id' => $existingUser['role_id'],
|
||||
'is_active' => $existingUser['is_active'],
|
||||
], [
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'role_id' => $roleId,
|
||||
'is_active' => $isActive,
|
||||
], "Upraven uživatel '$username'");
|
||||
|
||||
successResponse(null, 'Uživatel byl úspěšně aktualizován');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete user
|
||||
*/
|
||||
function handleDeleteUser(PDO $pdo, int $userId, int $currentUserId): void
|
||||
{
|
||||
// Prevent self-deletion
|
||||
if ($userId === $currentUserId) {
|
||||
errorResponse('Nemůžete smazat svůj vlastní účet');
|
||||
}
|
||||
|
||||
// Get user for audit log
|
||||
$stmt = $pdo->prepare('SELECT username FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user) {
|
||||
errorResponse('Uživatel nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
// Delete related records first (refresh tokens for JWT auth)
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
// Delete user
|
||||
$stmt = $pdo->prepare('DELETE FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
// Audit log
|
||||
AuditLog::logDelete('user', $userId, ['username' => $user['username']], "Smazán uživatel '{$user['username']}'");
|
||||
|
||||
successResponse(null, 'Uživatel byl úspěšně smazán');
|
||||
}
|
||||
405
api/config.php
Normal file
405
api/config.php
Normal file
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - API Configuration
|
||||
*
|
||||
* Database and application configuration
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Load .env file
|
||||
function loadEnv(string $path): bool
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('=', $line, 2);
|
||||
if (count($parts) !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim($parts[0]);
|
||||
$value = trim($parts[1]);
|
||||
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||
) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
|
||||
$_ENV[$name] = $value;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Load .env from api directory
|
||||
loadEnv(__DIR__ . '/.env');
|
||||
|
||||
// Helper function to get env value with default
|
||||
function env(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_ENV[$key] ?? $default;
|
||||
}
|
||||
|
||||
// Environment
|
||||
define('APP_ENV', env('APP_ENV', 'production'));
|
||||
define('DEBUG_MODE', APP_ENV === 'local');
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
} else {
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', 0);
|
||||
}
|
||||
|
||||
// Database configuration
|
||||
define('DB_HOST', env('DB_HOST', 'localhost'));
|
||||
define('DB_NAME', env('DB_NAME', ''));
|
||||
define('DB_USER', env('DB_USER', ''));
|
||||
define('DB_PASS', env('DB_PASS', ''));
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
// Security configuration
|
||||
define('MAX_LOGIN_ATTEMPTS', 5);
|
||||
define('LOCKOUT_MINUTES', 15);
|
||||
define('BCRYPT_COST', 12);
|
||||
|
||||
// CORS - aktualizuj po nasazeni na subdomenu
|
||||
define('CORS_ALLOWED_ORIGINS', [
|
||||
'http://www.boha-automation.cz',
|
||||
'https://www.boha-automation.cz',
|
||||
]);
|
||||
|
||||
// Paths
|
||||
define('API_ROOT', __DIR__);
|
||||
define('INCLUDES_PATH', API_ROOT . '/includes');
|
||||
|
||||
// Rate limiting
|
||||
define('RATE_LIMIT_STORAGE_PATH', __DIR__ . '/rate_limits');
|
||||
|
||||
|
||||
/**
|
||||
* Get PDO database connection (singleton pattern)
|
||||
*
|
||||
* @return PDO Database connection instance
|
||||
* @throws PDOException If connection fails
|
||||
*/
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
|
||||
if ($pdo === null) {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;dbname=%s;charset=%s',
|
||||
DB_HOST,
|
||||
DB_NAME,
|
||||
DB_CHARSET
|
||||
);
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . DB_CHARSET,
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
|
||||
} catch (PDOException $e) {
|
||||
if (DEBUG_MODE) {
|
||||
throw $e;
|
||||
}
|
||||
error_log('Database connection failed: ' . $e->getMessage());
|
||||
throw new PDOException('Database connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set CORS headers for API responses
|
||||
*/
|
||||
function setCorsHeaders(): void
|
||||
{
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
|
||||
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
} elseif (DEBUG_MODE && str_starts_with($origin, 'http://127.0.0.1:')) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
// Neznamy origin = zadny CORS header
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
|
||||
// Handle preflight requests
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response and exit
|
||||
*
|
||||
* @param mixed $data Data to send
|
||||
* @param int $statusCode HTTP status code
|
||||
*/
|
||||
function jsonResponse($data, int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $statusCode HTTP status code
|
||||
*/
|
||||
function errorResponse(string $message, int $statusCode = 400): void
|
||||
{
|
||||
jsonResponse(['success' => false, 'error' => $message], $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send success response
|
||||
*
|
||||
* @param mixed $data Data to include
|
||||
* @param string $message Optional message
|
||||
*/
|
||||
function successResponse($data = null, string $message = ''): void
|
||||
{
|
||||
$response = ['success' => true];
|
||||
if ($message) {
|
||||
$response['message'] = $message;
|
||||
}
|
||||
if ($data !== null) {
|
||||
$response['data'] = $data;
|
||||
}
|
||||
jsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JSON request body
|
||||
*
|
||||
* @return array<string, mixed> Decoded JSON data
|
||||
*/
|
||||
function getJsonInput(): array
|
||||
{
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize string input
|
||||
*
|
||||
* @param string $input Input string
|
||||
* @return string Sanitized string
|
||||
*/
|
||||
function sanitize(string $input): string
|
||||
{
|
||||
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*
|
||||
* @param string $email Email to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
function isValidEmail(string $email): bool
|
||||
{
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize month parameter (YYYY-MM format)
|
||||
*/
|
||||
function validateMonth(string $param = 'month'): string
|
||||
{
|
||||
$month = $_GET[$param] ?? date('Y-m');
|
||||
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
|
||||
$month = date('Y-m');
|
||||
}
|
||||
return $month;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*
|
||||
* Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP).
|
||||
* If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future,
|
||||
* update this function to trust specific proxy headers only from known proxy IPs.
|
||||
*
|
||||
* @return string IP address
|
||||
*/
|
||||
function getClientIp(): string
|
||||
{
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set security headers for API responses
|
||||
*
|
||||
* Sets standard security headers to protect against common web vulnerabilities:
|
||||
* - X-Content-Type-Options: Prevents MIME type sniffing
|
||||
* - X-Frame-Options: Prevents clickjacking attacks
|
||||
* - X-XSS-Protection: Enables browser XSS filter
|
||||
* - Referrer-Policy: Controls referrer information sent with requests
|
||||
*
|
||||
* Note: Content-Security-Policy is not set here as it may interfere with the React frontend
|
||||
*/
|
||||
function setSecurityHeaders(): void
|
||||
{
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
if (!DEBUG_MODE && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set no-cache headers
|
||||
*
|
||||
* Prevents browser caching for sensitive endpoints
|
||||
*/
|
||||
function setNoCacheHeaders(): void
|
||||
{
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Cache-Control: post-check=0, pre-check=0', false);
|
||||
header('Pragma: no-cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sdilene generovani cisel pro objednavky a projekty (spolecny ciselny prostor)
|
||||
*/
|
||||
function generateSharedNumber(PDO $pdo): string
|
||||
{
|
||||
$yy = date('y');
|
||||
|
||||
$settings = $pdo->query('SELECT order_type_code FROM company_settings LIMIT 1')->fetch();
|
||||
$typeCode = ($settings && !empty($settings['order_type_code'])) ? $settings['order_type_code'] : '71';
|
||||
|
||||
$prefix = $yy . $typeCode;
|
||||
$prefixLen = strlen($prefix);
|
||||
$likePattern = $prefix . '%';
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT COALESCE(MAX(seq), 0) FROM (
|
||||
SELECT CAST(SUBSTRING(order_number, ? + 1) AS UNSIGNED) AS seq
|
||||
FROM orders WHERE order_number LIKE ?
|
||||
UNION ALL
|
||||
SELECT CAST(SUBSTRING(project_number, ? + 1) AS UNSIGNED) AS seq
|
||||
FROM projects WHERE project_number LIKE ?
|
||||
) combined
|
||||
');
|
||||
$stmt->execute([$prefixLen, $likePattern, $prefixLen, $likePattern]);
|
||||
$max = (int) $stmt->fetchColumn();
|
||||
|
||||
return sprintf('%s%s%04d', $yy, $typeCode, $max + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions for a user by their ID
|
||||
* Cached per-request via static variable
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function getUserPermissions(int $userId): array
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (isset($cache[$userId])) {
|
||||
return $cache[$userId];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Check if user has admin role (superuser bypass)
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT r.name FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = ?
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
$role = $stmt->fetch();
|
||||
|
||||
if ($role && $role['name'] === 'admin') {
|
||||
// Admin gets all permissions
|
||||
$stmt = $pdo->query('SELECT name FROM permissions');
|
||||
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
return $cache[$userId];
|
||||
}
|
||||
|
||||
// Regular user: get permissions via role_permissions
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT p.name
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON p.id = rp.permission_id
|
||||
JOIN users u ON u.role_id = rp.role_id
|
||||
WHERE u.id = ?
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
return $cache[$userId];
|
||||
} catch (PDOException $e) {
|
||||
error_log('getUserPermissions error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific permission, return 403 if denied
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function requirePermission(array $authData, string $permission): void
|
||||
{
|
||||
// Admin superuser bypass
|
||||
if ($authData['user']['is_admin'] ?? false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissions = getUserPermissions($authData['user_id']);
|
||||
if (!in_array($permission, $permissions)) {
|
||||
errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission (returns bool)
|
||||
*
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function hasPermission(array $authData, string $permission): bool
|
||||
{
|
||||
if ($authData['user']['is_admin'] ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$permissions = getUserPermissions($authData['user_id']);
|
||||
return in_array($permission, $permissions);
|
||||
}
|
||||
968
api/includes/AttendanceAdmin.php
Normal file
968
api/includes/AttendanceAdmin.php
Normal file
@@ -0,0 +1,968 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Attendance admin handler functions
|
||||
* Requires: AttendanceHelpers.php, CzechHolidays.php, AuditLog.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function handleGetAdmin(PDO $pdo): void
|
||||
{
|
||||
$month = validateMonth();
|
||||
$filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
||||
|
||||
$year = (int)substr($month, 0, 4);
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$sql = "
|
||||
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$startDate, $endDate];
|
||||
|
||||
if ($filterUserId) {
|
||||
$sql .= ' AND a.user_id = ?';
|
||||
$params[] = $filterUserId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.shift_date DESC, a.arrival_time DESC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$records = $stmt->fetchAll();
|
||||
|
||||
enrichRecordsWithProjectLogs($pdo, $records);
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$userTotals = calculateUserTotals($records);
|
||||
$leaveBalances = getLeaveBalancesBatch(
|
||||
$pdo,
|
||||
array_keys($userTotals),
|
||||
$year
|
||||
);
|
||||
|
||||
$monthNum = (int)substr($month, 5, 2);
|
||||
addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum);
|
||||
|
||||
successResponse([
|
||||
'records' => $records,
|
||||
'users' => $users,
|
||||
'month' => $month,
|
||||
'user_totals' => $userTotals,
|
||||
'leave_balances' => $leaveBalances,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetBalances(PDO $pdo): void
|
||||
{
|
||||
$year = (int)($_GET['year'] ?? date('Y'));
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$userIds = array_column($users, 'id');
|
||||
$batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year);
|
||||
|
||||
$balances = [];
|
||||
foreach ($users as $user) {
|
||||
$balances[$user['id']] = array_merge(
|
||||
['name' => $user['name']],
|
||||
$batchBalances[$user['id']]
|
||||
);
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'users' => $users,
|
||||
'balances' => $balances,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetWorkFund(PDO $pdo): void
|
||||
{
|
||||
$year = (int)($_GET['year'] ?? date('Y'));
|
||||
$currentYear = (int)date('Y');
|
||||
$currentMonth = (int)date('m');
|
||||
|
||||
$maxMonth = ($year < $currentYear) ? 12 : (($year === $currentYear) ? $currentMonth : 0);
|
||||
|
||||
if ($maxMonth === 0) {
|
||||
successResponse(['months' => [], 'holidays' => [], 'year' => $year]);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$startDate = sprintf('%04d-01-01', $year);
|
||||
$endDate = sprintf('%04d-%02d-%02d', $year, $maxMonth, cal_days_in_month(CAL_GREGORIAN, $maxMonth, $year));
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE shift_date BETWEEN ? AND ? ORDER BY shift_date');
|
||||
$stmt->execute([$startDate, $endDate]);
|
||||
$allRecords = $stmt->fetchAll();
|
||||
|
||||
$monthUserData = [];
|
||||
foreach ($allRecords as $rec) {
|
||||
$m = (int)date('m', strtotime($rec['shift_date']));
|
||||
$uid = $rec['user_id'];
|
||||
if (!isset($monthUserData[$m][$uid])) {
|
||||
$monthUserData[$m][$uid] = ['minutes' => 0, 'vacation' => 0, 'sick' => 0, 'holiday' => 0, 'unpaid' => 0];
|
||||
}
|
||||
$lt = $rec['leave_type'] ?? 'work';
|
||||
$lh = (float)($rec['leave_hours'] ?? 0);
|
||||
if ($lt === 'work') {
|
||||
if ($rec['departure_time']) {
|
||||
$monthUserData[$m][$uid]['minutes'] += calculateWorkMinutes($rec);
|
||||
}
|
||||
} elseif ($lt === 'vacation') {
|
||||
$monthUserData[$m][$uid]['vacation'] += $lh;
|
||||
} elseif ($lt === 'sick') {
|
||||
$monthUserData[$m][$uid]['sick'] += $lh;
|
||||
} elseif ($lt === 'holiday') {
|
||||
$monthUserData[$m][$uid]['holiday'] += $lh;
|
||||
} elseif ($lt === 'unpaid') {
|
||||
$monthUserData[$m][$uid]['unpaid'] += $lh;
|
||||
}
|
||||
}
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= $maxMonth; $m++) {
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $m);
|
||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $m);
|
||||
$monthName = getCzechMonthName($m);
|
||||
|
||||
$userStats = [];
|
||||
foreach ($users as $user) {
|
||||
$uid = $user['id'];
|
||||
$ud = $monthUserData[$m][$uid] ?? [
|
||||
'minutes' => 0, 'vacation' => 0, 'sick' => 0,
|
||||
'holiday' => 0, 'unpaid' => 0,
|
||||
];
|
||||
$worked = round($ud['minutes'] / 60, 1);
|
||||
$leave = $ud['vacation'] + $ud['sick'];
|
||||
$covered = $worked + $leave;
|
||||
$missing = max(0, round($fund - $covered, 1));
|
||||
$overtime = max(0, round($covered - $fund, 1));
|
||||
|
||||
$userStats[$uid] = [
|
||||
'name' => $user['name'],
|
||||
'worked' => $worked,
|
||||
'vacation' => $ud['vacation'],
|
||||
'sick' => $ud['sick'],
|
||||
'holiday' => $ud['holiday'],
|
||||
'unpaid' => $ud['unpaid'],
|
||||
'leave' => $leave,
|
||||
'covered' => $covered,
|
||||
'missing' => $missing,
|
||||
'overtime' => $overtime,
|
||||
];
|
||||
}
|
||||
|
||||
$months[$m] = [
|
||||
'month' => $m,
|
||||
'month_name' => $monthName,
|
||||
'fund' => $fund,
|
||||
'business_days' => $businessDays,
|
||||
'users' => $userStats,
|
||||
];
|
||||
}
|
||||
|
||||
$userIds = array_column($users, 'id');
|
||||
$batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year);
|
||||
$balances = [];
|
||||
foreach ($users as $user) {
|
||||
$balances[$user['id']] = array_merge(
|
||||
['name' => $user['name']],
|
||||
$batchBalances[$user['id']]
|
||||
);
|
||||
}
|
||||
|
||||
$holidays = CzechHolidays::getHolidays($year);
|
||||
|
||||
successResponse([
|
||||
'months' => $months,
|
||||
'balances' => $balances,
|
||||
'holidays' => $holidays,
|
||||
'users' => $users,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetLocation(PDO $pdo, int $recordId): void
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.id = ?
|
||||
");
|
||||
$stmt->execute([$recordId]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
successResponse(['record' => $record]);
|
||||
}
|
||||
|
||||
function handleGetUsers(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
successResponse(['users' => $users]);
|
||||
}
|
||||
|
||||
function handleCreateAttendance(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$userId = (int)($input['user_id'] ?? 0);
|
||||
$shiftDate = $input['shift_date'] ?? '';
|
||||
$leaveType = $input['leave_type'] ?? 'work';
|
||||
$leaveHours = $input['leave_hours'] ?? null;
|
||||
$notes = $input['notes'] ?? null;
|
||||
|
||||
if (!$userId || !$shiftDate) {
|
||||
errorResponse('Vyplňte zaměstnance a datum směny');
|
||||
}
|
||||
|
||||
if ($leaveType !== 'work') {
|
||||
$leaveHours = $leaveHours ?: 8;
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$userId, $shiftDate, $leaveType, $leaveHours, $notes]);
|
||||
|
||||
updateLeaveBalance($pdo, $userId, $shiftDate, $leaveType, (float)$leaveHours);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
$arrivalDate = $input['arrival_date'] ?? $shiftDate;
|
||||
$arrivalTime = $input['arrival_time'] ?? null;
|
||||
$breakStartDate = $input['break_start_date'] ?? null;
|
||||
$breakStartTime = $input['break_start_time'] ?? null;
|
||||
$breakEndDate = $input['break_end_date'] ?? null;
|
||||
$breakEndTime = $input['break_end_time'] ?? null;
|
||||
$departureDate = $input['departure_date'] ?? null;
|
||||
$departureTime = $input['departure_time'] ?? null;
|
||||
/** @var mixed $rawProjectId */
|
||||
$rawProjectId = $input['project_id'] ?? null;
|
||||
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||
? (int)$rawProjectId
|
||||
: null;
|
||||
|
||||
$arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null;
|
||||
$breakStart = ($breakStartDate && $breakStartTime) ? "{$breakStartDate} {$breakStartTime}:00" : null;
|
||||
$breakEnd = ($breakEndDate && $breakEndTime) ? "{$breakEndDate} {$breakEndTime}:00" : null;
|
||||
$departure = ($departureDate && $departureTime) ? "{$departureDate} {$departureTime}:00" : null;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO attendance
|
||||
(user_id, shift_date, arrival_time, break_start,
|
||||
break_end, departure_time, leave_type, notes, project_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'work', ?, ?)
|
||||
");
|
||||
$stmt->execute([$userId, $shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId]);
|
||||
}
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
$projectLogs = $input['project_logs'] ?? [];
|
||||
if (!empty($projectLogs) && $leaveType === 'work') {
|
||||
$logStmt = $pdo->prepare(
|
||||
'INSERT INTO attendance_project_logs
|
||||
(attendance_id, project_id, hours, minutes)
|
||||
VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($projectLogs as $log) {
|
||||
$pid = (int)($log['project_id'] ?? 0);
|
||||
if (!$pid) {
|
||||
continue;
|
||||
}
|
||||
$h = (int)($log['hours'] ?? 0);
|
||||
$m = (int)($log['minutes'] ?? 0);
|
||||
if ($h === 0 && $m === 0) {
|
||||
continue;
|
||||
}
|
||||
$logStmt->execute([$newId, $pid, $h, $m]);
|
||||
}
|
||||
}
|
||||
|
||||
AuditLog::logCreate('attendance', $newId, $input, 'Admin vytvořil záznam docházky');
|
||||
|
||||
successResponse(['id' => $newId], 'Záznam byl vytvořen');
|
||||
}
|
||||
|
||||
function handleBulkAttendance(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$monthStr = $input['month'] ?? '';
|
||||
$userIds = $input['user_ids'] ?? [];
|
||||
$arrivalTime = trim($input['arrival_time'] ?? '08:00');
|
||||
$departureTime = trim($input['departure_time'] ?? '16:30');
|
||||
$breakStartTime = trim($input['break_start_time'] ?? '12:00');
|
||||
$breakEndTime = trim($input['break_end_time'] ?? '12:30');
|
||||
|
||||
if (!$monthStr || !preg_match('/^\d{4}-\d{2}$/', $monthStr)) {
|
||||
errorResponse('Měsíc je povinný (formát YYYY-MM)');
|
||||
}
|
||||
|
||||
if (empty($userIds) || !is_array($userIds)) {
|
||||
errorResponse('Vyberte alespoň jednoho zaměstnance');
|
||||
}
|
||||
|
||||
$year = (int)substr($monthStr, 0, 4);
|
||||
$month = (int)substr($monthStr, 5, 2);
|
||||
|
||||
$holidays = CzechHolidays::getHolidays($year);
|
||||
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
|
||||
// Batch fetch existing records (eliminates N*M queries)
|
||||
$dateFrom = sprintf('%04d-%02d-01', $year, $month);
|
||||
$dateTo = sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth);
|
||||
$userIdInts = array_map('intval', $userIds);
|
||||
$placeholders = implode(',', array_fill(0, count($userIdInts), '?'));
|
||||
$existStmt = $pdo->prepare("
|
||||
SELECT user_id, shift_date FROM attendance
|
||||
WHERE user_id IN ($placeholders) AND shift_date BETWEEN ? AND ?
|
||||
");
|
||||
$existParams = array_merge($userIdInts, [$dateFrom, $dateTo]);
|
||||
$existStmt->execute($existParams);
|
||||
$existingRecords = [];
|
||||
foreach ($existStmt->fetchAll() as $row) {
|
||||
$existingRecords[$row['user_id'] . ':' . $row['shift_date']] = true;
|
||||
}
|
||||
|
||||
$holidayStmt = $pdo->prepare("
|
||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||
VALUES (?, ?, 'holiday', 8, 'Státní svátek')
|
||||
");
|
||||
$workStmt = $pdo->prepare('
|
||||
INSERT INTO attendance (user_id, shift_date, arrival_time, departure_time, break_start, break_end)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
foreach ($userIdInts as $userId) {
|
||||
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
$dayOfWeek = (int)date('N', strtotime($date));
|
||||
|
||||
if ($dayOfWeek > 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isHoliday = in_array($date, $holidays, true);
|
||||
|
||||
if (isset($existingRecords[$userId . ':' . $date])) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isHoliday) {
|
||||
$holidayStmt->execute([$userId, $date]);
|
||||
} else {
|
||||
$arrival = $date . ' ' . $arrivalTime . ':00';
|
||||
$departure = $date . ' ' . $departureTime . ':00';
|
||||
$breakStart = $date . ' ' . $breakStartTime . ':00';
|
||||
$breakEnd = $date . ' ' . $breakEndTime . ':00';
|
||||
$workStmt->execute([$userId, $date, $arrival, $departure, $breakStart, $breakEnd]);
|
||||
}
|
||||
$inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
AuditLog::logCreate('attendance', 0, [
|
||||
'month' => $monthStr,
|
||||
'user_ids' => $userIds,
|
||||
'inserted' => $inserted,
|
||||
'skipped' => $skipped,
|
||||
], "Admin hromadně vytvořil $inserted záznamů docházky pro měsíc $monthStr");
|
||||
|
||||
$msg = "Vytvořeno $inserted záznamů";
|
||||
if ($skipped > 0) {
|
||||
$msg .= " ($skipped přeskočeno — již existují)";
|
||||
}
|
||||
|
||||
successResponse(['inserted' => $inserted, 'skipped' => $skipped], $msg);
|
||||
}
|
||||
|
||||
function handleUpdateBalance(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$userId = (int)($input['user_id'] ?? 0);
|
||||
$year = (int)($input['year'] ?? date('Y'));
|
||||
$actionType = $input['action_type'] ?? 'edit';
|
||||
|
||||
if (!$userId) {
|
||||
errorResponse('ID uživatele je povinné');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||
$stmt->execute([$userId, $year]);
|
||||
$exists = $stmt->fetch();
|
||||
|
||||
if ($actionType === 'reset') {
|
||||
if ($exists) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE leave_balances
|
||||
SET vacation_used = 0, sick_used = 0
|
||||
WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$userId, $year]);
|
||||
}
|
||||
successResponse(null, 'Bilance byla resetována');
|
||||
} else {
|
||||
$vacationTotal = (float)($input['vacation_total'] ?? 160);
|
||||
$vacationUsed = (float)($input['vacation_used'] ?? 0);
|
||||
$sickUsed = (float)($input['sick_used'] ?? 0);
|
||||
|
||||
if ($exists) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE leave_balances
|
||||
SET vacation_total = ?, vacation_used = ?, sick_used = ?
|
||||
WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$vacationTotal, $vacationUsed, $sickUsed, $userId, $year]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO leave_balances
|
||||
(user_id, year, vacation_total, vacation_used, sick_used)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$userId, $year, $vacationTotal, $vacationUsed, $sickUsed]);
|
||||
}
|
||||
successResponse(null, 'Bilance byla aktualizována');
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateAttendance(PDO $pdo, int $recordId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$shiftDate = $input['shift_date'] ?? $record['shift_date'];
|
||||
$leaveType = $input['leave_type'] ?? 'work';
|
||||
$leaveHours = $input['leave_hours'] ?? null;
|
||||
$notes = $input['notes'] ?? null;
|
||||
|
||||
$oldLeaveType = $record['leave_type'] ?? 'work';
|
||||
$oldLeaveHours = $record['leave_hours'] ?? 0;
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($leaveType !== 'work') {
|
||||
$leaveHours = $leaveHours ?: 8;
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE attendance
|
||||
SET shift_date = ?, leave_type = ?, leave_hours = ?,
|
||||
arrival_time = NULL, break_start = NULL,
|
||||
break_end = NULL, departure_time = NULL, notes = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$shiftDate, $leaveType, $leaveHours, $notes, $recordId]);
|
||||
|
||||
if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) {
|
||||
updateLeaveBalance(
|
||||
$pdo,
|
||||
(int)$record['user_id'],
|
||||
$record['shift_date'],
|
||||
$oldLeaveType,
|
||||
-(float)$oldLeaveHours
|
||||
);
|
||||
}
|
||||
updateLeaveBalance(
|
||||
$pdo,
|
||||
(int)$record['user_id'],
|
||||
$shiftDate,
|
||||
$leaveType,
|
||||
(float)$leaveHours
|
||||
);
|
||||
} else {
|
||||
$arrivalDate = $input['arrival_date'] ?? $shiftDate;
|
||||
$arrivalTime = $input['arrival_time'] ?? null;
|
||||
$breakStartDate = $input['break_start_date'] ?? null;
|
||||
$breakStartTime = $input['break_start_time'] ?? null;
|
||||
$breakEndDate = $input['break_end_date'] ?? null;
|
||||
$breakEndTime = $input['break_end_time'] ?? null;
|
||||
$departureDate = $input['departure_date'] ?? null;
|
||||
$departureTime = $input['departure_time'] ?? null;
|
||||
/** @var mixed $rawProjectId */
|
||||
$rawProjectId = $input['project_id'] ?? null;
|
||||
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||
? (int)$rawProjectId
|
||||
: null;
|
||||
|
||||
$arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null;
|
||||
$breakStart = $breakStartTime ? "{$breakStartDate} {$breakStartTime}:00" : null;
|
||||
$breakEnd = $breakEndTime ? "{$breakEndDate} {$breakEndTime}:00" : null;
|
||||
$departure = $departureTime ? "{$departureDate} {$departureTime}:00" : null;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE attendance
|
||||
SET shift_date = ?, arrival_time = ?, break_start = ?,
|
||||
break_end = ?, departure_time = ?,
|
||||
leave_type = 'work', leave_hours = NULL,
|
||||
notes = ?, project_id = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId, $recordId]);
|
||||
|
||||
if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) {
|
||||
updateLeaveBalance(
|
||||
$pdo,
|
||||
(int)$record['user_id'],
|
||||
$record['shift_date'],
|
||||
$oldLeaveType,
|
||||
-(float)$oldLeaveHours
|
||||
);
|
||||
}
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$projectLogs = $input['project_logs'] ?? null;
|
||||
if ($projectLogs !== null) {
|
||||
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
|
||||
if (!empty($projectLogs) && ($input['leave_type'] ?? 'work') === 'work') {
|
||||
$logStmt = $pdo->prepare(
|
||||
'INSERT INTO attendance_project_logs
|
||||
(attendance_id, project_id, hours, minutes)
|
||||
VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($projectLogs as $log) {
|
||||
$pid = (int)($log['project_id'] ?? 0);
|
||||
if (!$pid) {
|
||||
continue;
|
||||
}
|
||||
$h = (int)($log['hours'] ?? 0);
|
||||
$m = (int)($log['minutes'] ?? 0);
|
||||
if ($h === 0 && $m === 0) {
|
||||
continue;
|
||||
}
|
||||
$logStmt->execute([$recordId, $pid, $h, $m]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky');
|
||||
|
||||
successResponse(null, 'Záznam byl aktualizován');
|
||||
}
|
||||
|
||||
function handleDeleteAttendance(PDO $pdo, int $recordId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$leaveType = $record['leave_type'] ?? 'work';
|
||||
$leaveHours = $record['leave_hours'] ?? 0;
|
||||
if ($leaveType !== 'work' && $leaveHours > 0) {
|
||||
updateLeaveBalance($pdo, (int)$record['user_id'], $record['shift_date'], $leaveType, -(float)$leaveHours);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
|
||||
AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky');
|
||||
|
||||
successResponse(null, 'Záznam byl smazán');
|
||||
}
|
||||
|
||||
function handleGetProjectReport(PDO $pdo): void
|
||||
{
|
||||
$yearParam = $_GET['year'] ?? null;
|
||||
$monthParam = $_GET['month'] ?? null;
|
||||
|
||||
if ($yearParam) {
|
||||
$yearInt = (int)$yearParam;
|
||||
$currentYear = (int)date('Y');
|
||||
$currentMonth = (int)date('m');
|
||||
$maxMonth = ($yearInt < $currentYear) ? 12 : (($yearInt === $currentYear) ? $currentMonth : 0);
|
||||
|
||||
if ($maxMonth === 0) {
|
||||
successResponse(['months' => [], 'year' => $yearInt]);
|
||||
return;
|
||||
}
|
||||
|
||||
$startDate = sprintf('%04d-01-01', $yearInt);
|
||||
$lastDay = cal_days_in_month(CAL_GREGORIAN, $maxMonth, $yearInt);
|
||||
$endDate = sprintf('%04d-%02d-%02d', $yearInt, $maxMonth, $lastDay);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT a.user_id, a.id as attendance_id, a.shift_date,
|
||||
a.arrival_time, a.departure_time,
|
||||
a.break_start, a.break_end,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
AND a.departure_time IS NOT NULL
|
||||
AND (a.leave_type IS NULL OR a.leave_type = 'work')
|
||||
ORDER BY u.last_name ASC
|
||||
");
|
||||
$stmt->execute([$startDate, $endDate]);
|
||||
$workRecords = $stmt->fetchAll();
|
||||
|
||||
$totalWork = [];
|
||||
$attendanceIds = [];
|
||||
foreach ($workRecords as $rec) {
|
||||
$m = (int)date('m', strtotime($rec['shift_date']));
|
||||
$uid = $rec['user_id'];
|
||||
$attendanceIds[] = $rec['attendance_id'];
|
||||
if (!isset($totalWork[$m][$uid])) {
|
||||
$totalWork[$m][$uid] = ['name' => $rec['user_name'], 'minutes' => 0];
|
||||
}
|
||||
$totalWork[$m][$uid]['minutes'] += calculateWorkMinutes($rec);
|
||||
}
|
||||
|
||||
$loggedMinutes = [];
|
||||
$monthData = [];
|
||||
$projectIds = [];
|
||||
|
||||
if (!empty($attendanceIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($attendanceIds), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id, a.shift_date
|
||||
FROM attendance_project_logs pl
|
||||
JOIN attendance a ON pl.attendance_id = a.id
|
||||
WHERE pl.attendance_id IN ($placeholders)
|
||||
AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL)
|
||||
");
|
||||
$stmt->execute($attendanceIds);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$m = (int)date('m', strtotime($log['shift_date']));
|
||||
$pid = (int)$log['project_id'];
|
||||
$uid = $log['user_id'];
|
||||
$projectIds[$pid] = true;
|
||||
if ($log['hours'] !== null) {
|
||||
$minutes = (int)$log['hours'] * 60 + (int)$log['mins'];
|
||||
} else {
|
||||
$minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60);
|
||||
}
|
||||
|
||||
if (!isset($monthData[$m][$pid][$uid])) {
|
||||
$monthData[$m][$pid][$uid] = ['minutes' => 0];
|
||||
}
|
||||
$monthData[$m][$pid][$uid]['minutes'] += $minutes;
|
||||
|
||||
if (!isset($loggedMinutes[$m][$uid])) {
|
||||
$loggedMinutes[$m][$uid] = 0;
|
||||
}
|
||||
$loggedMinutes[$m][$uid] += $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// "Bez projektu" = total work - logged
|
||||
foreach ($totalWork as $m => $users) {
|
||||
foreach ($users as $uid => $ud) {
|
||||
$logged = $loggedMinutes[$m][$uid] ?? 0;
|
||||
$unlogged = $ud['minutes'] - $logged;
|
||||
if ($unlogged > 1) {
|
||||
if (!isset($monthData[$m][0][$uid])) {
|
||||
$monthData[$m][0][$uid] = ['minutes' => 0];
|
||||
}
|
||||
$monthData[$m][0][$uid]['minutes'] += $unlogged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$projectMap = [];
|
||||
if (!empty($projectIds)) {
|
||||
try {
|
||||
$offersPdo = db();
|
||||
$ids = array_keys($projectIds);
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt2 = $offersPdo->prepare(
|
||||
"SELECT id, project_number, name
|
||||
FROM projects WHERE id IN ($placeholders)"
|
||||
);
|
||||
$stmt2->execute($ids);
|
||||
foreach ($stmt2->fetchAll() as $p) {
|
||||
$projectMap[$p['id']] = $p;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to fetch project names for yearly report: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$userNames = [];
|
||||
foreach ($totalWork as $m => $users) {
|
||||
foreach ($users as $uid => $ud) {
|
||||
$userNames[$uid] = $ud['name'];
|
||||
}
|
||||
}
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= $maxMonth; $m++) {
|
||||
$projects = [];
|
||||
if (isset($monthData[$m])) {
|
||||
foreach ($monthData[$m] as $pid => $usersData) {
|
||||
$proj = $pid ? ($projectMap[$pid] ?? null) : null;
|
||||
$users = [];
|
||||
$projectTotal = 0;
|
||||
foreach ($usersData as $uid => $ud) {
|
||||
$hours = round($ud['minutes'] / 60, 1);
|
||||
$projectTotal += $hours;
|
||||
$users[] = [
|
||||
'user_id' => $uid,
|
||||
'user_name' => $userNames[$uid] ?? "User #$uid",
|
||||
'hours' => $hours,
|
||||
];
|
||||
}
|
||||
usort($users, fn ($a, $b) => $b['hours'] <=> $a['hours']);
|
||||
$projects[] = [
|
||||
'project_id' => $pid ?: null,
|
||||
'project_number' => $proj ? $proj['project_number'] : null,
|
||||
'project_name' => $proj ? $proj['name'] : null,
|
||||
'hours' => round($projectTotal, 1),
|
||||
'users' => $users,
|
||||
];
|
||||
}
|
||||
usort($projects, fn ($a, $b) => $b['hours'] <=> $a['hours']);
|
||||
}
|
||||
$months[$m] = [
|
||||
'month' => $m,
|
||||
'month_name' => getCzechMonthName($m),
|
||||
'projects' => $projects,
|
||||
];
|
||||
}
|
||||
|
||||
successResponse(['months' => $months, 'year' => $yearInt]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single month mode
|
||||
$month = $monthParam ?? date('Y-m');
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT a.user_id, a.id as attendance_id, a.arrival_time, a.departure_time, a.break_start, a.break_end,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
AND a.departure_time IS NOT NULL
|
||||
AND (a.leave_type IS NULL OR a.leave_type = 'work')
|
||||
ORDER BY u.last_name ASC
|
||||
");
|
||||
$stmt->execute([$startDate, $endDate]);
|
||||
$workRecords = $stmt->fetchAll();
|
||||
|
||||
$userTotalMinutes = [];
|
||||
$userNames = [];
|
||||
$attendanceIds = [];
|
||||
foreach ($workRecords as $rec) {
|
||||
$uid = $rec['user_id'];
|
||||
$attendanceIds[] = $rec['attendance_id'];
|
||||
$userNames[$uid] = $rec['user_name'];
|
||||
if (!isset($userTotalMinutes[$uid])) {
|
||||
$userTotalMinutes[$uid] = 0;
|
||||
}
|
||||
$userTotalMinutes[$uid] += calculateWorkMinutes($rec);
|
||||
}
|
||||
|
||||
$aggregated = [];
|
||||
$projectIds = [];
|
||||
$userLoggedMinutes = [];
|
||||
|
||||
if (!empty($attendanceIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($attendanceIds), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id
|
||||
FROM attendance_project_logs pl
|
||||
JOIN attendance a ON pl.attendance_id = a.id
|
||||
WHERE pl.attendance_id IN ($placeholders)
|
||||
AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL)
|
||||
");
|
||||
$stmt->execute($attendanceIds);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$uid = $log['user_id'];
|
||||
$pid = (int)$log['project_id'];
|
||||
$key = "{$uid}_{$pid}";
|
||||
$projectIds[$pid] = true;
|
||||
if ($log['hours'] !== null) {
|
||||
$minutes = (int)$log['hours'] * 60 + (int)$log['mins'];
|
||||
} else {
|
||||
$minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60);
|
||||
}
|
||||
|
||||
if (!isset($aggregated[$key])) {
|
||||
$aggregated[$key] = ['user_id' => $uid, 'project_id' => $pid, 'minutes' => 0];
|
||||
}
|
||||
$aggregated[$key]['minutes'] += $minutes;
|
||||
|
||||
if (!isset($userLoggedMinutes[$uid])) {
|
||||
$userLoggedMinutes[$uid] = 0;
|
||||
}
|
||||
$userLoggedMinutes[$uid] += $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// "Bez projektu" per user
|
||||
foreach ($userTotalMinutes as $uid => $total) {
|
||||
$logged = $userLoggedMinutes[$uid] ?? 0;
|
||||
$unlogged = $total - $logged;
|
||||
if ($unlogged > 1) {
|
||||
$key = "{$uid}_0";
|
||||
if (!isset($aggregated[$key])) {
|
||||
$aggregated[$key] = ['user_id' => $uid, 'project_id' => 0, 'minutes' => 0];
|
||||
}
|
||||
$aggregated[$key]['minutes'] += $unlogged;
|
||||
}
|
||||
}
|
||||
|
||||
$projectMap = [];
|
||||
if (!empty($projectIds)) {
|
||||
try {
|
||||
$offersPdo = db();
|
||||
$ids = array_keys($projectIds);
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $offersPdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)");
|
||||
$stmt->execute($ids);
|
||||
foreach ($stmt->fetchAll() as $p) {
|
||||
$projectMap[$p['id']] = $p;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to fetch project names for report: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$report = [];
|
||||
foreach ($aggregated as $item) {
|
||||
$pid = $item['project_id'];
|
||||
$proj = $pid ? ($projectMap[$pid] ?? null) : null;
|
||||
$report[] = [
|
||||
'user_id' => $item['user_id'],
|
||||
'user_name' => $userNames[$item['user_id']] ?? "User #{$item['user_id']}",
|
||||
'project_id' => $pid ?: null,
|
||||
'project_number' => $proj ? $proj['project_number'] : null,
|
||||
'project_name' => $proj ? $proj['name'] : null,
|
||||
'hours' => round($item['minutes'] / 60, 2),
|
||||
];
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'report' => $report,
|
||||
'month' => $month,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetPrint(PDO $pdo): void
|
||||
{
|
||||
$month = validateMonth();
|
||||
$filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null;
|
||||
|
||||
$year = (int)substr($month, 0, 4);
|
||||
$monthNum = (int)substr($month, 5, 2);
|
||||
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$sql = "
|
||||
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$startDate, $endDate];
|
||||
|
||||
if ($filterUserId) {
|
||||
$sql .= ' AND a.user_id = ?';
|
||||
$params[] = $filterUserId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY u.last_name ASC, a.shift_date ASC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$records = $stmt->fetchAll();
|
||||
|
||||
enrichRecordsWithProjectLogs($pdo, $records);
|
||||
|
||||
$userTotals = calculateUserTotals($records, true);
|
||||
$leaveBalances = getLeaveBalancesBatch($pdo, array_keys($userTotals), $year);
|
||||
addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum);
|
||||
|
||||
$selectedUserName = '';
|
||||
if ($filterUserId) {
|
||||
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
||||
$stmt->execute([$filterUserId]);
|
||||
$user = $stmt->fetch();
|
||||
$selectedUserName = $user ? $user['name'] : '';
|
||||
}
|
||||
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||
|
||||
successResponse([
|
||||
'user_totals' => $userTotals,
|
||||
'leave_balances' => $leaveBalances,
|
||||
'users' => $users,
|
||||
'month' => $month,
|
||||
'month_name' => getCzechMonthName($monthNum) . ' ' . $year,
|
||||
'selected_user' => $filterUserId,
|
||||
'selected_user_name' => $selectedUserName,
|
||||
'year' => $year,
|
||||
'fund' => $fund,
|
||||
]);
|
||||
}
|
||||
370
api/includes/AttendanceHelpers.php
Normal file
370
api/includes/AttendanceHelpers.php
Normal file
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Attendance helper functions - shared between user and admin handlers
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function roundUpTo15Minutes(string $datetime): string
|
||||
{
|
||||
$timestamp = strtotime($datetime);
|
||||
$minutes = (int)date('i', $timestamp);
|
||||
|
||||
$remainder = $minutes % 15;
|
||||
|
||||
if ($remainder === 0) {
|
||||
$roundedMinutes = $minutes;
|
||||
} else {
|
||||
$roundedMinutes = $minutes + (15 - $remainder);
|
||||
}
|
||||
|
||||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||
}
|
||||
|
||||
function roundDownTo15Minutes(string $datetime): string
|
||||
{
|
||||
$timestamp = strtotime($datetime);
|
||||
$minutes = (int)date('i', $timestamp);
|
||||
|
||||
$remainder = $minutes % 15;
|
||||
$roundedMinutes = $minutes - $remainder;
|
||||
|
||||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||
}
|
||||
|
||||
function roundToNearest10Minutes(string $datetime): string
|
||||
{
|
||||
$timestamp = strtotime($datetime);
|
||||
$minutes = (int)date('i', $timestamp);
|
||||
|
||||
$remainder = $minutes % 10;
|
||||
|
||||
if ($remainder < 5) {
|
||||
$roundedMinutes = $minutes - $remainder;
|
||||
} else {
|
||||
$roundedMinutes = $minutes + (10 - $remainder);
|
||||
}
|
||||
|
||||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $record
|
||||
*/
|
||||
function calculateWorkMinutes(array $record): int
|
||||
{
|
||||
if (!$record['arrival_time'] || !$record['departure_time']) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$arrival = strtotime($record['arrival_time']);
|
||||
$departure = strtotime($record['departure_time']);
|
||||
$totalMinutes = ($departure - $arrival) / 60;
|
||||
|
||||
if ($record['break_start'] && $record['break_end']) {
|
||||
$breakStart = strtotime($record['break_start']);
|
||||
$breakEnd = strtotime($record['break_end']);
|
||||
$breakMinutes = ($breakEnd - $breakStart) / 60;
|
||||
$totalMinutes -= $breakMinutes;
|
||||
}
|
||||
|
||||
return max(0, (int)$totalMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}
|
||||
*/
|
||||
function getLeaveBalance(PDO $pdo, int $userId, ?int $year = null): array
|
||||
{
|
||||
$year = $year ?: (int)date('Y');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT vacation_total, vacation_used, sick_used FROM leave_balances WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$userId, $year]);
|
||||
$balance = $stmt->fetch();
|
||||
|
||||
if (!$balance) {
|
||||
return [
|
||||
'vacation_total' => 160,
|
||||
'vacation_used' => 0,
|
||||
'vacation_remaining' => 160,
|
||||
'sick_used' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'vacation_total' => (float)$balance['vacation_total'],
|
||||
'vacation_used' => (float)$balance['vacation_used'],
|
||||
'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'],
|
||||
'sick_used' => (float)$balance['sick_used'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get leave balances for multiple users (eliminates N+1 queries)
|
||||
*
|
||||
* @param array<int, int> $userIds
|
||||
* @return array<int, array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}>
|
||||
*/
|
||||
function getLeaveBalancesBatch(PDO $pdo, array $userIds, ?int $year = null): array
|
||||
{
|
||||
$year = $year ?: (int)date('Y');
|
||||
$result = [];
|
||||
|
||||
$default = [
|
||||
'vacation_total' => 160,
|
||||
'vacation_used' => 0,
|
||||
'vacation_remaining' => 160,
|
||||
'sick_used' => 0,
|
||||
];
|
||||
|
||||
if (empty($userIds)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT user_id, vacation_total, vacation_used, sick_used
|
||||
FROM leave_balances
|
||||
WHERE user_id IN ($placeholders) AND year = ?
|
||||
");
|
||||
$params = array_values($userIds);
|
||||
$params[] = $year;
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$balanceMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$balanceMap[$row['user_id']] = [
|
||||
'vacation_total' => (float)$row['vacation_total'],
|
||||
'vacation_used' => (float)$row['vacation_used'],
|
||||
'vacation_remaining' => (float)$row['vacation_total'] - (float)$row['vacation_used'],
|
||||
'sick_used' => (float)$row['sick_used'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($userIds as $uid) {
|
||||
$result[$uid] = $balanceMap[$uid] ?? $default;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function updateLeaveBalance(PDO $pdo, int $userId, string $date, string $leaveType, float $hours): void
|
||||
{
|
||||
if ($leaveType === 'work' || $leaveType === 'holiday' || $leaveType === 'unpaid') {
|
||||
return;
|
||||
}
|
||||
|
||||
$year = (int)date('Y', strtotime($date));
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||
$stmt->execute([$userId, $year]);
|
||||
$balance = $stmt->fetch();
|
||||
|
||||
if (!$balance) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO leave_balances (user_id, year, vacation_total, vacation_used, sick_used)
|
||||
VALUES (?, ?, 160, 0, 0)'
|
||||
);
|
||||
$stmt->execute([$userId, $year]);
|
||||
}
|
||||
|
||||
if ($leaveType === 'vacation') {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE leave_balances SET vacation_used = vacation_used + ? WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$hours, $userId, $year]);
|
||||
} elseif ($leaveType === 'sick') {
|
||||
$stmt = $pdo->prepare('UPDATE leave_balances SET sick_used = sick_used + ? WHERE user_id = ? AND year = ?');
|
||||
$stmt->execute([$hours, $userId, $year]);
|
||||
}
|
||||
}
|
||||
|
||||
function getCzechMonthName(int $month): string
|
||||
{
|
||||
$months = [
|
||||
1 => 'Leden', 2 => 'Únor', 3 => 'Březen', 4 => 'Duben',
|
||||
5 => 'Květen', 6 => 'Červen', 7 => 'Červenec', 8 => 'Srpen',
|
||||
9 => 'Září', 10 => 'Říjen', 11 => 'Listopad', 12 => 'Prosinec',
|
||||
];
|
||||
return $months[$month] ?? '';
|
||||
}
|
||||
|
||||
function getCzechDayName(int $dayOfWeek): string
|
||||
{
|
||||
$days = [
|
||||
0 => 'neděle', 1 => 'pondělí', 2 => 'úterý', 3 => 'středa',
|
||||
4 => 'čtvrtek', 5 => 'pátek', 6 => 'sobota',
|
||||
];
|
||||
return $days[$dayOfWeek] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich attendance records with project logs and project names (in-place)
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $records
|
||||
*/
|
||||
function enrichRecordsWithProjectLogs(PDO $pdo, array &$records): void
|
||||
{
|
||||
$recordIds = array_column($records, 'id');
|
||||
$recordProjectLogs = [];
|
||||
if (!empty($recordIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($recordIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM attendance_project_logs WHERE attendance_id IN ($placeholders) ORDER BY started_at ASC"
|
||||
);
|
||||
$stmt->execute($recordIds);
|
||||
foreach ($stmt->fetchAll() as $log) {
|
||||
$recordProjectLogs[$log['attendance_id']][] = $log;
|
||||
}
|
||||
}
|
||||
|
||||
$projectIds = [];
|
||||
foreach ($records as $rec) {
|
||||
if ($rec['project_id']) {
|
||||
$projectIds[$rec['project_id']] = $rec['project_id'];
|
||||
}
|
||||
}
|
||||
foreach ($recordProjectLogs as $logs) {
|
||||
foreach ($logs as $l) {
|
||||
$projectIds[$l['project_id']] = $l['project_id'];
|
||||
}
|
||||
}
|
||||
$projectNameMap = fetchProjectNames($projectIds);
|
||||
|
||||
foreach ($records as &$rec) {
|
||||
$rec['project_name'] = $rec['project_id'] ? ($projectNameMap[$rec['project_id']] ?? null) : null;
|
||||
$logs = $recordProjectLogs[$rec['id']] ?? [];
|
||||
foreach ($logs as &$l) {
|
||||
$l['project_name'] = $projectNameMap[$l['project_id']] ?? null;
|
||||
}
|
||||
unset($l);
|
||||
$rec['project_logs'] = $logs;
|
||||
}
|
||||
unset($rec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate per-user totals from records array
|
||||
*
|
||||
* @param list<array<string, mixed>> $records
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function calculateUserTotals(array $records, bool $includeRecords = false): array
|
||||
{
|
||||
$userTotals = [];
|
||||
foreach ($records as $record) {
|
||||
$uid = $record['user_id'];
|
||||
if (!isset($userTotals[$uid])) {
|
||||
$userTotals[$uid] = [
|
||||
'name' => $record['user_name'],
|
||||
'minutes' => 0,
|
||||
'working' => false,
|
||||
'vacation_hours' => 0,
|
||||
'sick_hours' => 0,
|
||||
'holiday_hours' => 0,
|
||||
'unpaid_hours' => 0,
|
||||
];
|
||||
if ($includeRecords) {
|
||||
$userTotals[$uid]['records'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
$leaveType = $record['leave_type'] ?? 'work';
|
||||
$leaveHours = (float)($record['leave_hours'] ?? 0);
|
||||
|
||||
if ($leaveType === 'vacation') {
|
||||
$userTotals[$uid]['vacation_hours'] += $leaveHours;
|
||||
} elseif ($leaveType === 'sick') {
|
||||
$userTotals[$uid]['sick_hours'] += $leaveHours;
|
||||
} elseif ($leaveType === 'holiday') {
|
||||
$userTotals[$uid]['holiday_hours'] += $leaveHours;
|
||||
} elseif ($leaveType === 'unpaid') {
|
||||
$userTotals[$uid]['unpaid_hours'] += $leaveHours;
|
||||
} else {
|
||||
$userTotals[$uid]['minutes'] += calculateWorkMinutes($record);
|
||||
}
|
||||
|
||||
if ($includeRecords) {
|
||||
$userTotals[$uid]['records'][] = $record;
|
||||
}
|
||||
|
||||
if ($record['arrival_time'] && !$record['departure_time']) {
|
||||
$userTotals[$uid]['working'] = true;
|
||||
}
|
||||
}
|
||||
return $userTotals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add monthly fund data and "working now" status to user totals
|
||||
*
|
||||
* @param array<array<string, mixed>> $userTotals
|
||||
*/
|
||||
function addFundDataToUserTotals(PDO $pdo, array &$userTotals, int $year, int $monthNum): void
|
||||
{
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
|
||||
|
||||
foreach ($userTotals as $uid => &$ut) {
|
||||
$workedHours = round($ut['minutes'] / 60, 1);
|
||||
$leaveHours = $ut['vacation_hours'] + $ut['sick_hours'];
|
||||
$covered = $workedHours + $leaveHours;
|
||||
$ut['fund'] = $fund;
|
||||
$ut['business_days'] = $businessDays;
|
||||
$ut['worked_hours'] = $workedHours;
|
||||
$ut['covered'] = $covered;
|
||||
$ut['missing'] = max(0, round($fund - $covered, 1));
|
||||
$ut['overtime'] = max(0, round($covered - $fund, 1));
|
||||
}
|
||||
unset($ut);
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT DISTINCT user_id FROM attendance
|
||||
WHERE shift_date = ?
|
||||
AND arrival_time IS NOT NULL
|
||||
AND departure_time IS NULL
|
||||
AND (leave_type IS NULL OR leave_type = 'work')
|
||||
");
|
||||
$stmt->execute([$today]);
|
||||
$workingNow = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($workingNow as $uid) {
|
||||
if (isset($userTotals[$uid])) {
|
||||
$userTotals[$uid]['working'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch project names from offers DB
|
||||
*
|
||||
* @param array<int, int> $projectIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function fetchProjectNames(array $projectIds): array
|
||||
{
|
||||
if (empty($projectIds)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$pdo = db();
|
||||
$placeholders = implode(',', array_fill(0, count($projectIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)");
|
||||
$stmt->execute(array_values($projectIds));
|
||||
$map = [];
|
||||
foreach ($stmt->fetchAll() as $p) {
|
||||
$map[$p['id']] = $p['project_number'] . ' – ' . $p['name'];
|
||||
}
|
||||
return $map;
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to fetch project names: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
556
api/includes/AuditLog.php
Normal file
556
api/includes/AuditLog.php
Normal file
@@ -0,0 +1,556 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Audit Logging System
|
||||
*
|
||||
* Comprehensive audit trail for all administrative actions
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
|
||||
class AuditLog
|
||||
{
|
||||
// Action types
|
||||
public const ACTION_LOGIN = 'login';
|
||||
public const ACTION_LOGIN_FAILED = 'login_failed';
|
||||
public const ACTION_LOGOUT = 'logout';
|
||||
public const ACTION_CREATE = 'create';
|
||||
public const ACTION_UPDATE = 'update';
|
||||
public const ACTION_DELETE = 'delete';
|
||||
public const ACTION_VIEW = 'view';
|
||||
public const ACTION_ACTIVATE = 'activate';
|
||||
public const ACTION_DEACTIVATE = 'deactivate';
|
||||
public const ACTION_PASSWORD_CHANGE = 'password_change';
|
||||
public const ACTION_PERMISSION_CHANGE = 'permission_change';
|
||||
public const ACTION_ACCESS_DENIED = 'access_denied';
|
||||
|
||||
private static ?int $currentUserId = null;
|
||||
private static ?string $currentUsername = null;
|
||||
|
||||
/**
|
||||
* Nastaví kontext aktuálního uživatele pro všechny následující logy
|
||||
*/
|
||||
public static function setUser(int $userId, string $username): void
|
||||
{
|
||||
self::$currentUserId = $userId;
|
||||
self::$currentUsername = $username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an action
|
||||
*
|
||||
* @param string $action Action type (use class constants)
|
||||
* @param string|null $entityType Entity type (e.g., 'user', 'project')
|
||||
* @param int|null $entityId Entity ID
|
||||
* @param string|null $description Human-readable description
|
||||
* @param array<string, mixed>|null $oldValues Previous values (for updates)
|
||||
* @param array<string, mixed>|null $newValues New values (for updates/creates)
|
||||
*/
|
||||
public static function log(
|
||||
string $action,
|
||||
?string $entityType = null,
|
||||
?int $entityId = null,
|
||||
?string $description = null,
|
||||
?array $oldValues = null,
|
||||
?array $newValues = null
|
||||
): void {
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$userId = self::$currentUserId;
|
||||
$username = self::$currentUsername;
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
description,
|
||||
old_values,
|
||||
new_values,
|
||||
user_agent,
|
||||
session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$username,
|
||||
getClientIp(),
|
||||
$action,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description,
|
||||
$oldValues ? json_encode($oldValues, JSON_UNESCAPED_UNICODE) : null,
|
||||
$newValues ? json_encode($newValues, JSON_UNESCAPED_UNICODE) : null,
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
session_id() ?: null,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log successful login
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $username Username
|
||||
*/
|
||||
public static function logLogin(int $userId, string $username): void
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
description,
|
||||
user_agent,
|
||||
session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$username,
|
||||
getClientIp(),
|
||||
self::ACTION_LOGIN,
|
||||
'user',
|
||||
$userId,
|
||||
"Přihlášení uživatele '$username'",
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
session_id() ?: null,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog login error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempt
|
||||
*
|
||||
* @param string $username Attempted username
|
||||
* @param string $reason Failure reason
|
||||
*/
|
||||
public static function logLoginFailed(string $username, string $reason = 'invalid_credentials'): void
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
description,
|
||||
user_agent,
|
||||
session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$username,
|
||||
getClientIp(),
|
||||
self::ACTION_LOGIN_FAILED,
|
||||
'user',
|
||||
"Neúspěšné přihlášení '$username': $reason",
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
session_id() ?: null,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog login failed error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log logout
|
||||
*
|
||||
* @param int|null $userId User ID (optional, for JWT-based auth)
|
||||
* @param string|null $username Username (optional, for JWT-based auth)
|
||||
*/
|
||||
public static function logLogout(?int $userId = null, ?string $username = null): void
|
||||
{
|
||||
if ($userId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
description,
|
||||
user_agent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$username,
|
||||
getClientIp(),
|
||||
self::ACTION_LOGOUT,
|
||||
'user',
|
||||
$userId,
|
||||
"Odhlášení uživatele '{$username}'",
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog logout error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entity creation
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param array<string, mixed> $data Created data
|
||||
* @param string|null $description Optional description
|
||||
*/
|
||||
public static function logCreate(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
array $data,
|
||||
?string $description = null
|
||||
): void {
|
||||
// Remove sensitive fields from logged data
|
||||
$safeData = self::sanitizeData($data);
|
||||
|
||||
self::log(
|
||||
self::ACTION_CREATE,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description ?? "Vytvořen $entityType #$entityId",
|
||||
null,
|
||||
$safeData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entity update
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param array<string, mixed> $oldData Old values
|
||||
* @param array<string, mixed> $newData New values
|
||||
* @param string|null $description Optional description
|
||||
*/
|
||||
public static function logUpdate(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
array $oldData,
|
||||
array $newData,
|
||||
?string $description = null
|
||||
): void {
|
||||
// Only log changed fields
|
||||
$changes = self::getChanges($oldData, $newData);
|
||||
|
||||
if (empty($changes['old']) && empty($changes['new'])) {
|
||||
return; // No actual changes
|
||||
}
|
||||
|
||||
self::log(
|
||||
self::ACTION_UPDATE,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description ?? "Upraven $entityType #$entityId",
|
||||
$changes['old'],
|
||||
$changes['new']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entity deletion
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param array<string, mixed>|null $data Deleted entity data
|
||||
* @param string|null $description Optional description
|
||||
*/
|
||||
public static function logDelete(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
?array $data = null,
|
||||
?string $description = null
|
||||
): void {
|
||||
$safeData = $data ? self::sanitizeData($data) : null;
|
||||
|
||||
self::log(
|
||||
self::ACTION_DELETE,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description ?? "Smazán $entityType #$entityId",
|
||||
$safeData,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log access denied
|
||||
*
|
||||
* @param string $resource Resource that was denied
|
||||
* @param string|null $permission Required permission
|
||||
*/
|
||||
public static function logAccessDenied(string $resource, ?string $permission = null): void
|
||||
{
|
||||
$description = "Přístup odepřen k '$resource'";
|
||||
if ($permission) {
|
||||
$description .= " (vyžaduje: $permission)";
|
||||
}
|
||||
|
||||
self::log(
|
||||
self::ACTION_ACCESS_DENIED,
|
||||
null,
|
||||
null,
|
||||
$description
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changes between old and new data
|
||||
*
|
||||
* @param array<string, mixed> $oldData Old values
|
||||
* @param array<string, mixed> $newData New values
|
||||
* @return array{old: array<string, mixed>, new: array<string, mixed>}
|
||||
*/
|
||||
private static function getChanges(array $oldData, array $newData): array
|
||||
{
|
||||
$oldData = self::sanitizeData($oldData);
|
||||
$newData = self::sanitizeData($newData);
|
||||
|
||||
$changedOld = [];
|
||||
$changedNew = [];
|
||||
|
||||
// Find changed fields
|
||||
foreach ($newData as $key => $newValue) {
|
||||
$oldValue = $oldData[$key] ?? null;
|
||||
|
||||
if ($oldValue !== $newValue) {
|
||||
$changedOld[$key] = $oldValue;
|
||||
$changedNew[$key] = $newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed fields
|
||||
foreach ($oldData as $key => $oldValue) {
|
||||
if (!array_key_exists($key, $newData)) {
|
||||
$changedOld[$key] = $oldValue;
|
||||
$changedNew[$key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return ['old' => $changedOld, 'new' => $changedNew];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sensitive fields from data before logging
|
||||
*
|
||||
* @param array<string, mixed> $data Data to sanitize
|
||||
* @return array<string, mixed> Sanitized data
|
||||
*/
|
||||
private static function sanitizeData(array $data): array
|
||||
{
|
||||
$sensitiveFields = [
|
||||
'password',
|
||||
'password_hash',
|
||||
'token',
|
||||
'token_hash',
|
||||
'secret',
|
||||
'api_key',
|
||||
'private_key',
|
||||
'csrf_token',
|
||||
];
|
||||
|
||||
foreach ($sensitiveFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$data[$field] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs with filtering and pagination
|
||||
*
|
||||
* @param array<string, mixed> $filters Filter options
|
||||
* @param int $page Page number (1-based)
|
||||
* @param int $perPage Items per page
|
||||
* @return array{logs: list<array<string, mixed>>, total: int, pages: int, page: int, per_page: int}
|
||||
*/
|
||||
public static function getLogs(array $filters = [], int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
// Apply filters
|
||||
if (!empty($filters['user_id'])) {
|
||||
$where[] = 'user_id = ?';
|
||||
$params[] = $filters['user_id'];
|
||||
}
|
||||
|
||||
if (!empty($filters['username'])) {
|
||||
$where[] = 'username LIKE ?';
|
||||
$params[] = '%' . $filters['username'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($filters['action'])) {
|
||||
$where[] = 'action = ?';
|
||||
$params[] = $filters['action'];
|
||||
}
|
||||
|
||||
if (!empty($filters['entity_type'])) {
|
||||
$where[] = 'entity_type = ?';
|
||||
$params[] = $filters['entity_type'];
|
||||
}
|
||||
|
||||
if (!empty($filters['ip'])) {
|
||||
$where[] = 'user_ip LIKE ?';
|
||||
$params[] = '%' . $filters['ip'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
$where[] = 'created_at >= ?';
|
||||
$params[] = $filters['date_from'] . ' 00:00:00';
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
$where[] = 'created_at <= ?';
|
||||
$params[] = $filters['date_to'] . ' 23:59:59';
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$where[] = '(description LIKE ? OR username LIKE ?)';
|
||||
$searchTerm = '%' . $filters['search'] . '%';
|
||||
$params[] = $searchTerm;
|
||||
$params[] = $searchTerm;
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// Count total
|
||||
$countSql = "SELECT COUNT(*) FROM audit_logs $whereClause";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
|
||||
// Calculate pagination
|
||||
$pages = max(1, ceil($total / $perPage));
|
||||
$page = max(1, min($page, $pages));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Get logs
|
||||
$sql = "
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
$whereClause
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
// Parse JSON fields
|
||||
foreach ($logs as &$log) {
|
||||
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
||||
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'logs' => $logs,
|
||||
'total' => $total,
|
||||
'pages' => $pages,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
];
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog getLogs error: ' . $e->getMessage());
|
||||
return ['logs' => [], 'total' => 0, 'pages' => 0, 'page' => 1, 'per_page' => $perPage];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent activity for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $limit Number of records
|
||||
* @return list<array<string, mixed>> Recent logs
|
||||
*/
|
||||
public static function getUserActivity(int $userId, int $limit = 10): array
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
');
|
||||
$stmt->execute([$userId, $limit]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog getUserActivity error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity history
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @return list<array<string, mixed>> Audit log history
|
||||
*/
|
||||
public static function getEntityHistory(string $entityType, int $entityId): array
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY created_at DESC
|
||||
');
|
||||
$stmt->execute([$entityType, $entityId]);
|
||||
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
foreach ($logs as &$log) {
|
||||
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
||||
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
||||
}
|
||||
|
||||
return $logs;
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog getEntityHistory error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
205
api/includes/CnbRates.php
Normal file
205
api/includes/CnbRates.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Devizove kurzy CNB s file cache per datum.
|
||||
*
|
||||
* Pouziti:
|
||||
* $cnb = CnbRates::getInstance();
|
||||
* $czk = $cnb->toCzk(1000.0, 'EUR', '2026-03-01');
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class CnbRates
|
||||
{
|
||||
private const API_URL = 'https://api.cnb.cz/cnbapi/exrates/daily';
|
||||
private const CACHE_FILE = 'cnb_rates_cache.json';
|
||||
|
||||
// Kurzy starsi nez dnesek se nemeni, cachujem navzdy.
|
||||
// Dnesni kurz cachujeme na 6 hodin (muze se behem dne aktualizovat).
|
||||
private const TODAY_CACHE_TTL = 21600;
|
||||
|
||||
/**
|
||||
* In-memory cache: date => currency => {rate, amount}
|
||||
* @var array<string, array<string, array{rate: float, amount: int}>>
|
||||
*/
|
||||
private array $ratesByDate = [];
|
||||
|
||||
private static ?CnbRates $instance = null;
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$this->loadCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevede castku na CZK dle kurzu platneho k danemu datu.
|
||||
*/
|
||||
public function toCzk(float $amount, string $currency, string $date = ''): float
|
||||
{
|
||||
if ($currency === 'CZK') {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$rates = $this->getRatesForDate($date ?: date('Y-m-d'));
|
||||
if (!isset($rates[$currency])) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$info = $rates[$currency];
|
||||
return $amount * ($info['rate'] / $info['amount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secte pole [{amount, currency, date?}] do jedne CZK castky.
|
||||
* Kazda polozka muze mit vlastni datum pro kurz.
|
||||
*
|
||||
* @param array<int, array{amount: float, currency: string, date?: string}> $items
|
||||
*/
|
||||
public function sumToCzk(array $items): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($items as $item) {
|
||||
$total += $this->toCzk(
|
||||
(float) $item['amount'],
|
||||
(string) $item['currency'],
|
||||
(string) ($item['date'] ?? '')
|
||||
);
|
||||
}
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{rate: float, amount: int}>
|
||||
*/
|
||||
private function getRatesForDate(string $date): array
|
||||
{
|
||||
if (isset($this->ratesByDate[$date])) {
|
||||
return $this->ratesByDate[$date];
|
||||
}
|
||||
|
||||
$rates = $this->fetchFromApi($date);
|
||||
if ($rates !== []) {
|
||||
$this->ratesByDate[$date] = $rates;
|
||||
$this->saveCache();
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
|
||||
// --- Cache ---
|
||||
|
||||
private function getCachePath(): string
|
||||
{
|
||||
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private function loadCache(): void
|
||||
{
|
||||
$path = $this->getCachePath();
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
foreach ($data as $date => $entry) {
|
||||
if (!is_array($entry) || !isset($entry['rates'], $entry['fetched_at'])) {
|
||||
continue;
|
||||
}
|
||||
// Dnesni kurz expiruje po TTL, starsi zustavaji navzdy
|
||||
if ($date === $today) {
|
||||
$age = time() - (int) $entry['fetched_at'];
|
||||
if ($age > self::TODAY_CACHE_TTL) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->ratesByDate[$date] = $entry['rates'];
|
||||
}
|
||||
}
|
||||
|
||||
private function saveCache(): void
|
||||
{
|
||||
$path = $this->getCachePath();
|
||||
|
||||
// Nacist existujici cache a mergovat
|
||||
$existing = [];
|
||||
if (file_exists($path)) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content !== false) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$existing = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
foreach ($this->ratesByDate as $date => $rates) {
|
||||
// Neprepisuj existujici pokud uz tam je (zachovej fetched_at)
|
||||
if (!isset($existing[$date])) {
|
||||
$existing[$date] = [
|
||||
'rates' => $rates,
|
||||
'fetched_at' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($existing, JSON_THROW_ON_ERROR);
|
||||
file_put_contents($path, $json, LOCK_EX);
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
/**
|
||||
* @return array<string, array{rate: float, amount: int}>
|
||||
*/
|
||||
private function fetchFromApi(string $date): array
|
||||
{
|
||||
$url = self::API_URL . '?lang=EN&date=' . urlencode($date);
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => 5],
|
||||
]);
|
||||
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
if ($response === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data) || !isset($data['rates'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
foreach ($data['rates'] as $entry) {
|
||||
if (!isset($entry['currencyCode'], $entry['rate'], $entry['amount'])) {
|
||||
continue;
|
||||
}
|
||||
$rates[$entry['currencyCode']] = [
|
||||
'rate' => (float) $entry['rate'],
|
||||
'amount' => (int) $entry['amount'],
|
||||
];
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
}
|
||||
117
api/includes/CzechHolidays.php
Normal file
117
api/includes/CzechHolidays.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Czech Holidays & Work Fund Calculator
|
||||
*
|
||||
* Provides Czech public holidays (including movable Easter dates)
|
||||
* and monthly work fund calculations.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class CzechHolidays
|
||||
{
|
||||
/** @var array<int, list<string>> Static cache for holidays by year */
|
||||
private static array $holidayCache = [];
|
||||
|
||||
/**
|
||||
* Get all Czech public holidays for a given year.
|
||||
* Returns array of 'Y-m-d' strings (11 fixed + 2 Easter-based).
|
||||
* Results are cached per-request to avoid recalculation.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function getHolidays(int $year): array
|
||||
{
|
||||
if (isset(self::$holidayCache[$year])) {
|
||||
return self::$holidayCache[$year];
|
||||
}
|
||||
// Fixed holidays
|
||||
$holidays = [
|
||||
sprintf('%04d-01-01', $year), // Den obnovy samostatného českého státu
|
||||
sprintf('%04d-05-01', $year), // Svátek práce
|
||||
sprintf('%04d-05-08', $year), // Den vítězství
|
||||
sprintf('%04d-07-05', $year), // Den slovanských věrozvěstů Cyrila a Metoděje
|
||||
sprintf('%04d-07-06', $year), // Den upálení mistra Jana Husa
|
||||
sprintf('%04d-09-28', $year), // Den české státnosti
|
||||
sprintf('%04d-10-28', $year), // Den vzniku samostatného československého státu
|
||||
sprintf('%04d-11-17', $year), // Den boje za svobodu a demokracii
|
||||
sprintf('%04d-12-24', $year), // Štědrý den
|
||||
sprintf('%04d-12-25', $year), // 1. svátek vánoční
|
||||
sprintf('%04d-12-26', $year), // 2. svátek vánoční
|
||||
];
|
||||
|
||||
// Easter-based holidays (Anonymous Gregorian algorithm)
|
||||
$easterSunday = self::getEasterSunday($year);
|
||||
$goodFriday = date('Y-m-d', strtotime($easterSunday . ' -2 days'));
|
||||
$easterMonday = date('Y-m-d', strtotime($easterSunday . ' +1 day'));
|
||||
$holidays[] = $goodFriday; // Velký pátek
|
||||
$holidays[] = $easterMonday; // Velikonoční pondělí
|
||||
|
||||
sort($holidays);
|
||||
self::$holidayCache[$year] = $holidays;
|
||||
return $holidays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is a Czech public holiday.
|
||||
*/
|
||||
public static function isHoliday(string $date): bool
|
||||
{
|
||||
$year = (int)date('Y', strtotime($date));
|
||||
$formatted = date('Y-m-d', strtotime($date));
|
||||
return in_array($formatted, self::getHolidays($year), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of business days (Mon-Fri, excluding holidays) in a month.
|
||||
*/
|
||||
public static function getBusinessDaysInMonth(int $year, int $month): int
|
||||
{
|
||||
$holidays = self::getHolidays($year);
|
||||
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
|
||||
$businessDays = 0;
|
||||
|
||||
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
$dayOfWeek = (int)date('N', strtotime($date)); // 1=Mon, 7=Sun
|
||||
if ($dayOfWeek <= 5 && !in_array($date, $holidays, true)) {
|
||||
$businessDays++;
|
||||
}
|
||||
}
|
||||
|
||||
return $businessDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly work fund in hours (business days × 8).
|
||||
*/
|
||||
public static function getMonthlyWorkFund(int $year, int $month): float
|
||||
{
|
||||
return self::getBusinessDaysInMonth($year, $month) * 8.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Easter Sunday date using the Anonymous Gregorian algorithm.
|
||||
* Returns 'Y-m-d' string.
|
||||
*/
|
||||
private static function getEasterSunday(int $year): string
|
||||
{
|
||||
$a = $year % 19;
|
||||
$b = intdiv($year, 100);
|
||||
$c = $year % 100;
|
||||
$d = intdiv($b, 4);
|
||||
$e = $b % 4;
|
||||
$f = intdiv($b + 8, 25);
|
||||
$g = intdiv($b - $f + 1, 3);
|
||||
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||
$i = intdiv($c, 4);
|
||||
$k = $c % 4;
|
||||
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
98
api/includes/Encryption.php
Normal file
98
api/includes/Encryption.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption helper for sensitive data at rest (e.g., TOTP secrets).
|
||||
*
|
||||
* Requires TOTP_ENCRYPTION_KEY in .env (64 hex chars = 32 bytes).
|
||||
* Format: base64(nonce + ciphertext + tag)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Encryption
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
private const NONCE_LENGTH = 12;
|
||||
private const TAG_LENGTH = 16;
|
||||
|
||||
private static ?string $key = null;
|
||||
|
||||
private static function getKey(): string
|
||||
{
|
||||
if (self::$key === null) {
|
||||
$hex = env('TOTP_ENCRYPTION_KEY', '');
|
||||
if (strlen($hex) !== 64 || !ctype_xdigit($hex)) {
|
||||
throw new RuntimeException('TOTP_ENCRYPTION_KEY must be 64 hex chars (32 bytes)');
|
||||
}
|
||||
self::$key = hex2bin($hex);
|
||||
}
|
||||
return self::$key;
|
||||
}
|
||||
|
||||
public static function encrypt(string $plaintext): string
|
||||
{
|
||||
$key = self::getKey();
|
||||
$nonce = random_bytes(self::NONCE_LENGTH);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LENGTH
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('Encryption failed');
|
||||
}
|
||||
|
||||
return base64_encode($nonce . $ciphertext . $tag);
|
||||
}
|
||||
|
||||
public static function decrypt(string $encoded): string
|
||||
{
|
||||
$key = self::getKey();
|
||||
$raw = base64_decode($encoded, true);
|
||||
|
||||
if ($raw === false || strlen($raw) < self::NONCE_LENGTH + self::TAG_LENGTH + 1) {
|
||||
throw new RuntimeException('Invalid encrypted data');
|
||||
}
|
||||
|
||||
$nonce = substr($raw, 0, self::NONCE_LENGTH);
|
||||
$tag = substr($raw, -self::TAG_LENGTH);
|
||||
$ciphertext = substr($raw, self::NONCE_LENGTH, -self::TAG_LENGTH);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zjisti, zda je hodnota sifrovana (base64 s ocekavanou delkou).
|
||||
* TOTP secret je vzdy 16-32 ASCII znaku, sifrovany je base64 s nonce+tag.
|
||||
*/
|
||||
public static function isEncrypted(string $value): bool
|
||||
{
|
||||
if (strlen($value) < 40) {
|
||||
return false;
|
||||
}
|
||||
$decoded = base64_decode($value, true);
|
||||
return $decoded !== false
|
||||
&& strlen($decoded) > self::NONCE_LENGTH + self::TAG_LENGTH;
|
||||
}
|
||||
}
|
||||
663
api/includes/JWTAuth.php
Normal file
663
api/includes/JWTAuth.php
Normal file
@@ -0,0 +1,663 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - JWT Authentication Handler
|
||||
*
|
||||
* Handles JWT access tokens and refresh tokens for stateless authentication.
|
||||
* Access tokens: Short-lived (configurable, default 15 min), stored in memory on client
|
||||
* Refresh tokens: Long-lived, stored in httpOnly cookie
|
||||
*
|
||||
* Without "remember me": Session cookie + 1 hour DB expiry (sliding window on activity)
|
||||
* With "remember me": Persistent cookie + 30 day expiry
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
|
||||
class JWTAuth
|
||||
{
|
||||
private const ALGORITHM = 'HS256';
|
||||
|
||||
// Cache for config values
|
||||
private static ?int $accessTokenExpiry = null;
|
||||
private static ?int $refreshTokenExpirySession = null;
|
||||
private static ?int $refreshTokenExpiryDays = null;
|
||||
|
||||
private static ?string $secretKey = null;
|
||||
|
||||
/**
|
||||
* Get the secret key from environment
|
||||
*/
|
||||
private static function getSecretKey(): string
|
||||
{
|
||||
if (self::$secretKey === null) {
|
||||
self::$secretKey = env('JWT_SECRET');
|
||||
if (empty(self::$secretKey)) {
|
||||
throw new Exception('JWT_SECRET not configured in environment');
|
||||
}
|
||||
if (strlen(self::$secretKey) < 32) {
|
||||
throw new Exception('JWT_SECRET must be at least 32 characters');
|
||||
}
|
||||
}
|
||||
return self::$secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token expiry in seconds (from env or default 900 = 15 min)
|
||||
*/
|
||||
public static function getAccessTokenExpiry(): int
|
||||
{
|
||||
if (self::$accessTokenExpiry === null) {
|
||||
self::$accessTokenExpiry = (int) env('JWT_ACCESS_TOKEN_EXPIRY', 900);
|
||||
}
|
||||
return self::$accessTokenExpiry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token session expiry in seconds (from env or default 3600 = 1 hour)
|
||||
* Used when "remember me" is NOT checked
|
||||
*/
|
||||
private static function getRefreshTokenExpirySession(): int
|
||||
{
|
||||
if (self::$refreshTokenExpirySession === null) {
|
||||
self::$refreshTokenExpirySession = (int) env('JWT_REFRESH_TOKEN_EXPIRY_SESSION', 3600);
|
||||
}
|
||||
return self::$refreshTokenExpirySession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token expiry in days (from env or default 30)
|
||||
* Used when "remember me" IS checked
|
||||
*/
|
||||
private static function getRefreshTokenExpiryDays(): int
|
||||
{
|
||||
if (self::$refreshTokenExpiryDays === null) {
|
||||
self::$refreshTokenExpiryDays = (int) env('JWT_REFRESH_TOKEN_EXPIRY_DAYS', 30);
|
||||
}
|
||||
return self::$refreshTokenExpiryDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an access token (short-lived, for API requests)
|
||||
*
|
||||
* @param array<string, mixed> $userData
|
||||
*/
|
||||
public static function generateAccessToken(array $userData): string
|
||||
{
|
||||
$issuedAt = time();
|
||||
$expiry = $issuedAt + self::getAccessTokenExpiry();
|
||||
|
||||
$payload = [
|
||||
'iss' => 'boha-automation', // Issuer
|
||||
'iat' => $issuedAt, // Issued at
|
||||
'exp' => $expiry, // Expiry
|
||||
'type' => 'access', // Token type
|
||||
'sub' => $userData['id'], // Subject (user ID)
|
||||
'user' => [
|
||||
'id' => $userData['id'],
|
||||
'username' => $userData['username'],
|
||||
'email' => $userData['email'],
|
||||
'full_name' => trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? '')),
|
||||
'role' => $userData['role'] ?? null,
|
||||
'role_display' => $userData['role_display'] ?? $userData['role'] ?? null,
|
||||
'is_admin' => $userData['is_admin'] ?? ($userData['role'] === 'admin'),
|
||||
],
|
||||
];
|
||||
|
||||
return JWT::encode($payload, self::getSecretKey(), self::ALGORITHM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a refresh token (stored in httpOnly cookie)
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param bool $remember If true: 30 day persistent cookie. If false: session cookie (1 hour DB expiry)
|
||||
*/
|
||||
public static function generateRefreshToken(int $userId, bool $remember = false): string
|
||||
{
|
||||
$token = bin2hex(random_bytes(32)); // 64 character random string
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
// Calculate expiry based on remember me
|
||||
if ($remember) {
|
||||
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400); // 30 days default
|
||||
$cookieExpiry = $dbExpiry; // Persistent cookie
|
||||
} else {
|
||||
$dbExpiry = time() + self::getRefreshTokenExpirySession(); // 1 hour default
|
||||
$cookieExpiry = 0; // Session cookie (deleted on browser close)
|
||||
}
|
||||
|
||||
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Pročistit replaced tokeny (po grace period uz nepotřebné)
|
||||
$stmt = $pdo->prepare(
|
||||
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL'
|
||||
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
// Limit aktivních sessions per user (max 5 devices)
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$count = $stmt->fetchColumn();
|
||||
|
||||
if ($count >= 5) {
|
||||
$stmt = $pdo->prepare('
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE user_id = ? AND replaced_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
}
|
||||
|
||||
// Store new refresh token
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$hashedToken,
|
||||
$expiresAt,
|
||||
getClientIp(),
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
$remember ? 1 : 0,
|
||||
]);
|
||||
|
||||
// Set httpOnly cookie
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
|
||||
setcookie('refresh_token', $token, [
|
||||
'expires' => $cookieExpiry,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
|
||||
return $token;
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth refresh token error: ' . $e->getMessage());
|
||||
throw new Exception('Failed to create refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode an access token
|
||||
*
|
||||
* @return array{user_id: mixed, user: array<string, mixed>}|null
|
||||
*/
|
||||
public static function verifyAccessToken(string $token): ?array
|
||||
{
|
||||
try {
|
||||
$decoded = JWT::decode($token, new Key(self::getSecretKey(), self::ALGORITHM));
|
||||
$payload = (array) $decoded;
|
||||
|
||||
// Verify it's an access token
|
||||
if (($payload['type'] ?? '') !== 'access') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $payload['sub'],
|
||||
'user' => (array) $payload['user'],
|
||||
];
|
||||
} catch (ExpiredException $e) {
|
||||
// Token expired - client should use refresh token
|
||||
return null;
|
||||
} catch (Exception $e) {
|
||||
error_log('JWT verification error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify refresh token and return user data if valid
|
||||
* Returns array with 'user' data and 'remember_me' flag
|
||||
* Deletes expired tokens from database when found
|
||||
*
|
||||
* @return array{user: array<string, mixed>, remember_me: bool, in_grace_period?: bool}|null
|
||||
*/
|
||||
public static function verifyRefreshToken(?string $token = null): ?array
|
||||
{
|
||||
// Get token from cookie if not provided
|
||||
if ($token === null) {
|
||||
$token = $_COOKIE['refresh_token'] ?? null;
|
||||
}
|
||||
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
// First check if token exists (regardless of expiry)
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT rt.*, u.id as user_id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active, r.name as role_name, r.display_name as role_display_name
|
||||
FROM refresh_tokens rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE rt.token_hash = ?
|
||||
');
|
||||
$stmt->execute([$hashedToken]);
|
||||
$data = $stmt->fetch();
|
||||
|
||||
if (!$data) {
|
||||
self::clearRefreshCookie();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token byl rotovan - zkontrolovat grace period
|
||||
if ($data['replaced_at'] !== null) {
|
||||
$replacedAt = strtotime($data['replaced_at']);
|
||||
if ((time() - $replacedAt) <= self::ROTATION_GRACE_PERIOD) {
|
||||
// Grace period - token jeste plati (souběžny request)
|
||||
if (!$data['is_active']) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'user' => [
|
||||
'id' => $data['user_id'],
|
||||
'username' => $data['username'],
|
||||
'email' => $data['email'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'role' => $data['role_name'],
|
||||
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
||||
'is_admin' => $data['role_name'] === 'admin',
|
||||
'permissions' => self::getUserPermissions($data['user_id']),
|
||||
],
|
||||
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
||||
'in_grace_period' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Po grace period - stary token uz neni platny, smazat jen tento token
|
||||
$uid = $data['user_id'];
|
||||
error_log("Refresh token reuse after grace period for user {$uid}");
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||
$stmt->execute([$hashedToken]);
|
||||
self::clearRefreshCookie();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (strtotime($data['expires_at']) < time()) {
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||
$stmt->execute([$hashedToken]);
|
||||
self::clearRefreshCookie();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check user is still active
|
||||
if (!$data['is_active']) {
|
||||
self::revokeRefreshToken($token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => [
|
||||
'id' => $data['user_id'],
|
||||
'username' => $data['username'],
|
||||
'email' => $data['email'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'role' => $data['role_name'],
|
||||
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
||||
'is_admin' => $data['role_name'] === 'admin',
|
||||
'permissions' => self::getUserPermissions($data['user_id']),
|
||||
],
|
||||
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
||||
];
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth verify refresh error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Grace period pro rotovane tokeny (sekundy) */
|
||||
private const ROTATION_GRACE_PERIOD = 30;
|
||||
|
||||
public static function getGracePeriod(): int
|
||||
{
|
||||
return self::ROTATION_GRACE_PERIOD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh tokens - issue new access token + rotate refresh token
|
||||
* Grace period 30s pro souběžné requesty
|
||||
*
|
||||
* @return array{access_token: string, user: array<string, mixed>, expires_in: int}|null
|
||||
*/
|
||||
public static function refreshTokens(): ?array
|
||||
{
|
||||
$token = $_COOKIE['refresh_token'] ?? null;
|
||||
|
||||
$tokenData = self::verifyRefreshToken($token);
|
||||
if (!$tokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$userData = $tokenData['user'];
|
||||
$accessToken = self::generateAccessToken($userData);
|
||||
|
||||
// Rotace: pokud token nebyl jiz nahrazen (grace period request), rotovat
|
||||
if (!($tokenData['in_grace_period'] ?? false)) {
|
||||
self::rotateRefreshToken(
|
||||
$token,
|
||||
$userData['id'],
|
||||
(bool) $tokenData['remember_me']
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'user' => [
|
||||
'id' => $userData['id'],
|
||||
'username' => $userData['username'],
|
||||
'email' => $userData['email'],
|
||||
'full_name' => trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? '')),
|
||||
'role' => $userData['role'],
|
||||
'role_display' => $userData['role_display'],
|
||||
'is_admin' => $userData['is_admin'],
|
||||
'permissions' => $userData['permissions'] ?? self::getUserPermissions($userData['id']),
|
||||
],
|
||||
'expires_in' => self::getAccessTokenExpiry(),
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
error_log('JWTAuth refresh error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotace refresh tokenu - vygeneruje novy, stary oznaci jako replaced
|
||||
*/
|
||||
private static function rotateRefreshToken(string $oldToken, int $userId, bool $remember): void
|
||||
{
|
||||
$pdo = db();
|
||||
$oldHash = hash('sha256', $oldToken);
|
||||
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$newHash = hash('sha256', $newToken);
|
||||
|
||||
if ($remember) {
|
||||
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400);
|
||||
$cookieExpiry = $dbExpiry;
|
||||
} else {
|
||||
$dbExpiry = time() + self::getRefreshTokenExpirySession();
|
||||
$cookieExpiry = 0;
|
||||
}
|
||||
|
||||
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
||||
|
||||
// Oznacit stary token jako replaced (atomicky - race condition ochrana)
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE refresh_tokens SET replaced_at = NOW(), replaced_by_hash = ?
|
||||
WHERE token_hash = ? AND replaced_at IS NULL
|
||||
');
|
||||
$stmt->execute([$newHash, $oldHash]);
|
||||
|
||||
// Jiny request uz token rotoval - nepokracovat
|
||||
if ($stmt->rowCount() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Procistit drive replaced tokeny (az po uspesne rotaci, respektovat grace period)
|
||||
$pdo->prepare(
|
||||
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL AND token_hash != ?'
|
||||
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
||||
)->execute([$userId, $oldHash]);
|
||||
|
||||
// Vlozit novy token
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$newHash,
|
||||
$expiresAt,
|
||||
getClientIp(),
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
$remember ? 1 : 0,
|
||||
]);
|
||||
|
||||
// Novy cookie
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
setcookie('refresh_token', $newToken, [
|
||||
'expires' => $cookieExpiry,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific refresh token
|
||||
*/
|
||||
public static function revokeRefreshToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||
$stmt->execute([$hashedToken]);
|
||||
|
||||
self::clearRefreshCookie();
|
||||
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth revoke error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a user (logout from all devices)
|
||||
*/
|
||||
public static function revokeAllUserTokens(int $userId): bool
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
self::clearRefreshCookie();
|
||||
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth revoke all error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the refresh token cookie
|
||||
*/
|
||||
private static function clearRefreshCookie(): void
|
||||
{
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
setcookie('refresh_token', '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
unset($_COOKIE['refresh_token']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token from Authorization header
|
||||
*/
|
||||
public static function getTokenFromHeader(): ?string
|
||||
{
|
||||
$headers = getallheaders();
|
||||
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||
|
||||
if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require valid access token
|
||||
* Also verifies refresh token still exists in database (session not revoked)
|
||||
* Extends session expiry only when less than 50% of time remaining (smart extend)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function requireAuth(): array
|
||||
{
|
||||
$token = self::getTokenFromHeader();
|
||||
|
||||
if (!$token) {
|
||||
errorResponse('Access token required', 401);
|
||||
}
|
||||
|
||||
$payload = self::verifyAccessToken($token);
|
||||
|
||||
if (!$payload) {
|
||||
errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
// Verify refresh token exists + smart extend in a single query
|
||||
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||
if ($refreshToken) {
|
||||
$hashedToken = hash('sha256', $refreshToken);
|
||||
try {
|
||||
$pdo = db();
|
||||
// Verify session - tolerovat replaced tokeny v grace period
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, remember_me, expires_at, replaced_at
|
||||
FROM refresh_tokens
|
||||
WHERE token_hash = ? AND expires_at > NOW()
|
||||
');
|
||||
$stmt->execute([$hashedToken]);
|
||||
$tokenData = $stmt->fetch();
|
||||
|
||||
if (!$tokenData) {
|
||||
self::clearRefreshCookie();
|
||||
errorResponse('Session revoked', 401);
|
||||
}
|
||||
|
||||
// Replaced token v grace period - jen validovat, neextendovat
|
||||
if ($tokenData['replaced_at'] !== null) {
|
||||
$replacedAt = strtotime($tokenData['replaced_at']);
|
||||
if ((time() - $replacedAt) > self::ROTATION_GRACE_PERIOD) {
|
||||
self::clearRefreshCookie();
|
||||
errorResponse('Session revoked', 401);
|
||||
}
|
||||
// V grace period - skip extend, access token jeste plati
|
||||
return $payload;
|
||||
}
|
||||
|
||||
// Smart extend: only UPDATE when less than 50% of session time remaining
|
||||
$expiresAt = strtotime($tokenData['expires_at']);
|
||||
$now = time();
|
||||
$remaining = $expiresAt - $now;
|
||||
|
||||
if ($tokenData['remember_me']) {
|
||||
$totalWindow = self::getRefreshTokenExpiryDays() * 86400;
|
||||
} else {
|
||||
$totalWindow = self::getRefreshTokenExpirySession();
|
||||
}
|
||||
|
||||
// Only extend if less than 50% remaining
|
||||
if ($remaining < ($totalWindow * 0.5)) {
|
||||
$newExpiry = date('Y-m-d H:i:s', $now + $totalWindow);
|
||||
$stmt = $pdo->prepare('UPDATE refresh_tokens SET expires_at = ? WHERE id = ?');
|
||||
$stmt->execute([$newExpiry, $tokenData['id']]);
|
||||
|
||||
// Refresh cookie expiry for remember-me sessions
|
||||
if ($tokenData['remember_me']) {
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
setcookie('refresh_token', $refreshToken, [
|
||||
'expires' => $now + $totalWindow,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth session check error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Optional auth - returns user data if valid token, null otherwise
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function optionalAuth(): ?array
|
||||
{
|
||||
$token = self::getTokenFromHeader();
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::verifyAccessToken($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission names for a user
|
||||
* Admin role returns all permissions.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function getUserPermissions(int $userId): array
|
||||
{
|
||||
return getUserPermissions($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired and replaced refresh tokens
|
||||
*/
|
||||
public static function cleanupExpiredTokens(): int
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
'DELETE FROM refresh_tokens WHERE expires_at < NOW()'
|
||||
. ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL '
|
||||
. self::ROTATION_GRACE_PERIOD . ' SECOND))'
|
||||
);
|
||||
$stmt->execute();
|
||||
return $stmt->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth cleanup error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
api/includes/LeaveNotification.php
Normal file
91
api/includes/LeaveNotification.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Leave Request Email Notifications
|
||||
*
|
||||
* Sends email notifications when leave requests are created.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Mailer.php';
|
||||
|
||||
class LeaveNotification
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private static array $leaveTypeLabels = [
|
||||
'vacation' => 'Dovolená',
|
||||
'sick' => 'Nemocenská',
|
||||
'unpaid' => 'Neplacené volno',
|
||||
];
|
||||
|
||||
/**
|
||||
* Send notification about a new leave request
|
||||
*
|
||||
* @param array<string, mixed> $request
|
||||
*/
|
||||
public static function notifyNewRequest(array $request, string $employeeName): void
|
||||
{
|
||||
$notifyEmail = env('LEAVE_NOTIFY_EMAIL', '');
|
||||
if (!$notifyEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
$leaveType = self::$leaveTypeLabels[$request['leave_type']] ?? $request['leave_type'];
|
||||
$dateFrom = date('d.m.Y', strtotime($request['date_from']));
|
||||
$dateTo = date('d.m.Y', strtotime($request['date_to']));
|
||||
$notes = $request['notes'] ?? '';
|
||||
|
||||
$subject = "Nová žádost o nepřítomnost - $employeeName ($leaveType)";
|
||||
|
||||
$html = "
|
||||
<html>
|
||||
<body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>
|
||||
<h2 style='color: #de3a3a;'>Nová žádost o nepřítomnost</h2>
|
||||
<table style='width: 100%; border-collapse: collapse; margin: 20px 0;'>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold; width: 180px;'>
|
||||
Zaměstnanec:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>"
|
||||
. htmlspecialchars($employeeName) . "</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Typ:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($leaveType) . "</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Období:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>$dateFrom – $dateTo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Pracovní dny:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>"
|
||||
. "{$request['total_days']} dní ({$request['total_hours']} hodin)</td>
|
||||
</tr>"
|
||||
. ($notes ? "
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Poznámka:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($notes) . '</td>
|
||||
</tr>' : '') . "
|
||||
</table>
|
||||
<p style='margin-top: 20px;'>
|
||||
<a href='https://www.boha-automation.cz/boha/leave-approval'
|
||||
style='background: #de3a3a; color: #fff; padding: 10px 20px;
|
||||
text-decoration: none; border-radius: 5px;'>
|
||||
Přejít ke schvalování
|
||||
</a>
|
||||
</p>
|
||||
<hr style='margin: 30px 0; border: none; border-top: 1px solid #ddd;'>
|
||||
<p style='font-size: 12px; color: #999;'>
|
||||
Tato zpráva byla automaticky vygenerována systémem BOHA Automation.<br>
|
||||
Datum: " . date('d.m.Y H:i:s') . '
|
||||
</p>
|
||||
</body>
|
||||
</html>';
|
||||
|
||||
$sent = Mailer::send($notifyEmail, $subject, $html);
|
||||
if (!$sent) {
|
||||
error_log("LeaveNotification: Failed to send new request notification to $notifyEmail");
|
||||
}
|
||||
}
|
||||
}
|
||||
45
api/includes/Mailer.php
Normal file
45
api/includes/Mailer.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Email Helper
|
||||
*
|
||||
* Sends emails via PHP mail() function.
|
||||
* Configuration via .env variables.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Mailer
|
||||
{
|
||||
/**
|
||||
* Send an email
|
||||
*
|
||||
* @param string $to Recipient email address
|
||||
* @param string $subject Email subject (plain text, will be UTF-8 encoded)
|
||||
* @param string $htmlBody HTML email body
|
||||
* @param string|null $replyTo Optional reply-to address
|
||||
* @return bool True if sent successfully
|
||||
*/
|
||||
public static function send(string $to, string $subject, string $htmlBody, ?string $replyTo = null): bool
|
||||
{
|
||||
$fromEmail = env('SMTP_FROM_EMAIL', env('CONTACT_EMAIL_FROM', 'web@boha-automation.cz'));
|
||||
$fromName = env('SMTP_FROM_NAME', 'BOHA Automation');
|
||||
|
||||
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||
|
||||
$headers = "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
|
||||
$headers .= "From: $fromName <$fromEmail>\r\n";
|
||||
if ($replyTo) {
|
||||
$headers .= "Reply-To: $replyTo\r\n";
|
||||
}
|
||||
|
||||
$sent = mail($to, $encodedSubject, $htmlBody, $headers);
|
||||
|
||||
if (!$sent) {
|
||||
error_log("Mailer error: mail() failed for recipient $to");
|
||||
}
|
||||
|
||||
return $sent;
|
||||
}
|
||||
}
|
||||
220
api/includes/RateLimiter.php
Normal file
220
api/includes/RateLimiter.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - IP-based Rate Limiter
|
||||
*
|
||||
* Implements rate limiting using file-based storage to prevent abuse
|
||||
* and protect API endpoints from excessive requests.
|
||||
*
|
||||
* Features:
|
||||
* - IP-based rate limiting
|
||||
* - Configurable limits per endpoint
|
||||
* - File-based storage (no database dependency)
|
||||
* - Automatic cleanup of expired entries
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class RateLimiter
|
||||
{
|
||||
/** @var string Directory for storing rate limit data */
|
||||
private string $storagePath;
|
||||
|
||||
/** @var int Default requests per minute */
|
||||
private int $defaultLimit = 60;
|
||||
|
||||
/** @var int Time window in seconds (1 minute) */
|
||||
private int $windowSeconds = 60;
|
||||
|
||||
/** @var bool Whether storage directory has been verified */
|
||||
private static bool $dirVerified = false;
|
||||
|
||||
/**
|
||||
* Initialize the rate limiter
|
||||
*
|
||||
* @param string|null $storagePath Path to store rate limit files
|
||||
*/
|
||||
public function __construct(?string $storagePath = null)
|
||||
{
|
||||
$this->storagePath = $storagePath
|
||||
?? (defined('RATE_LIMIT_STORAGE_PATH')
|
||||
? RATE_LIMIT_STORAGE_PATH
|
||||
: __DIR__ . '/../rate_limits');
|
||||
|
||||
// Only check directory once per process (static flag)
|
||||
if (!self::$dirVerified) {
|
||||
if (!is_dir($this->storagePath)) {
|
||||
mkdir($this->storagePath, 0755, true);
|
||||
}
|
||||
self::$dirVerified = true;
|
||||
}
|
||||
|
||||
// Cleanup old files very rarely (0.1% of requests instead of 1%)
|
||||
if (rand(1, 1000) === 1) {
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request should be rate limited
|
||||
*
|
||||
* Uses exclusive file locking for the entire read-check-increment-write cycle
|
||||
* to prevent race conditions under concurrent requests.
|
||||
*
|
||||
* @param string $endpoint Endpoint identifier (e.g., 'login', 'session')
|
||||
* @param int|null $limit Custom limit for this endpoint (requests per minute)
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
/** @var bool Fail-closed: blokuj request pri chybe FS (pro kriticke endpointy) */
|
||||
private bool $failClosed = false;
|
||||
|
||||
public function setFailClosed(bool $failClosed = true): self
|
||||
{
|
||||
$this->failClosed = $failClosed;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function check(string $endpoint, ?int $limit = null): bool
|
||||
{
|
||||
$limit = $limit ?? $this->defaultLimit;
|
||||
$ip = $this->getClientIp();
|
||||
$key = $this->getKey($ip, $endpoint);
|
||||
$file = $this->storagePath . '/' . $key . '.json';
|
||||
$now = time();
|
||||
|
||||
// Open file with exclusive lock for atomic read-check-increment-write
|
||||
$fp = @fopen($file, 'c+');
|
||||
if (!$fp) {
|
||||
return !$this->failClosed;
|
||||
}
|
||||
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
fclose($fp);
|
||||
return !$this->failClosed;
|
||||
}
|
||||
|
||||
// Read current data under lock
|
||||
$content = stream_get_contents($fp);
|
||||
$data = $content ? json_decode($content, true) : null;
|
||||
|
||||
if (is_array($data) && $data['window_start'] > ($now - $this->windowSeconds)) {
|
||||
// Same window - check count
|
||||
if ($data['count'] >= $limit) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return false; // Rate limited
|
||||
}
|
||||
$data['count']++;
|
||||
} else {
|
||||
// New window - reset counter
|
||||
$data = ['window_start' => $now, 'count' => 1];
|
||||
}
|
||||
|
||||
// Write updated data
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data));
|
||||
fflush($fp);
|
||||
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce rate limit and return 429 response if exceeded
|
||||
*
|
||||
* @param string $endpoint Endpoint identifier
|
||||
* @param int|null $limit Custom limit for this endpoint
|
||||
*/
|
||||
public function enforce(string $endpoint, ?int $limit = null): void
|
||||
{
|
||||
if (!$this->check($endpoint, $limit)) {
|
||||
$this->sendRateLimitResponse();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 429 Too Many Requests response and exit
|
||||
*/
|
||||
private function sendRateLimitResponse(): void
|
||||
{
|
||||
http_response_code(429);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Retry-After: ' . $this->windowSeconds);
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Prilis mnoho pozadavku. Zkuste to prosim pozdeji.',
|
||||
'retry_after' => $this->windowSeconds,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*
|
||||
* @return string IP address
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
{
|
||||
// Use the global helper if available
|
||||
if (function_exists('getClientIp')) {
|
||||
return getClientIp();
|
||||
}
|
||||
|
||||
// Fallback: use only REMOTE_ADDR (cannot be spoofed)
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate storage key for IP and endpoint
|
||||
*
|
||||
* @param string $ip Client IP
|
||||
* @param string $endpoint Endpoint identifier
|
||||
* @return string Storage key (filename-safe)
|
||||
*/
|
||||
private function getKey(string $ip, string $endpoint): string
|
||||
{
|
||||
// Create a safe filename from IP and endpoint
|
||||
return md5($ip . ':' . $endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired rate limit files
|
||||
*
|
||||
* Removes files older than the time window to prevent disk space issues
|
||||
*/
|
||||
private function cleanup(): void
|
||||
{
|
||||
if (!is_dir($this->storagePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($this->storagePath . '/*.json');
|
||||
$expireTime = time() - ($this->windowSeconds * 2); // Keep for 2x window to be safe
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (filemtime($file) < $expireTime) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rate limit data (useful for testing)
|
||||
*/
|
||||
public function clearAll(): void
|
||||
{
|
||||
if (!is_dir($this->storagePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($this->storagePath . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
api/rate_limits/.htaccess
Normal file
1
api/rate_limits/.htaccess
Normal file
@@ -0,0 +1 @@
|
||||
Deny from all
|
||||
Reference in New Issue
Block a user