Files
app/api/admin/handlers/attendance-handlers.php
Simon 758be819c3 feat: P4 backend kvalita - SELECT * fix, overdue konsolidace, Validator
- SELECT * nahrazen explicitnimi sloupci ve 22 PHP souborech (69+ vyskytu)
- users-handlers.php: password_hash explicitne vyloucen z dotazu
- Overdue detekce presunuta do invoices.php routeru (1x pred dispatch misto 3x v handlerech)
- Validator.php: validacni helper s pravidly required, string, int, email, in, numeric
- PaginationHelper: PHPStan typy opraveny

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:42:42 +01:00

612 lines
21 KiB
PHP

<?php
declare(strict_types=1);
function handleGetCurrent(PDO $pdo, int $userId): void
{
$today = date('Y-m-d');
$stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng,
arrival_accuracy, arrival_address, break_start, break_end,
departure_time, departure_lat, departure_lng, departure_accuracy,
departure_address, notes, project_id, leave_type, leave_hours, created_at
FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1
");
$stmt->execute([$userId]);
$ongoingShift = $stmt->fetch();
$projectLogs = [];
$activeProjectId = null;
if ($ongoingShift) {
$stmt = $pdo->prepare(
'SELECT id, attendance_id, project_id, started_at, ended_at, hours, minutes
FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'
);
$stmt->execute([$ongoingShift['id']]);
$projectLogs = $stmt->fetchAll();
foreach ($projectLogs as $log) {
if ($log['ended_at'] === null) {
$activeProjectId = (int)$log['project_id'];
break;
}
}
}
$stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng,
arrival_accuracy, arrival_address, break_start, break_end,
departure_time, departure_lat, departure_lng, departure_accuracy,
departure_address, notes, project_id, leave_type, leave_hours, created_at
FROM attendance
WHERE user_id = ? AND shift_date = ?
AND departure_time IS NOT NULL
AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY arrival_time DESC
");
$stmt->execute([$userId, $today]);
$todayShifts = $stmt->fetchAll();
$completedShiftIds = array_column($todayShifts, 'id');
$completedProjectLogs = [];
if (!empty($completedShiftIds)) {
$placeholders = implode(',', array_fill(0, count($completedShiftIds), '?'));
$stmt = $pdo->prepare(
"SELECT id, attendance_id, project_id, started_at, ended_at, hours, minutes
FROM attendance_project_logs
WHERE attendance_id IN ($placeholders)
ORDER BY started_at ASC"
);
$stmt->execute($completedShiftIds);
$allLogs = $stmt->fetchAll();
foreach ($allLogs as $log) {
$completedProjectLogs[$log['attendance_id']][] = $log;
}
}
$leaveBalance = getLeaveBalance($pdo, $userId);
$currentYear = (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 id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
FROM attendance
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
');
$stmt->execute([$userId, $startDate, $endDate]);
$monthRecords = $stmt->fetchAll();
$workedMinutes = 0;
$leaveHoursMonth = 0;
$vacationHours = 0;
$sickHours = 0;
$holidayHours = 0;
$unpaidHours = 0;
foreach ($monthRecords as $rec) {
$lt = $rec['leave_type'] ?? 'work';
$lh = (float)($rec['leave_hours'] ?? 0);
if ($lt === 'work') {
if ($rec['departure_time']) {
$workedMinutes += calculateWorkMinutes($rec);
}
} elseif ($lt === 'vacation') {
$vacationHours += $lh;
$leaveHoursMonth += $lh;
} elseif ($lt === 'sick') {
$sickHours += $lh;
$leaveHoursMonth += $lh;
} elseif ($lt === 'holiday') {
$holidayHours += $lh;
} elseif ($lt === 'unpaid') {
$unpaidHours += $lh;
}
}
$workedHours = round($workedMinutes / 60, 1);
$covered = $workedHours + $leaveHoursMonth;
$remaining = max(0, $fund - $covered);
$overtime = max(0, round($covered - $fund, 1));
$monthlyFund = [
'fund' => $fund,
'business_days' => $businessDays,
'worked' => $workedHours,
'leave_hours' => $leaveHoursMonth,
'vacation_hours' => $vacationHours,
'sick_hours' => $sickHours,
'holiday_hours' => $holidayHours,
'unpaid_hours' => $unpaidHours,
'covered' => $covered,
'remaining' => $remaining,
'overtime' => $overtime,
'month_name' => getCzechMonthName($currentMonth) . ' ' . $currentYear,
];
// Enrich project logs with names
$allLogProjectIds = [];
foreach ($projectLogs as $l) {
$allLogProjectIds[$l['project_id']] = $l['project_id'];
}
foreach ($completedProjectLogs as $logs) {
foreach ($logs as $l) {
$allLogProjectIds[$l['project_id']] = $l['project_id'];
}
}
$projNameMap = fetchProjectNames($allLogProjectIds);
foreach ($projectLogs as &$l) {
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
}
unset($l);
foreach ($completedProjectLogs as &$logs) {
foreach ($logs as &$l) {
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
}
unset($l);
}
unset($logs);
foreach ($todayShifts as &$shift) {
$shift['project_logs'] = $completedProjectLogs[$shift['id']] ?? [];
}
unset($shift);
successResponse([
'ongoing_shift' => $ongoingShift,
'today_shifts' => $todayShifts,
'date' => $today,
'leave_balance' => $leaveBalance,
'monthly_fund' => $monthlyFund,
'project_logs' => $projectLogs,
'active_project_id' => $activeProjectId,
]);
}
function handleGetHistory(PDO $pdo, int $userId): void
{
$month = validateMonth();
$year = (int)substr($month, 0, 4);
$monthNum = (int)substr($month, 5, 2);
$startDate = "{$month}-01";
$endDate = date('Y-m-t', strtotime($startDate));
$stmt = $pdo->prepare('
SELECT id, user_id, shift_date, arrival_time, arrival_address,
break_start, break_end, departure_time, departure_address,
notes, project_id, leave_type, leave_hours, created_at
FROM attendance
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
ORDER BY shift_date DESC
');
$stmt->execute([$userId, $startDate, $endDate]);
$records = $stmt->fetchAll();
enrichRecordsWithProjectLogs($pdo, $records);
$totalMinutes = 0;
$vacationHours = 0;
$sickHours = 0;
$holidayHours = 0;
$unpaidHours = 0;
foreach ($records as $record) {
$leaveType = $record['leave_type'] ?? 'work';
$leaveHours = (float)($record['leave_hours'] ?? 0);
if ($leaveType === 'vacation') {
$vacationHours += $leaveHours;
} elseif ($leaveType === 'sick') {
$sickHours += $leaveHours;
} elseif ($leaveType === 'holiday') {
$holidayHours += $leaveHours;
} elseif ($leaveType === 'unpaid') {
$unpaidHours += $leaveHours;
} else {
$totalMinutes += calculateWorkMinutes($record);
}
}
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
$workedHours = round($totalMinutes / 60, 1);
$leaveHoursCovered = $vacationHours + $sickHours;
$covered = $workedHours + $leaveHoursCovered;
$remaining = max(0, round($fund - $covered, 1));
$overtime = max(0, round($covered - $fund, 1));
$leaveBalance = getLeaveBalance($pdo, $userId, $year);
successResponse([
'records' => $records,
'month' => $month,
'year' => $year,
'month_name' => getCzechMonthName($monthNum) . ' ' . $year,
'total_minutes' => $totalMinutes,
'vacation_hours' => $vacationHours,
'sick_hours' => $sickHours,
'holiday_hours' => $holidayHours,
'unpaid_hours' => $unpaidHours,
'leave_balance' => $leaveBalance,
'monthly_fund' => [
'fund' => $fund,
'business_days' => $businessDays,
'worked' => $workedHours,
'leave_hours' => $leaveHoursCovered,
'covered' => $covered,
'remaining' => $remaining,
'overtime' => $overtime,
],
]);
}
function handlePunch(PDO $pdo, int $userId): void
{
$input = getJsonInput();
$action = $input['punch_action'] ?? '';
$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 id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, created_at
FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1
");
$stmt->execute([$userId]);
$ongoingShift = $stmt->fetch();
if ($action === 'arrival' && !$ongoingShift) {
$now = roundUpTo15Minutes($rawNow);
$stmt = $pdo->prepare('
INSERT INTO attendance
(user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address)
VALUES (?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]);
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
'arrival_time' => $now,
'location' => $address,
], 'Příchod zaznamenán');
successResponse(null, 'Příchod zaznamenán');
} elseif ($ongoingShift) {
switch ($action) {
case 'break_start':
if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) {
$breakStart = roundToNearest10Minutes($rawNow);
$breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
successResponse(null, 'Pauza zaznamenána');
} else {
errorResponse('Nelze zadat pauzu');
}
break;
case 'departure':
if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) {
$now = roundDownTo15Minutes($rawNow);
// Auto-add break if shift is longer than 6h and no break
if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) {
$arrivalTime = strtotime($ongoingShift['arrival_time']);
$departureTime = strtotime($now);
$hoursWorked = ($departureTime - $arrivalTime) / 3600;
if ($hoursWorked > 12) {
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60)));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
} elseif ($hoursWorked > 6) {
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
}
}
$stmt = $pdo->prepare('
UPDATE attendance
SET departure_time = ?, departure_lat = ?, departure_lng = ?,
departure_accuracy = ?, departure_address = ?
WHERE id = ?
');
$stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]);
// Close any open project log
$stmt = $pdo->prepare('
UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL
');
$stmt->execute([$now, $ongoingShift['id']]);
AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [
'departure_time' => $now,
'location' => $address,
], 'Odchod zaznamenán');
successResponse(null, 'Odchod zaznamenán');
} else {
errorResponse('Nelze zadat odchod');
}
break;
default:
errorResponse('Neplatná akce');
}
} else {
errorResponse('Neplatná akce - nemáte aktivní směnu');
}
}
function handleUpdateAddress(PDO $pdo, int $userId): void
{
$input = getJsonInput();
$address = trim($input['address'] ?? '');
$punchAction = $input['punch_action'] ?? '';
if (!$address) {
successResponse(null);
return;
}
if ($punchAction === 'arrival') {
$stmt = $pdo->prepare("
UPDATE attendance SET arrival_address = ?
WHERE id = (
SELECT id FROM (
SELECT id FROM attendance
WHERE user_id = ? AND (arrival_address IS NULL OR arrival_address = '')
ORDER BY created_at DESC LIMIT 1
) t
)
");
} else {
$stmt = $pdo->prepare("
UPDATE attendance SET departure_address = ?
WHERE id = (
SELECT id FROM (
SELECT id FROM attendance
WHERE user_id = ? AND (departure_address IS NULL OR departure_address = '')
AND departure_time IS NOT NULL
ORDER BY created_at DESC LIMIT 1
) t
)
");
}
$stmt->execute([$address, $userId]);
successResponse(null);
}
function handleAddLeave(PDO $pdo, int $userId): void
{
$input = getJsonInput();
$leaveType = $input['leave_type'] ?? '';
$leaveDate = $input['leave_date'] ?? '';
$leaveHours = (float)($input['leave_hours'] ?? 8);
$notes = trim($input['notes'] ?? '');
if (!$leaveType || !$leaveDate || $leaveHours <= 0) {
errorResponse('Vyplňte všechna povinná pole');
}
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
errorResponse('Neplatný typ nepřítomnosti');
}
if ($leaveType === 'vacation') {
$year = (int)date('Y', strtotime($leaveDate));
$balance = getLeaveBalance($pdo, $userId, $year);
if ($balance['vacation_remaining'] < $leaveHours) {
errorResponse(
"Nemáte dostatek hodin dovolené. Zbývá vám "
. "{$balance['vacation_remaining']} hodin, požadujete {$leaveHours} hodin."
);
}
}
$stmt = $pdo->prepare('
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
VALUES (?, ?, ?, ?, ?)
');
$stmt->execute([$userId, $leaveDate, $leaveType, $leaveHours, $notes ?: null]);
updateLeaveBalance($pdo, $userId, $leaveDate, $leaveType, $leaveHours);
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
'leave_type' => $leaveType,
'leave_hours' => $leaveHours,
], "Zaznamenána nepřítomnost: $leaveType");
successResponse(null, 'Nepřítomnost byla zaznamenána');
}
function handleSaveNotes(PDO $pdo, int $userId): void
{
$input = getJsonInput();
$notes = trim($input['notes'] ?? '');
$stmt = $pdo->prepare('
SELECT id FROM attendance
WHERE user_id = ? AND departure_time IS NULL
ORDER BY created_at DESC LIMIT 1
');
$stmt->execute([$userId]);
$currentShift = $stmt->fetch();
if (!$currentShift) {
errorResponse('Nemáte aktivní směnu');
}
$stmt = $pdo->prepare('UPDATE attendance SET notes = ? WHERE id = ?');
$stmt->execute([$notes, $currentShift['id']]);
successResponse(null, 'Poznámka byla uložena');
}
function handleGetProjects(): void
{
try {
$pdo = db();
$stmt = $pdo->query(
"SELECT id, project_number, name FROM projects
WHERE status = 'aktivni' ORDER BY project_number ASC"
);
$projects = $stmt->fetchAll();
successResponse(['projects' => $projects]);
} catch (\Exception $e) {
error_log('Failed to fetch projects: ' . $e->getMessage());
successResponse(['projects' => []]);
}
}
function handleSwitchProject(PDO $pdo, int $userId): void
{
$input = getJsonInput();
/** @var mixed $rawProjectId */
$rawProjectId = $input['project_id'] ?? null;
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
? (int)$rawProjectId
: null;
$stmt = $pdo->prepare("
SELECT id FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1
");
$stmt->execute([$userId]);
$currentShift = $stmt->fetch();
if (!$currentShift) {
errorResponse('Nemáte aktivní směnu');
}
$attendanceId = $currentShift['id'];
$now = 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 id, attendance_id, project_id, started_at, ended_at, hours, minutes
FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'
);
$stmt->execute([$attendanceId]);
$logs = $stmt->fetchAll();
$projectIds = [];
foreach ($logs as $l) {
$projectIds[$l['project_id']] = $l['project_id'];
}
$projNameMap = fetchProjectNames($projectIds);
foreach ($logs as &$l) {
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
}
unset($l);
successResponse(['logs' => $logs]);
}
function handleSaveProjectLogs(PDO $pdo): void
{
$input = getJsonInput();
$attendanceId = (int)($input['attendance_id'] ?? 0);
$logs = $input['project_logs'] ?? [];
if (!$attendanceId) {
errorResponse('attendance_id je povinné');
}
$stmt = $pdo->prepare('SELECT id FROM attendance WHERE id = ?');
$stmt->execute([$attendanceId]);
$record = $stmt->fetch();
if (!$record) {
errorResponse('Záznam nebyl nalezen', 404);
}
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
$stmt->execute([$attendanceId]);
if (!empty($logs)) {
$stmt = $pdo->prepare(
'INSERT INTO attendance_project_logs
(attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)'
);
foreach ($logs as $log) {
$projectId = (int)($log['project_id'] ?? 0);
if (!$projectId) {
continue;
}
$h = (int)($log['hours'] ?? 0);
$m = (int)($log['minutes'] ?? 0);
if ($h === 0 && $m === 0) {
continue;
}
$stmt->execute([$attendanceId, $projectId, $h, $m]);
}
}
successResponse(null, 'Projektové záznamy byly uloženy');
}