538 lines
16 KiB
PHP
538 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* BOHA Automation - Leave Requests API
|
|
*
|
|
* Endpoints:
|
|
* GET /api/admin/leave-requests.php - Get own leave requests
|
|
* GET /api/admin/leave-requests.php?action=pending - Get all pending requests (approver)
|
|
* GET /api/admin/leave-requests.php?action=all - Get all requests with filters (approver)
|
|
* POST /api/admin/leave-requests.php - Submit new leave request
|
|
* POST /api/admin/leave-requests.php?action=cancel - Cancel own pending request
|
|
* POST /api/admin/leave-requests.php?action=approve - Approve a request (approver)
|
|
* POST /api/admin/leave-requests.php?action=reject - Reject a request (approver)
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__) . '/config.php';
|
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
|
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
|
require_once dirname(__DIR__) . '/includes/LeaveNotification.php';
|
|
|
|
// Set headers
|
|
setCorsHeaders();
|
|
setSecurityHeaders();
|
|
setNoCacheHeaders();
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
// Require authentication
|
|
$authData = JWTAuth::requireAuth();
|
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
$action = $_GET['action'] ?? '';
|
|
|
|
try {
|
|
$pdo = db();
|
|
$userId = $authData['user_id'];
|
|
|
|
switch ($method) {
|
|
case 'GET':
|
|
if ($action === 'pending') {
|
|
requirePermission($authData, 'attendance.approve');
|
|
handleGetPending($pdo);
|
|
} elseif ($action === 'all') {
|
|
requirePermission($authData, 'attendance.approve');
|
|
handleGetAll($pdo);
|
|
} else {
|
|
handleGetMyRequests($pdo, $userId);
|
|
}
|
|
break;
|
|
|
|
case 'POST':
|
|
if ($action === 'cancel') {
|
|
handleCancelRequest($pdo, $userId);
|
|
} elseif ($action === 'approve') {
|
|
requirePermission($authData, 'attendance.approve');
|
|
handleApproveRequest($pdo, $userId, $authData);
|
|
} elseif ($action === 'reject') {
|
|
requirePermission($authData, 'attendance.approve');
|
|
handleRejectRequest($pdo, $userId, $authData);
|
|
} else {
|
|
handleSubmitRequest($pdo, $userId);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
errorResponse('Nepodporovaná metoda', 405);
|
|
}
|
|
} catch (PDOException $e) {
|
|
error_log('Leave requests API error: ' . $e->getMessage());
|
|
errorResponse('Chyba databáze', 500);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* 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 * 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.*,
|
|
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.*,
|
|
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.*,
|
|
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 * 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 * 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 * 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');
|
|
}
|