Files
app/api/admin/handlers/leave-requests-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

482 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/**
* Calculate number of business days between two dates (skip Sat/Sun)
*/
function calculateBusinessDays(string $dateFrom, string $dateTo): int
{
$start = new DateTime($dateFrom);
$end = new DateTime($dateTo);
$end->modify('+1 day'); // include the end date
$days = 0;
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
foreach ($period as $date) {
$dayOfWeek = (int)$date->format('N'); // 1=Mon, 7=Sun
if ($dayOfWeek <= 5) {
$days++;
}
}
return $days;
}
/**
* Get leave balance for user (reuse logic from attendance.php)
*
* @return array<string, mixed>
*/
function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): array
{
$year = $year ?: (int)date('Y');
$stmt = $pdo->prepare(
'SELECT id, user_id, year, vacation_total, vacation_used, sick_used
FROM leave_balances WHERE user_id = ? AND year = ?'
);
$stmt->execute([$userId, $year]);
$balance = $stmt->fetch();
if (!$balance) {
return [
'vacation_total' => 160,
'vacation_used' => 0,
'vacation_remaining' => 160,
'sick_used' => 0,
];
}
return [
'vacation_total' => (float)$balance['vacation_total'],
'vacation_used' => (float)$balance['vacation_used'],
'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'],
'sick_used' => (float)$balance['sick_used'],
];
}
/**
* Get hours already locked in pending requests for vacation
*/
function getPendingVacationHours(PDO $pdo, int $userId, int $year): float
{
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(total_hours), 0) as pending_hours
FROM leave_requests
WHERE user_id = ? AND leave_type = 'vacation' AND status = 'pending'
AND YEAR(date_from) = ?
");
$stmt->execute([$userId, $year]);
return (float)$stmt->fetchColumn();
}
// ============================================================================
// GET Handlers
// ============================================================================
/**
* GET - Own leave requests
*/
function handleGetMyRequests(PDO $pdo, int $userId): void
{
$stmt = $pdo->prepare("
SELECT lr.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
CONCAT(u.first_name, ' ', u.last_name) as reviewer_name
FROM leave_requests lr
LEFT JOIN users u ON lr.reviewer_id = u.id
WHERE lr.user_id = ?
ORDER BY lr.created_at DESC
");
$stmt->execute([$userId]);
$requests = $stmt->fetchAll();
successResponse($requests);
}
/**
* GET - All pending requests (for approver)
*/
function handleGetPending(PDO $pdo): void
{
$stmt = $pdo->prepare("
SELECT lr.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
FROM leave_requests lr
JOIN users u ON lr.user_id = u.id
LEFT JOIN users rv ON lr.reviewer_id = rv.id
WHERE lr.status = 'pending'
ORDER BY lr.created_at ASC
");
$stmt->execute();
$requests = $stmt->fetchAll();
successResponse([
'requests' => $requests,
'count' => count($requests),
]);
}
/**
* GET - All requests with filters (for approver)
*/
function handleGetAll(PDO $pdo): void
{
$status = $_GET['status'] ?? '';
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
$where = [];
$params = [];
if ($status && in_array($status, ['pending', 'approved', 'rejected', 'cancelled'])) {
$where[] = 'lr.status = ?';
$params[] = $status;
}
if ($userId) {
$where[] = 'lr.user_id = ?';
$params[] = $userId;
}
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$stmt = $pdo->prepare("
SELECT lr.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
FROM leave_requests lr
JOIN users u ON lr.user_id = u.id
LEFT JOIN users rv ON lr.reviewer_id = rv.id
$whereClause
ORDER BY lr.created_at DESC
LIMIT 200
");
$stmt->execute($params);
$requests = $stmt->fetchAll();
successResponse($requests);
}
// ============================================================================
// POST Handlers
// ============================================================================
/**
* POST - Submit new leave request
*/
function handleSubmitRequest(PDO $pdo, int $userId): void
{
$input = getJsonInput();
$leaveType = $input['leave_type'] ?? '';
$dateFrom = $input['date_from'] ?? '';
$dateTo = $input['date_to'] ?? '';
$notes = trim($input['notes'] ?? '');
if (!$leaveType || !$dateFrom || !$dateTo) {
errorResponse('Vyplňte všechna povinná pole');
}
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
errorResponse('Neplatný typ nepřítomnosti');
}
// Validate dates
$from = new DateTime($dateFrom);
$to = new DateTime($dateTo);
if ($to < $from) {
errorResponse('Datum "do" nesmí být před datem "od"');
}
// Calculate business days
$businessDays = calculateBusinessDays($dateFrom, $dateTo);
if ($businessDays === 0) {
errorResponse('Zvolené období neobsahuje žádné pracovní dny');
}
$totalHours = $businessDays * 8;
// Check vacation balance
if ($leaveType === 'vacation') {
$year = (int)$from->format('Y');
$balance = getLeaveBalanceForRequest($pdo, $userId, $year);
$pendingHours = getPendingVacationHours($pdo, $userId, $year);
$availableHours = $balance['vacation_remaining'] - $pendingHours;
if ($availableHours < $totalHours) {
errorResponse(
"Nemáte dostatek hodin dovolené. Dostupné: {$availableHours}h"
. " (zbývá {$balance['vacation_remaining']}h, v čekajících žádostech: {$pendingHours}h),"
. " požadujete: {$totalHours}h."
);
}
}
// Check overlapping requests
$stmt = $pdo->prepare("
SELECT id FROM leave_requests
WHERE user_id = ? AND status IN ('pending', 'approved')
AND date_from <= ? AND date_to >= ?
");
$stmt->execute([$userId, $dateTo, $dateFrom]);
if ($stmt->fetch()) {
errorResponse('Pro toto období již existuje žádost o nepřítomnost');
}
// Insert request
$stmt = $pdo->prepare("
INSERT INTO leave_requests (user_id, leave_type, date_from, date_to, total_hours, total_days, notes, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')
");
$stmt->execute([$userId, $leaveType, $dateFrom, $dateTo, $totalHours, $businessDays, $notes ?: null]);
$requestId = (int)$pdo->lastInsertId();
AuditLog::logCreate('leave_request', $requestId, [
'leave_type' => $leaveType,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'total_days' => $businessDays,
'total_hours' => $totalHours,
], "Podána žádost o nepřítomnost: $leaveType ($dateFrom - $dateTo)");
// Send email notification
try {
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
$stmt->execute([$userId]);
$employeeName = $stmt->fetchColumn() ?: 'Neznámý';
LeaveNotification::notifyNewRequest([
'leave_type' => $leaveType,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'total_days' => $businessDays,
'total_hours' => $totalHours,
'notes' => $notes,
], $employeeName);
} catch (\Exception $e) {
error_log('Leave notification error: ' . $e->getMessage());
}
successResponse(['id' => $requestId], 'Žádost byla odeslána ke schválení');
}
/**
* POST - Cancel own pending request
*/
function handleCancelRequest(PDO $pdo, int $userId): void
{
$input = getJsonInput();
$requestId = (int)($input['request_id'] ?? 0);
if (!$requestId) {
errorResponse('ID žádosti je povinné');
}
$stmt = $pdo->prepare(
'SELECT id, user_id, leave_type, date_from, date_to, total_hours,
total_days, notes, status
FROM leave_requests WHERE id = ? AND user_id = ?'
);
$stmt->execute([$requestId, $userId]);
$request = $stmt->fetch();
if (!$request) {
errorResponse('Žádost nebyla nalezena');
}
if ($request['status'] !== 'pending') {
errorResponse('Lze zrušit pouze čekající žádosti');
}
$stmt = $pdo->prepare("UPDATE leave_requests SET status = 'cancelled' WHERE id = ?");
$stmt->execute([$requestId]);
AuditLog::logUpdate(
'leave_request',
$requestId,
['status' => 'pending'],
['status' => 'cancelled'],
'Žádost o nepřítomnost zrušena zaměstnancem'
);
successResponse(null, 'Žádost byla zrušena');
}
/**
* POST - Approve a leave request
*
* @param array<string, mixed> $authData
*/
function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void
{
$input = getJsonInput();
$requestId = (int)($input['request_id'] ?? 0);
if (!$requestId) {
errorResponse('ID žádosti je povinné');
}
$stmt = $pdo->prepare(
'SELECT id, user_id, leave_type, date_from, date_to, total_hours,
total_days, status
FROM leave_requests WHERE id = ?'
);
$stmt->execute([$requestId]);
$request = $stmt->fetch();
if (!$request) {
errorResponse('Žádost nebyla nalezena');
}
if ($request['status'] !== 'pending') {
errorResponse('Lze schválit pouze čekající žádosti');
}
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
errorResponse('Nemůžete schválit vlastní žádost', 403);
}
// Re-check vacation balance
if ($request['leave_type'] === 'vacation') {
$year = (int)date('Y', strtotime($request['date_from']));
$balance = getLeaveBalanceForRequest($pdo, (int)$request['user_id'], $year);
if ($balance['vacation_remaining'] < (float)$request['total_hours']) {
errorResponse(
"Zaměstnanec nemá dostatek hodin dovolené."
. " Zbývá: {$balance['vacation_remaining']}h, požadováno: {$request['total_hours']}h."
);
}
}
// Begin transaction
$pdo->beginTransaction();
try {
// Create attendance records for each business day
$start = new DateTime($request['date_from']);
$end = new DateTime($request['date_to']);
$end->modify('+1 day');
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
$insertStmt = $pdo->prepare('
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
VALUES (?, ?, ?, 8, ?)
');
$leaveNote = "Schválená žádost #$requestId";
$totalBusinessDays = 0;
foreach ($period as $date) {
$dayOfWeek = (int)$date->format('N');
if ($dayOfWeek <= 5) {
$shiftDate = $date->format('Y-m-d');
$insertStmt->execute([
$request['user_id'],
$shiftDate,
$request['leave_type'],
$leaveNote,
]);
$totalBusinessDays++;
}
}
// Update leave balance ONCE with total hours (was N queries, one per day)
if ($totalBusinessDays > 0) {
updateLeaveBalance(
$pdo,
(int)$request['user_id'],
$request['date_from'],
$request['leave_type'],
(float)($totalBusinessDays * 8)
);
}
// Update request status
$stmt = $pdo->prepare("
UPDATE leave_requests
SET status = 'approved', reviewer_id = ?, reviewed_at = NOW()
WHERE id = ?
");
$stmt->execute([$reviewerId, $requestId]);
$pdo->commit();
AuditLog::logUpdate(
'leave_request',
$requestId,
['status' => 'pending'],
['status' => 'approved', 'reviewer_id' => $reviewerId],
'Žádost o nepřítomnost schválena'
);
successResponse(null, 'Žádost byla schválena');
} catch (\Exception $e) {
$pdo->rollBack();
error_log('Approve request error: ' . $e->getMessage());
errorResponse('Chyba při schvalování žádosti', 500);
}
}
/**
* POST - Reject a leave request
*
* @param array<string, mixed> $authData
*/
function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void
{
$input = getJsonInput();
$requestId = (int)($input['request_id'] ?? 0);
$note = trim($input['note'] ?? '');
if (!$requestId) {
errorResponse('ID žádosti je povinné');
}
if (!$note) {
errorResponse('Důvod zamítnutí je povinný');
}
$stmt = $pdo->prepare(
'SELECT id, user_id, status FROM leave_requests WHERE id = ?'
);
$stmt->execute([$requestId]);
$request = $stmt->fetch();
if (!$request) {
errorResponse('Žádost nebyla nalezena');
}
if ($request['status'] !== 'pending') {
errorResponse('Lze zamítnout pouze čekající žádosti');
}
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
errorResponse('Nemůžete zamítnout vlastní žádost', 403);
}
$stmt = $pdo->prepare("
UPDATE leave_requests
SET status = 'rejected', reviewer_id = ?, reviewer_note = ?, reviewed_at = NOW()
WHERE id = ?
");
$stmt->execute([$reviewerId, $note, $requestId]);
AuditLog::logUpdate(
'leave_request',
$requestId,
['status' => 'pending'],
['status' => 'rejected', 'reviewer_id' => $reviewerId, 'reviewer_note' => $note],
"Žádost o nepřítomnost zamítnuta: $note"
);
successResponse(null, 'Žádost byla zamítnuta');
}