'Nour Logistics', 'atlas-clinic' => 'Atlas Clinic', ]; function textLength(string $value): int { return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); } function appProjectName(): string { $name = $_SERVER['PROJECT_NAME'] ?? 'NexusHR'; return trim((string)$name) !== '' ? (string)$name : 'NexusHR'; } function appProjectDescription(): string { $desc = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Multi-tenant HR workspace for attendance, leave, and employee operations.'; return trim((string)$desc) !== '' ? (string)$desc : 'Multi-tenant HR workspace for attendance, leave, and employee operations.'; } function ensureLeaveRequestTable(): void { $sql = "CREATE TABLE IF NOT EXISTS leave_requests ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, company_id VARCHAR(120) NOT NULL, company_name VARCHAR(160) NOT NULL, employee_name VARCHAR(160) NOT NULL, employee_email VARCHAR(190) NOT NULL, department VARCHAR(120) NOT NULL, leave_type VARCHAR(60) NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, days_requested INT UNSIGNED NOT NULL, reason TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', rejection_reason TEXT DEFAULT NULL, submitted_by VARCHAR(120) NOT NULL DEFAULT 'owner', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, reviewed_at TIMESTAMP NULL DEFAULT NULL, INDEX idx_company_status (company_id, status), INDEX idx_company_created (company_id, created_at), INDEX idx_company_dates (company_id, start_date, end_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; db()->exec($sql); $countStmt = db()->query('SELECT COUNT(*) AS total FROM leave_requests'); $count = (int)($countStmt->fetch()['total'] ?? 0); if ($count > 0) { return; } $seed = db()->prepare( 'INSERT INTO leave_requests (company_id, company_name, employee_name, employee_email, department, leave_type, start_date, end_date, days_requested, reason, status, rejection_reason, submitted_by, reviewed_at) VALUES (:company_id, :company_name, :employee_name, :employee_email, :department, :leave_type, :start_date, :end_date, :days_requested, :reason, :status, :rejection_reason, :submitted_by, :reviewed_at)' ); $records = [ [ 'company_id' => 'nour-logistics', 'company_name' => 'Nour Logistics', 'employee_name' => 'Ahmed Saleh', 'employee_email' => 'ahmed@nour.example', 'department' => 'Operations', 'leave_type' => 'Annual leave', 'start_date' => date('Y-m-d', strtotime('+3 days')), 'end_date' => date('Y-m-d', strtotime('+5 days')), 'days_requested' => 3, 'reason' => 'Family travel booked during school break.', 'status' => 'pending', 'rejection_reason' => null, 'submitted_by' => 'manager', 'reviewed_at' => null, ], [ 'company_id' => 'nour-logistics', 'company_name' => 'Nour Logistics', 'employee_name' => 'Sara Mahmoud', 'employee_email' => 'sara@nour.example', 'department' => 'Customer Success', 'leave_type' => 'Sick leave', 'start_date' => date('Y-m-d', strtotime('-2 days')), 'end_date' => date('Y-m-d', strtotime('-1 days')), 'days_requested' => 2, 'reason' => 'Medical rest recommended by physician.', 'status' => 'approved', 'rejection_reason' => null, 'submitted_by' => 'employee', 'reviewed_at' => date('Y-m-d H:i:s', strtotime('-3 days')), ], [ 'company_id' => 'atlas-clinic', 'company_name' => 'Atlas Clinic', 'employee_name' => 'Layla Hassan', 'employee_email' => 'layla@atlas.example', 'department' => 'Front Desk', 'leave_type' => 'Emergency leave', 'start_date' => date('Y-m-d', strtotime('+1 days')), 'end_date' => date('Y-m-d', strtotime('+1 days')), 'days_requested' => 1, 'reason' => 'Urgent family matter requiring same-day absence.', 'status' => 'rejected', 'rejection_reason' => 'Critical staffing shortage on the selected day.', 'submitted_by' => 'employee', 'reviewed_at' => date('Y-m-d H:i:s', strtotime('-1 day')), ], ]; foreach ($records as $record) { foreach ($record as $key => $value) { $seed->bindValue(':' . $key, $value); } $seed->execute(); } } function currentCompanyId(): string { if (isset($_GET['company']) && is_string($_GET['company']) && array_key_exists($_GET['company'], APP_DEMO_COMPANIES)) { $_SESSION['company_id'] = $_GET['company']; } $companyId = $_SESSION['company_id'] ?? 'nour-logistics'; if (!array_key_exists($companyId, APP_DEMO_COMPANIES)) { $companyId = 'nour-logistics'; $_SESSION['company_id'] = $companyId; } return $companyId; } function currentCompanyName(): string { return APP_DEMO_COMPANIES[currentCompanyId()] ?? 'Nour Logistics'; } function currentCompanyFilterQuery(string $path, array $params = []): string { $params = array_merge(['company' => currentCompanyId()], $params); return $path . '?' . http_build_query($params); } function formatDateLabel(?string $date): string { if (!$date) { return '—'; } $time = strtotime($date); return $time ? date('d M Y', $time) : '—'; } function flash(string $type, string $message): void { $_SESSION['flash'] = ['type' => $type, 'message' => $message]; } function pullFlash(): ?array { if (!isset($_SESSION['flash'])) { return null; } $flash = $_SESSION['flash']; unset($_SESSION['flash']); return is_array($flash) ? $flash : null; } function redirectWithCompany(string $path, array $params = []): void { header('Location: ' . currentCompanyFilterQuery($path, $params)); exit; } function leaveTypeOptions(): array { return ['Annual leave', 'Sick leave', 'Emergency leave', 'Remote day', 'Unpaid leave']; } function departmentOptions(): array { return ['Operations', 'Customer Success', 'Finance', 'People Ops', 'Engineering', 'Front Desk']; } function requestStatusBadgeClass(string $status): string { return match ($status) { 'approved' => 'text-bg-success', 'rejected' => 'text-bg-danger', default => 'text-bg-warning', }; } function tenantScopedRequestById(int $id): ?array { $stmt = db()->prepare('SELECT * FROM leave_requests WHERE id = :id AND company_id = :company_id LIMIT 1'); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->bindValue(':company_id', currentCompanyId()); $stmt->execute(); $row = $stmt->fetch(); return $row ?: null; } function dashboardMetrics(): array { $stmt = db()->prepare( 'SELECT COUNT(*) AS total_requests, SUM(CASE WHEN status = "pending" THEN 1 ELSE 0 END) AS pending_requests, SUM(CASE WHEN status = "approved" THEN days_requested ELSE 0 END) AS approved_days, SUM(CASE WHEN status = "rejected" THEN 1 ELSE 0 END) AS rejected_requests FROM leave_requests WHERE company_id = :company_id' ); $stmt->bindValue(':company_id', currentCompanyId()); $stmt->execute(); $data = $stmt->fetch() ?: []; return [ 'total_requests' => (int)($data['total_requests'] ?? 0), 'pending_requests' => (int)($data['pending_requests'] ?? 0), 'approved_days' => (int)($data['approved_days'] ?? 0), 'rejected_requests' => (int)($data['rejected_requests'] ?? 0), ]; } function recentLeaveRequests(int $limit = 6): array { $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id ORDER BY created_at DESC LIMIT :limit'); $stmt->bindValue(':company_id', currentCompanyId()); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } function pendingLeaveRequests(int $limit = 5): array { $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id AND status = :status ORDER BY start_date ASC LIMIT :limit'); $stmt->bindValue(':company_id', currentCompanyId()); $stmt->bindValue(':status', 'pending'); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } function leaveRequestTrend(): array { $stmt = db()->prepare( 'SELECT DATE_FORMAT(created_at, "%b %Y") AS month_label, COUNT(*) AS total FROM leave_requests WHERE company_id = :company_id GROUP BY YEAR(created_at), MONTH(created_at) ORDER BY YEAR(created_at), MONTH(created_at)' ); $stmt->bindValue(':company_id', currentCompanyId()); $stmt->execute(); return $stmt->fetchAll(); } function filteredLeaveRequests(?string $status = null): array { if ($status && in_array($status, ['pending', 'approved', 'rejected'], true)) { $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id AND status = :status ORDER BY start_date DESC, created_at DESC'); $stmt->bindValue(':status', $status); } else { $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id ORDER BY start_date DESC, created_at DESC'); } $stmt->bindValue(':company_id', currentCompanyId()); $stmt->execute(); return $stmt->fetchAll(); } function createLeaveRequest(array $input): array { $employeeName = trim((string)($input['employee_name'] ?? '')); $employeeEmail = trim((string)($input['employee_email'] ?? '')); $department = trim((string)($input['department'] ?? '')); $leaveType = trim((string)($input['leave_type'] ?? '')); $startDate = trim((string)($input['start_date'] ?? '')); $endDate = trim((string)($input['end_date'] ?? '')); $reason = trim((string)($input['reason'] ?? '')); $errors = []; if ($employeeName === '' || textLength($employeeName) < 3) { $errors['employee_name'] = 'Enter a full employee name.'; } if (!filter_var($employeeEmail, FILTER_VALIDATE_EMAIL)) { $errors['employee_email'] = 'Enter a valid work email.'; } if (!in_array($department, departmentOptions(), true)) { $errors['department'] = 'Choose a valid department.'; } if (!in_array($leaveType, leaveTypeOptions(), true)) { $errors['leave_type'] = 'Choose a valid leave type.'; } if (!$startDate || !$endDate) { $errors['dates'] = 'Start and end dates are required.'; } $startTs = strtotime($startDate); $endTs = strtotime($endDate); if (!$startTs || !$endTs || $endTs < $startTs) { $errors['dates'] = 'End date must be on or after the start date.'; } if ($reason === '' || textLength($reason) < 10) { $errors['reason'] = 'Provide a short business reason (10+ characters).'; } $daysRequested = 0; if ($startTs && $endTs && $endTs >= $startTs) { $daysRequested = (int) floor(($endTs - $startTs) / 86400) + 1; } if ($daysRequested < 1) { $errors['days_requested'] = 'Requested leave must be at least one day.'; } if ($errors) { return ['success' => false, 'errors' => $errors]; } $stmt = db()->prepare( 'INSERT INTO leave_requests (company_id, company_name, employee_name, employee_email, department, leave_type, start_date, end_date, days_requested, reason, status, submitted_by) VALUES (:company_id, :company_name, :employee_name, :employee_email, :department, :leave_type, :start_date, :end_date, :days_requested, :reason, :status, :submitted_by)' ); $stmt->bindValue(':company_id', currentCompanyId()); $stmt->bindValue(':company_name', currentCompanyName()); $stmt->bindValue(':employee_name', $employeeName); $stmt->bindValue(':employee_email', $employeeEmail); $stmt->bindValue(':department', $department); $stmt->bindValue(':leave_type', $leaveType); $stmt->bindValue(':start_date', $startDate); $stmt->bindValue(':end_date', $endDate); $stmt->bindValue(':days_requested', $daysRequested, PDO::PARAM_INT); $stmt->bindValue(':reason', $reason); $stmt->bindValue(':status', 'pending'); $stmt->bindValue(':submitted_by', 'owner'); $stmt->execute(); return ['success' => true, 'id' => (int)db()->lastInsertId()]; } function reviewLeaveRequest(int $id, string $action, string $rejectionReason = ''): bool { $request = tenantScopedRequestById($id); if (!$request || $request['status'] !== 'pending') { return false; } $status = $action === 'approve' ? 'approved' : 'rejected'; if ($status === 'rejected' && trim($rejectionReason) === '') { return false; } $stmt = db()->prepare( 'UPDATE leave_requests SET status = :status, rejection_reason = :rejection_reason, reviewed_at = NOW() WHERE id = :id AND company_id = :company_id AND status = :current_status' ); $stmt->bindValue(':status', $status); $stmt->bindValue(':rejection_reason', $status === 'rejected' ? trim($rejectionReason) : null); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->bindValue(':company_id', currentCompanyId()); $stmt->bindValue(':current_status', 'pending'); $stmt->execute(); return $stmt->rowCount() > 0; } function renderPageStart(string $pageTitle, string $metaDescription, string $activeNav = 'dashboard'): void { $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; $projectName = appProjectName(); $fullTitle = htmlspecialchars($pageTitle . ' · ' . $projectName); $resolvedDescription = htmlspecialchars($metaDescription !== '' ? $metaDescription : appProjectDescription()); $flash = pullFlash(); $currentCompanyId = currentCompanyId(); ?> <?= $fullTitle ?>
SMB HR SaaS · first MVP slice

Create request