Files
app/api/includes/AttendanceAdmin.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

992 lines
35 KiB
PHP

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