prepare($sql); $stmt->execute($params); $records = $stmt->fetchAll(); enrichRecordsWithProjectLogs($pdo, $records); $stmt = $pdo->query( "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" ); $users = $stmt->fetchAll(); $userTotals = calculateUserTotals($records); $leaveBalances = getLeaveBalancesBatch( $pdo, array_keys($userTotals), $year ); $monthNum = (int)substr($month, 5, 2); addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum); successResponse([ 'records' => $records, 'users' => $users, 'month' => $month, 'user_totals' => $userTotals, 'leave_balances' => $leaveBalances, ]); } function handleGetBalances(PDO $pdo): void { $year = (int)($_GET['year'] ?? date('Y')); $stmt = $pdo->query( "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" ); $users = $stmt->fetchAll(); $userIds = array_column($users, 'id'); $batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year); $balances = []; foreach ($users as $user) { $balances[$user['id']] = array_merge( ['name' => $user['name']], $batchBalances[$user['id']] ); } successResponse([ 'users' => $users, 'balances' => $balances, 'year' => $year, ]); } function handleGetWorkFund(PDO $pdo): void { $year = (int)($_GET['year'] ?? date('Y')); $currentYear = (int)date('Y'); $currentMonth = (int)date('m'); $maxMonth = ($year < $currentYear) ? 12 : (($year === $currentYear) ? $currentMonth : 0); if ($maxMonth === 0) { successResponse(['months' => [], 'holidays' => [], 'year' => $year]); return; } $stmt = $pdo->query( "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" ); $users = $stmt->fetchAll(); $startDate = sprintf('%04d-01-01', $year); $endDate = sprintf('%04d-%02d-%02d', $year, $maxMonth, cal_days_in_month(CAL_GREGORIAN, $maxMonth, $year)); $stmt = $pdo->prepare( 'SELECT id, user_id, shift_date, arrival_time, break_start, break_end, departure_time, notes, project_id, leave_type, leave_hours FROM attendance WHERE shift_date BETWEEN ? AND ? ORDER BY shift_date' ); $stmt->execute([$startDate, $endDate]); $allRecords = $stmt->fetchAll(); $monthUserData = []; foreach ($allRecords as $rec) { $m = (int)date('m', strtotime($rec['shift_date'])); $uid = $rec['user_id']; if (!isset($monthUserData[$m][$uid])) { $monthUserData[$m][$uid] = ['minutes' => 0, 'vacation' => 0, 'sick' => 0, 'holiday' => 0, 'unpaid' => 0]; } $lt = $rec['leave_type'] ?? 'work'; $lh = (float)($rec['leave_hours'] ?? 0); if ($lt === 'work') { if ($rec['departure_time']) { $monthUserData[$m][$uid]['minutes'] += calculateWorkMinutes($rec); } } elseif ($lt === 'vacation') { $monthUserData[$m][$uid]['vacation'] += $lh; } elseif ($lt === 'sick') { $monthUserData[$m][$uid]['sick'] += $lh; } elseif ($lt === 'holiday') { $monthUserData[$m][$uid]['holiday'] += $lh; } elseif ($lt === 'unpaid') { $monthUserData[$m][$uid]['unpaid'] += $lh; } } $months = []; for ($m = 1; $m <= $maxMonth; $m++) { $fund = CzechHolidays::getMonthlyWorkFund($year, $m); $businessDays = CzechHolidays::getBusinessDaysInMonth($year, $m); $monthName = getCzechMonthName($m); $userStats = []; foreach ($users as $user) { $uid = $user['id']; $ud = $monthUserData[$m][$uid] ?? [ 'minutes' => 0, 'vacation' => 0, 'sick' => 0, 'holiday' => 0, 'unpaid' => 0, ]; $worked = round($ud['minutes'] / 60, 1); $leave = $ud['vacation'] + $ud['sick']; $covered = $worked + $leave; $missing = max(0, round($fund - $covered, 1)); $overtime = max(0, round($covered - $fund, 1)); $userStats[$uid] = [ 'name' => $user['name'], 'worked' => $worked, 'vacation' => $ud['vacation'], 'sick' => $ud['sick'], 'holiday' => $ud['holiday'], 'unpaid' => $ud['unpaid'], 'leave' => $leave, 'covered' => $covered, 'missing' => $missing, 'overtime' => $overtime, ]; } $months[$m] = [ 'month' => $m, 'month_name' => $monthName, 'fund' => $fund, 'business_days' => $businessDays, 'users' => $userStats, ]; } $userIds = array_column($users, 'id'); $batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year); $balances = []; foreach ($users as $user) { $balances[$user['id']] = array_merge( ['name' => $user['name']], $batchBalances[$user['id']] ); } $holidays = CzechHolidays::getHolidays($year); successResponse([ 'months' => $months, 'balances' => $balances, 'holidays' => $holidays, 'users' => $users, 'year' => $year, ]); } function handleGetLocation(PDO $pdo, int $recordId): void { $stmt = $pdo->prepare(" SELECT a.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_lat, a.arrival_lng, a.arrival_accuracy, a.arrival_address, a.break_start, a.break_end, a.departure_time, a.departure_lat, a.departure_lng, a.departure_accuracy, a.departure_address, a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at, CONCAT(u.first_name, ' ', u.last_name) as user_name FROM attendance a JOIN users u ON a.user_id = u.id WHERE a.id = ? "); $stmt->execute([$recordId]); $record = $stmt->fetch(); if (!$record) { errorResponse('Záznam nebyl nalezen', 404); } successResponse(['record' => $record]); } function handleGetUsers(PDO $pdo): void { $stmt = $pdo->query( "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" ); $users = $stmt->fetchAll(); successResponse(['users' => $users]); } function handleCreateAttendance(PDO $pdo): void { $input = getJsonInput(); $userId = (int)($input['user_id'] ?? 0); $shiftDate = $input['shift_date'] ?? ''; $leaveType = $input['leave_type'] ?? 'work'; $leaveHours = $input['leave_hours'] ?? null; $notes = $input['notes'] ?? null; if (!$userId || !$shiftDate) { errorResponse('Vyplňte zaměstnance a datum směny'); } if ($leaveType !== 'work') { $leaveHours = $leaveHours ?: 8; $pdo->beginTransaction(); try { $stmt = $pdo->prepare(' INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes) VALUES (?, ?, ?, ?, ?) '); $stmt->execute([$userId, $shiftDate, $leaveType, $leaveHours, $notes]); updateLeaveBalance($pdo, $userId, $shiftDate, $leaveType, (float)$leaveHours); $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); throw $e; } } else { $arrivalDate = $input['arrival_date'] ?? $shiftDate; $arrivalTime = $input['arrival_time'] ?? null; $breakStartDate = $input['break_start_date'] ?? null; $breakStartTime = $input['break_start_time'] ?? null; $breakEndDate = $input['break_end_date'] ?? null; $breakEndTime = $input['break_end_time'] ?? null; $departureDate = $input['departure_date'] ?? null; $departureTime = $input['departure_time'] ?? null; /** @var mixed $rawProjectId */ $rawProjectId = $input['project_id'] ?? null; $projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null ? (int)$rawProjectId : null; $arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null; $breakStart = ($breakStartDate && $breakStartTime) ? "{$breakStartDate} {$breakStartTime}:00" : null; $breakEnd = ($breakEndDate && $breakEndTime) ? "{$breakEndDate} {$breakEndTime}:00" : null; $departure = ($departureDate && $departureTime) ? "{$departureDate} {$departureTime}:00" : null; $stmt = $pdo->prepare(" INSERT INTO attendance (user_id, shift_date, arrival_time, break_start, break_end, departure_time, leave_type, notes, project_id) VALUES (?, ?, ?, ?, ?, ?, 'work', ?, ?) "); $stmt->execute([$userId, $shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId]); } $newId = (int)$pdo->lastInsertId(); $projectLogs = $input['project_logs'] ?? []; if (!empty($projectLogs) && $leaveType === 'work') { $logStmt = $pdo->prepare( 'INSERT INTO attendance_project_logs (attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)' ); foreach ($projectLogs as $log) { $pid = (int)($log['project_id'] ?? 0); if (!$pid) { continue; } $h = (int)($log['hours'] ?? 0); $m = (int)($log['minutes'] ?? 0); if ($h === 0 && $m === 0) { continue; } $logStmt->execute([$newId, $pid, $h, $m]); } } AuditLog::logCreate('attendance', $newId, $input, 'Admin vytvořil záznam docházky'); successResponse(['id' => $newId], 'Záznam byl vytvořen'); } function handleBulkAttendance(PDO $pdo): void { $input = getJsonInput(); $monthStr = $input['month'] ?? ''; $userIds = $input['user_ids'] ?? []; $arrivalTime = trim($input['arrival_time'] ?? '08:00'); $departureTime = trim($input['departure_time'] ?? '16:30'); $breakStartTime = trim($input['break_start_time'] ?? '12:00'); $breakEndTime = trim($input['break_end_time'] ?? '12:30'); if (!$monthStr || !preg_match('/^\d{4}-\d{2}$/', $monthStr)) { errorResponse('Měsíc je povinný (formát YYYY-MM)'); } if (empty($userIds) || !is_array($userIds)) { errorResponse('Vyberte alespoň jednoho zaměstnance'); } $year = (int)substr($monthStr, 0, 4); $month = (int)substr($monthStr, 5, 2); $holidays = CzechHolidays::getHolidays($year); $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year); $inserted = 0; $skipped = 0; // Batch fetch existing records (eliminates N*M queries) $dateFrom = sprintf('%04d-%02d-01', $year, $month); $dateTo = sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth); $userIdInts = array_map('intval', $userIds); $placeholders = implode(',', array_fill(0, count($userIdInts), '?')); $existStmt = $pdo->prepare(" SELECT user_id, shift_date FROM attendance WHERE user_id IN ($placeholders) AND shift_date BETWEEN ? AND ? "); $existParams = array_merge($userIdInts, [$dateFrom, $dateTo]); $existStmt->execute($existParams); $existingRecords = []; foreach ($existStmt->fetchAll() as $row) { $existingRecords[$row['user_id'] . ':' . $row['shift_date']] = true; } $holidayStmt = $pdo->prepare(" INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes) VALUES (?, ?, 'holiday', 8, 'Státní svátek') "); $workStmt = $pdo->prepare(' INSERT INTO attendance (user_id, shift_date, arrival_time, departure_time, break_start, break_end) VALUES (?, ?, ?, ?, ?, ?) '); foreach ($userIdInts as $userId) { for ($day = 1; $day <= $daysInMonth; $day++) { $date = sprintf('%04d-%02d-%02d', $year, $month, $day); $dayOfWeek = (int)date('N', strtotime($date)); if ($dayOfWeek > 5) { continue; } $isHoliday = in_array($date, $holidays, true); if (isset($existingRecords[$userId . ':' . $date])) { $skipped++; continue; } if ($isHoliday) { $holidayStmt->execute([$userId, $date]); } else { $arrival = $date . ' ' . $arrivalTime . ':00'; $departure = $date . ' ' . $departureTime . ':00'; $breakStart = $date . ' ' . $breakStartTime . ':00'; $breakEnd = $date . ' ' . $breakEndTime . ':00'; $workStmt->execute([$userId, $date, $arrival, $departure, $breakStart, $breakEnd]); } $inserted++; } } AuditLog::logCreate('attendance', 0, [ 'month' => $monthStr, 'user_ids' => $userIds, 'inserted' => $inserted, 'skipped' => $skipped, ], "Admin hromadně vytvořil $inserted záznamů docházky pro měsíc $monthStr"); $msg = "Vytvořeno $inserted záznamů"; if ($skipped > 0) { $msg .= " ($skipped přeskočeno — již existují)"; } successResponse(['inserted' => $inserted, 'skipped' => $skipped], $msg); } function handleUpdateBalance(PDO $pdo): void { $input = getJsonInput(); $userId = (int)($input['user_id'] ?? 0); $year = (int)($input['year'] ?? date('Y')); $actionType = $input['action_type'] ?? 'edit'; if (!$userId) { errorResponse('ID uživatele je povinné'); } $stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?'); $stmt->execute([$userId, $year]); $exists = $stmt->fetch(); if ($actionType === 'reset') { if ($exists) { $stmt = $pdo->prepare( 'UPDATE leave_balances SET vacation_used = 0, sick_used = 0 WHERE user_id = ? AND year = ?' ); $stmt->execute([$userId, $year]); } successResponse(null, 'Bilance byla resetována'); } else { $vacationTotal = (float)($input['vacation_total'] ?? 160); $vacationUsed = (float)($input['vacation_used'] ?? 0); $sickUsed = (float)($input['sick_used'] ?? 0); if ($exists) { $stmt = $pdo->prepare( 'UPDATE leave_balances SET vacation_total = ?, vacation_used = ?, sick_used = ? WHERE user_id = ? AND year = ?' ); $stmt->execute([$vacationTotal, $vacationUsed, $sickUsed, $userId, $year]); } else { $stmt = $pdo->prepare( 'INSERT INTO leave_balances (user_id, year, vacation_total, vacation_used, sick_used) VALUES (?, ?, ?, ?, ?)' ); $stmt->execute([$userId, $year, $vacationTotal, $vacationUsed, $sickUsed]); } successResponse(null, 'Bilance byla aktualizována'); } } function handleUpdateAttendance(PDO $pdo, int $recordId): void { $stmt = $pdo->prepare( 'SELECT id, user_id, shift_date, arrival_time, break_start, break_end, departure_time, notes, project_id, leave_type, leave_hours FROM attendance WHERE id = ?' ); $stmt->execute([$recordId]); $record = $stmt->fetch(); if (!$record) { errorResponse('Záznam nebyl nalezen', 404); } $input = getJsonInput(); $shiftDate = $input['shift_date'] ?? $record['shift_date']; $leaveType = $input['leave_type'] ?? 'work'; $leaveHours = $input['leave_hours'] ?? null; $notes = $input['notes'] ?? null; $oldLeaveType = $record['leave_type'] ?? 'work'; $oldLeaveHours = $record['leave_hours'] ?? 0; $pdo->beginTransaction(); try { if ($leaveType !== 'work') { $leaveHours = $leaveHours ?: 8; $stmt = $pdo->prepare(' UPDATE attendance SET shift_date = ?, leave_type = ?, leave_hours = ?, arrival_time = NULL, break_start = NULL, break_end = NULL, departure_time = NULL, notes = ? WHERE id = ? '); $stmt->execute([$shiftDate, $leaveType, $leaveHours, $notes, $recordId]); if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) { updateLeaveBalance( $pdo, (int)$record['user_id'], $record['shift_date'], $oldLeaveType, -(float)$oldLeaveHours ); } updateLeaveBalance( $pdo, (int)$record['user_id'], $shiftDate, $leaveType, (float)$leaveHours ); } else { $arrivalDate = $input['arrival_date'] ?? $shiftDate; $arrivalTime = $input['arrival_time'] ?? null; $breakStartDate = $input['break_start_date'] ?? null; $breakStartTime = $input['break_start_time'] ?? null; $breakEndDate = $input['break_end_date'] ?? null; $breakEndTime = $input['break_end_time'] ?? null; $departureDate = $input['departure_date'] ?? null; $departureTime = $input['departure_time'] ?? null; /** @var mixed $rawProjectId */ $rawProjectId = $input['project_id'] ?? null; $projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null ? (int)$rawProjectId : null; $arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null; $breakStart = $breakStartTime ? "{$breakStartDate} {$breakStartTime}:00" : null; $breakEnd = $breakEndTime ? "{$breakEndDate} {$breakEndTime}:00" : null; $departure = $departureTime ? "{$departureDate} {$departureTime}:00" : null; $stmt = $pdo->prepare(" UPDATE attendance SET shift_date = ?, arrival_time = ?, break_start = ?, break_end = ?, departure_time = ?, leave_type = 'work', leave_hours = NULL, notes = ?, project_id = ? WHERE id = ? "); $stmt->execute([$shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId, $recordId]); if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) { updateLeaveBalance( $pdo, (int)$record['user_id'], $record['shift_date'], $oldLeaveType, -(float)$oldLeaveHours ); } } $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); throw $e; } $projectLogs = $input['project_logs'] ?? null; if ($projectLogs !== null) { $stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?'); $stmt->execute([$recordId]); if (!empty($projectLogs) && ($input['leave_type'] ?? 'work') === 'work') { $logStmt = $pdo->prepare( 'INSERT INTO attendance_project_logs (attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)' ); foreach ($projectLogs as $log) { $pid = (int)($log['project_id'] ?? 0); if (!$pid) { continue; } $h = (int)($log['hours'] ?? 0); $m = (int)($log['minutes'] ?? 0); if ($h === 0 && $m === 0) { continue; } $logStmt->execute([$recordId, $pid, $h, $m]); } } } AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky'); successResponse(null, 'Záznam byl aktualizován'); } function handleDeleteAttendance(PDO $pdo, int $recordId): void { $stmt = $pdo->prepare( 'SELECT id, user_id, shift_date, leave_type, leave_hours FROM attendance WHERE id = ?' ); $stmt->execute([$recordId]); $record = $stmt->fetch(); if (!$record) { errorResponse('Záznam nebyl nalezen', 404); } $leaveType = $record['leave_type'] ?? 'work'; $leaveHours = $record['leave_hours'] ?? 0; if ($leaveType !== 'work' && $leaveHours > 0) { updateLeaveBalance($pdo, (int)$record['user_id'], $record['shift_date'], $leaveType, -(float)$leaveHours); } $stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?'); $stmt->execute([$recordId]); $stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?'); $stmt->execute([$recordId]); AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky'); successResponse(null, 'Záznam byl smazán'); } function handleGetProjectReport(PDO $pdo): void { $yearParam = $_GET['year'] ?? null; $monthParam = $_GET['month'] ?? null; if ($yearParam) { $yearInt = (int)$yearParam; $currentYear = (int)date('Y'); $currentMonth = (int)date('m'); $maxMonth = ($yearInt < $currentYear) ? 12 : (($yearInt === $currentYear) ? $currentMonth : 0); if ($maxMonth === 0) { successResponse(['months' => [], 'year' => $yearInt]); return; } $startDate = sprintf('%04d-01-01', $yearInt); $lastDay = cal_days_in_month(CAL_GREGORIAN, $maxMonth, $yearInt); $endDate = sprintf('%04d-%02d-%02d', $yearInt, $maxMonth, $lastDay); $stmt = $pdo->prepare(" SELECT a.user_id, a.id as attendance_id, a.shift_date, a.arrival_time, a.departure_time, a.break_start, a.break_end, CONCAT(u.first_name, ' ', u.last_name) as user_name FROM attendance a JOIN users u ON a.user_id = u.id WHERE a.shift_date BETWEEN ? AND ? AND a.departure_time IS NOT NULL AND (a.leave_type IS NULL OR a.leave_type = 'work') ORDER BY u.last_name ASC "); $stmt->execute([$startDate, $endDate]); $workRecords = $stmt->fetchAll(); $totalWork = []; $attendanceIds = []; foreach ($workRecords as $rec) { $m = (int)date('m', strtotime($rec['shift_date'])); $uid = $rec['user_id']; $attendanceIds[] = $rec['attendance_id']; if (!isset($totalWork[$m][$uid])) { $totalWork[$m][$uid] = ['name' => $rec['user_name'], 'minutes' => 0]; } $totalWork[$m][$uid]['minutes'] += calculateWorkMinutes($rec); } $loggedMinutes = []; $monthData = []; $projectIds = []; if (!empty($attendanceIds)) { $placeholders = implode(',', array_fill(0, count($attendanceIds), '?')); $stmt = $pdo->prepare(" SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id, a.shift_date FROM attendance_project_logs pl JOIN attendance a ON pl.attendance_id = a.id WHERE pl.attendance_id IN ($placeholders) AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL) "); $stmt->execute($attendanceIds); $logs = $stmt->fetchAll(); foreach ($logs as $log) { $m = (int)date('m', strtotime($log['shift_date'])); $pid = (int)$log['project_id']; $uid = $log['user_id']; $projectIds[$pid] = true; if ($log['hours'] !== null) { $minutes = (int)$log['hours'] * 60 + (int)$log['mins']; } else { $minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60); } if (!isset($monthData[$m][$pid][$uid])) { $monthData[$m][$pid][$uid] = ['minutes' => 0]; } $monthData[$m][$pid][$uid]['minutes'] += $minutes; if (!isset($loggedMinutes[$m][$uid])) { $loggedMinutes[$m][$uid] = 0; } $loggedMinutes[$m][$uid] += $minutes; } } // "Bez projektu" = total work - logged foreach ($totalWork as $m => $users) { foreach ($users as $uid => $ud) { $logged = $loggedMinutes[$m][$uid] ?? 0; $unlogged = $ud['minutes'] - $logged; if ($unlogged > 1) { if (!isset($monthData[$m][0][$uid])) { $monthData[$m][0][$uid] = ['minutes' => 0]; } $monthData[$m][0][$uid]['minutes'] += $unlogged; } } } $projectMap = []; if (!empty($projectIds)) { try { $offersPdo = db(); $ids = array_keys($projectIds); $placeholders = implode(',', array_fill(0, count($ids), '?')); $stmt2 = $offersPdo->prepare( "SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)" ); $stmt2->execute($ids); foreach ($stmt2->fetchAll() as $p) { $projectMap[$p['id']] = $p; } } catch (\Exception $e) { error_log('Failed to fetch project names for yearly report: ' . $e->getMessage()); } } $userNames = []; foreach ($totalWork as $m => $users) { foreach ($users as $uid => $ud) { $userNames[$uid] = $ud['name']; } } $months = []; for ($m = 1; $m <= $maxMonth; $m++) { $projects = []; if (isset($monthData[$m])) { foreach ($monthData[$m] as $pid => $usersData) { $proj = $pid ? ($projectMap[$pid] ?? null) : null; $users = []; $projectTotal = 0; foreach ($usersData as $uid => $ud) { $hours = round($ud['minutes'] / 60, 1); $projectTotal += $hours; $users[] = [ 'user_id' => $uid, 'user_name' => $userNames[$uid] ?? "User #$uid", 'hours' => $hours, ]; } usort($users, fn ($a, $b) => $b['hours'] <=> $a['hours']); $projects[] = [ 'project_id' => $pid ?: null, 'project_number' => $proj ? $proj['project_number'] : null, 'project_name' => $proj ? $proj['name'] : null, 'hours' => round($projectTotal, 1), 'users' => $users, ]; } usort($projects, fn ($a, $b) => $b['hours'] <=> $a['hours']); } $months[$m] = [ 'month' => $m, 'month_name' => getCzechMonthName($m), 'projects' => $projects, ]; } successResponse(['months' => $months, 'year' => $yearInt]); return; } // Single month mode $month = $monthParam ?? date('Y-m'); $startDate = "{$month}-01"; $endDate = date('Y-m-t', strtotime($startDate)); $stmt = $pdo->prepare(" SELECT a.user_id, a.id as attendance_id, a.arrival_time, a.departure_time, a.break_start, a.break_end, CONCAT(u.first_name, ' ', u.last_name) as user_name FROM attendance a JOIN users u ON a.user_id = u.id WHERE a.shift_date BETWEEN ? AND ? AND a.departure_time IS NOT NULL AND (a.leave_type IS NULL OR a.leave_type = 'work') ORDER BY u.last_name ASC "); $stmt->execute([$startDate, $endDate]); $workRecords = $stmt->fetchAll(); $userTotalMinutes = []; $userNames = []; $attendanceIds = []; foreach ($workRecords as $rec) { $uid = $rec['user_id']; $attendanceIds[] = $rec['attendance_id']; $userNames[$uid] = $rec['user_name']; if (!isset($userTotalMinutes[$uid])) { $userTotalMinutes[$uid] = 0; } $userTotalMinutes[$uid] += calculateWorkMinutes($rec); } $aggregated = []; $projectIds = []; $userLoggedMinutes = []; if (!empty($attendanceIds)) { $placeholders = implode(',', array_fill(0, count($attendanceIds), '?')); $stmt = $pdo->prepare(" SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id FROM attendance_project_logs pl JOIN attendance a ON pl.attendance_id = a.id WHERE pl.attendance_id IN ($placeholders) AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL) "); $stmt->execute($attendanceIds); $logs = $stmt->fetchAll(); foreach ($logs as $log) { $uid = $log['user_id']; $pid = (int)$log['project_id']; $key = "{$uid}_{$pid}"; $projectIds[$pid] = true; if ($log['hours'] !== null) { $minutes = (int)$log['hours'] * 60 + (int)$log['mins']; } else { $minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60); } if (!isset($aggregated[$key])) { $aggregated[$key] = ['user_id' => $uid, 'project_id' => $pid, 'minutes' => 0]; } $aggregated[$key]['minutes'] += $minutes; if (!isset($userLoggedMinutes[$uid])) { $userLoggedMinutes[$uid] = 0; } $userLoggedMinutes[$uid] += $minutes; } } // "Bez projektu" per user foreach ($userTotalMinutes as $uid => $total) { $logged = $userLoggedMinutes[$uid] ?? 0; $unlogged = $total - $logged; if ($unlogged > 1) { $key = "{$uid}_0"; if (!isset($aggregated[$key])) { $aggregated[$key] = ['user_id' => $uid, 'project_id' => 0, 'minutes' => 0]; } $aggregated[$key]['minutes'] += $unlogged; } } $projectMap = []; if (!empty($projectIds)) { try { $offersPdo = db(); $ids = array_keys($projectIds); $placeholders = implode(',', array_fill(0, count($ids), '?')); $stmt = $offersPdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)"); $stmt->execute($ids); foreach ($stmt->fetchAll() as $p) { $projectMap[$p['id']] = $p; } } catch (\Exception $e) { error_log('Failed to fetch project names for report: ' . $e->getMessage()); } } $report = []; foreach ($aggregated as $item) { $pid = $item['project_id']; $proj = $pid ? ($projectMap[$pid] ?? null) : null; $report[] = [ 'user_id' => $item['user_id'], 'user_name' => $userNames[$item['user_id']] ?? "User #{$item['user_id']}", 'project_id' => $pid ?: null, 'project_number' => $proj ? $proj['project_number'] : null, 'project_name' => $proj ? $proj['name'] : null, 'hours' => round($item['minutes'] / 60, 2), ]; } successResponse([ 'report' => $report, 'month' => $month, ]); } function handleGetPrint(PDO $pdo): void { $month = validateMonth(); $filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null; $year = (int)substr($month, 0, 4); $monthNum = (int)substr($month, 5, 2); $startDate = "{$month}-01"; $endDate = date('Y-m-t', strtotime($startDate)); $stmt = $pdo->query( "SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name" ); $users = $stmt->fetchAll(); $sql = " SELECT a.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_address, a.break_start, a.break_end, a.departure_time, a.departure_address, a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at, CONCAT(u.first_name, ' ', u.last_name) as user_name FROM attendance a JOIN users u ON a.user_id = u.id WHERE a.shift_date BETWEEN ? AND ? "; $params = [$startDate, $endDate]; if ($filterUserId) { $sql .= ' AND a.user_id = ?'; $params[] = $filterUserId; } $sql .= ' ORDER BY u.last_name ASC, a.shift_date ASC'; $stmt = $pdo->prepare($sql); $stmt->execute($params); $records = $stmt->fetchAll(); enrichRecordsWithProjectLogs($pdo, $records); $userTotals = calculateUserTotals($records, true); $leaveBalances = getLeaveBalancesBatch($pdo, array_keys($userTotals), $year); addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum); $selectedUserName = ''; if ($filterUserId) { $stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?"); $stmt->execute([$filterUserId]); $user = $stmt->fetch(); $selectedUserName = $user ? $user['name'] : ''; } $fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum); successResponse([ 'user_totals' => $userTotals, 'leave_balances' => $leaveBalances, 'users' => $users, 'month' => $month, 'month_name' => getCzechMonthName($monthNum) . ' ' . $year, 'selected_user' => $filterUserId, 'selected_user_name' => $selectedUserName, 'year' => $year, 'fund' => $fund, ]); }