496 lines
19 KiB
PHP
496 lines
19 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
session_start();
|
|
require_once __DIR__ . '/../db/config.php';
|
|
|
|
const APP_DEMO_COMPANIES = [
|
|
'nour-logistics' => '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();
|
|
?>
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title><?= $fullTitle ?></title>
|
|
<meta name="description" content="<?= $resolvedDescription ?>" />
|
|
<?php if ($projectDescription): ?>
|
|
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
<?php endif; ?>
|
|
<?php if ($projectImageUrl): ?>
|
|
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
<?php endif; ?>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Tajawal:wght@400;500;700;800&display=swap" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
|
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= urlencode((string)filemtime(__DIR__ . '/../assets/css/custom.css')) ?>">
|
|
</head>
|
|
<body>
|
|
<div class="app-shell">
|
|
<aside class="sidebar d-none d-lg-flex">
|
|
<div>
|
|
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/index.php')) ?>" class="brand-mark text-decoration-none">
|
|
<span class="brand-icon">N</span>
|
|
<div>
|
|
<div class="fw-semibold">NexusHR</div>
|
|
<small class="text-secondary">Leave Operations</small>
|
|
</div>
|
|
</a>
|
|
<div class="sidebar-section mt-4">
|
|
<p class="sidebar-label">Workspace</p>
|
|
<nav class="nav flex-column gap-1">
|
|
<a class="nav-link <?= $activeNav === 'dashboard' ? 'active' : '' ?>" href="<?= htmlspecialchars(currentCompanyFilterQuery('/index.php')) ?>">Dashboard</a>
|
|
<a class="nav-link <?= $activeNav === 'new' ? 'active' : '' ?>" href="<?= htmlspecialchars(currentCompanyFilterQuery('/request_leave.php')) ?>">New request</a>
|
|
<a class="nav-link <?= $activeNav === 'requests' ? 'active' : '' ?>" href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_requests.php')) ?>">All requests</a>
|
|
</nav>
|
|
</div>
|
|
<div class="sidebar-section mt-4">
|
|
<p class="sidebar-label">Roadmap</p>
|
|
<div class="roadmap-item"><span class="status-dot"></span> Attendance with GPS</div>
|
|
<div class="roadmap-item"><span class="status-dot"></span> Employee directory</div>
|
|
<div class="roadmap-item"><span class="status-dot"></span> AI HR assistant</div>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-footer">
|
|
<span class="tenant-chip">Tenant scoped</span>
|
|
<p class="mb-0 text-secondary small">Arabic-ready UI shell with clean, secure tenant isolation patterns.</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="content-area">
|
|
<header class="topbar">
|
|
<div>
|
|
<div class="eyebrow">SMB HR SaaS · first MVP slice</div>
|
|
<h1 class="page-title"><?= htmlspecialchars($pageTitle) ?></h1>
|
|
</div>
|
|
<div class="topbar-actions">
|
|
<form method="get" class="tenant-switcher">
|
|
<label for="company" class="form-label mb-1 text-secondary small">Demo company</label>
|
|
<select name="company" id="company" class="form-select form-select-sm js-company-switcher">
|
|
<?php foreach (APP_DEMO_COMPANIES as $companyId => $companyName): ?>
|
|
<option value="<?= htmlspecialchars($companyId) ?>" <?= $currentCompanyId === $companyId ? 'selected' : '' ?>><?= htmlspecialchars($companyName) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</form>
|
|
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/request_leave.php')) ?>" class="btn btn-dark btn-sm">Create request</a>
|
|
</div>
|
|
</header>
|
|
|
|
<?php if ($flash): ?>
|
|
<div class="toast-stack">
|
|
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> alert-dismissible fade show shadow-sm js-autodismiss" role="alert">
|
|
<?= htmlspecialchars($flash['message']) ?>
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<main class="page-content">
|
|
<?php
|
|
}
|
|
|
|
function renderPageEnd(): void
|
|
{
|
|
?>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
|
<script src="/assets/js/main.js?v=<?= urlencode((string)filemtime(__DIR__ . '/../assets/js/main.js')) ?>" defer></script>
|
|
</body>
|
|
</html>
|
|
<?php
|
|
}
|
|
|
|
ensureLeaveRequestTable();
|
|
currentCompanyId();
|