- 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>
992 lines
35 KiB
PHP
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,
|
|
]);
|
|
}
|