Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
File diff suppressed because it is too large
Load Diff
@ -1,74 +1,39 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
|
||||
const alerts = document.querySelectorAll('.js-autodismiss');
|
||||
alerts.forEach((alertEl) => {
|
||||
window.setTimeout(() => {
|
||||
if (window.bootstrap && bootstrap.Alert) {
|
||||
bootstrap.Alert.getOrCreateInstance(alertEl).close();
|
||||
}
|
||||
}, 3800);
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
};
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const message = chatInput.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
appendMessage(message, 'visitor');
|
||||
chatInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('api/chat.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Artificial delay for realism
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
}
|
||||
});
|
||||
|
||||
const companySwitcher = document.querySelector('.js-company-switcher');
|
||||
if (companySwitcher) {
|
||||
companySwitcher.addEventListener('change', () => {
|
||||
companySwitcher.form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
const reasonInput = document.querySelector('.js-reason-input');
|
||||
const reasonCount = document.querySelector('.js-reason-count');
|
||||
if (reasonInput && reasonCount) {
|
||||
const syncCount = () => {
|
||||
reasonCount.textContent = String(reasonInput.value.length);
|
||||
};
|
||||
syncCount();
|
||||
reasonInput.addEventListener('input', syncCount);
|
||||
}
|
||||
|
||||
const dateInputs = document.querySelectorAll('.js-date-input');
|
||||
const daysPreview = document.querySelector('.js-days-preview');
|
||||
if (dateInputs.length === 2 && daysPreview) {
|
||||
const syncDays = () => {
|
||||
const [startInput, endInput] = dateInputs;
|
||||
const start = startInput.value ? new Date(startInput.value) : null;
|
||||
const end = endInput.value ? new Date(endInput.value) : null;
|
||||
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) {
|
||||
daysPreview.textContent = '0';
|
||||
return;
|
||||
}
|
||||
const diffDays = Math.floor((end - start) / 86400000) + 1;
|
||||
daysPreview.textContent = String(diffDays);
|
||||
};
|
||||
syncDays();
|
||||
dateInputs.forEach((input) => input.addEventListener('input', syncDays));
|
||||
dateInputs.forEach((input) => input.addEventListener('change', syncDays));
|
||||
}
|
||||
|
||||
const revealTargets = document.querySelectorAll('.hero-panel, .metric-card, .panel-card, .toast-stack .alert');
|
||||
revealTargets.forEach((element, index) => {
|
||||
element.classList.add('js-reveal');
|
||||
element.style.setProperty('--reveal-delay', `${Math.min(index * 70, 360)}ms`);
|
||||
});
|
||||
|
||||
if (prefersReducedMotion || !('IntersectionObserver' in window)) {
|
||||
revealTargets.forEach((element) => element.classList.add('is-visible'));
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries, currentObserver) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
entry.target.classList.add('is-visible');
|
||||
currentObserver.unobserve(entry.target);
|
||||
});
|
||||
}, {
|
||||
threshold: 0.14,
|
||||
rootMargin: '0px 0px -28px 0px'
|
||||
});
|
||||
|
||||
revealTargets.forEach((element) => observer.observe(element));
|
||||
});
|
||||
|
||||
20
healthz.php
20
healthz.php
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
db()->query('SELECT 1');
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'app' => 'NexusHR',
|
||||
'time' => gmdate('c'),
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'time' => gmdate('c'),
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
495
includes/app.php
495
includes/app.php
@ -1,495 +0,0 @@
|
||||
<?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();
|
||||
295
index.php
295
index.php
@ -1,155 +1,150 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$metrics = dashboardMetrics();
|
||||
$recentRequests = recentLeaveRequests();
|
||||
$pendingRequests = pendingLeaveRequests();
|
||||
$trend = leaveRequestTrend();
|
||||
|
||||
renderPageStart(
|
||||
'Leave Operations Dashboard',
|
||||
'Track leave requests, approvals, and tenant-scoped HR activity for SMB teams.'
|
||||
);
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
?>
|
||||
<section class="hero-panel">
|
||||
<div>
|
||||
<span class="section-kicker">Today’s workspace</span>
|
||||
<h2 class="section-title">A restrained, tenant-scoped HR command center for <?= htmlspecialchars(currentCompanyName()) ?>.</h2>
|
||||
<p class="section-copy">This first delivery focuses on one meaningful end-to-end workflow: create leave requests, review them safely inside the active company, and monitor approval health from a single SaaS-style dashboard.</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/request_leave.php')) ?>" class="btn btn-dark">Start a new leave request</a>
|
||||
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_requests.php')) ?>" class="btn btn-outline-secondary">Review queue</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Requests</div>
|
||||
<div class="metric-value"><?= $metrics['total_requests'] ?></div>
|
||||
<p class="metric-footnote">Scoped to current company only.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Pending approvals</div>
|
||||
<div class="metric-value"><?= $metrics['pending_requests'] ?></div>
|
||||
<p class="metric-footnote">Needs manager action.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Approved days</div>
|
||||
<div class="metric-value"><?= $metrics['approved_days'] ?></div>
|
||||
<p class="metric-footnote">Deductible leave usage.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Rejected</div>
|
||||
<div class="metric-value"><?= $metrics['rejected_requests'] ?></div>
|
||||
<p class="metric-footnote">With auditable reason.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="row g-4">
|
||||
<div class="col-xl-8">
|
||||
<article class="panel-card h-100">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<span class="section-kicker">Recent requests</span>
|
||||
<h3 class="panel-title">Latest leave activity</h3>
|
||||
</div>
|
||||
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_requests.php')) ?>" class="btn btn-outline-secondary btn-sm">See all</a>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
|
||||
<?php if (!$recentRequests): ?>
|
||||
<div class="empty-state">
|
||||
<h4>No leave requests yet</h4>
|
||||
<p>Create the first request to turn this into a live approval queue.</p>
|
||||
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/request_leave.php')) ?>" class="btn btn-dark btn-sm">Create request</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0 hr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Employee</th>
|
||||
<th>Type</th>
|
||||
<th>Dates</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recentRequests as $request): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= htmlspecialchars($request['employee_name']) ?></div>
|
||||
<div class="text-secondary small"><?= htmlspecialchars($request['department']) ?></div>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($request['leave_type']) ?></td>
|
||||
<td><?= htmlspecialchars(formatDateLabel($request['start_date'])) ?> → <?= htmlspecialchars(formatDateLabel($request['end_date'])) ?></td>
|
||||
<td><span class="badge <?= htmlspecialchars(requestStatusBadgeClass($request['status'])) ?>"><?= htmlspecialchars(ucfirst($request['status'])) ?></span></td>
|
||||
<td class="text-end"><a href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_request.php', ['id' => (int)$request['id']])) ?>" class="btn btn-sm btn-outline-dark">Open</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<article class="panel-card mb-4">
|
||||
<span class="section-kicker">Approval queue</span>
|
||||
<h3 class="panel-title">Items requiring attention</h3>
|
||||
<?php if (!$pendingRequests): ?>
|
||||
<div class="empty-state compact">
|
||||
<h4>All clear</h4>
|
||||
<p>No pending requests for this tenant.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="queue-list">
|
||||
<?php foreach ($pendingRequests as $request): ?>
|
||||
<a class="queue-item" href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_request.php', ['id' => (int)$request['id']])) ?>">
|
||||
<div>
|
||||
<div class="fw-semibold"><?= htmlspecialchars($request['employee_name']) ?></div>
|
||||
<div class="small text-secondary"><?= htmlspecialchars($request['leave_type']) ?> · <?= (int)$request['days_requested'] ?> day(s)</div>
|
||||
</div>
|
||||
<span class="queue-date"><?= htmlspecialchars(formatDateLabel($request['start_date'])) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
|
||||
<article class="panel-card">
|
||||
<span class="section-kicker">Trend snapshot</span>
|
||||
<h3 class="panel-title">Submission volume</h3>
|
||||
<?php if (!$trend): ?>
|
||||
<div class="empty-state compact">
|
||||
<h4>No trend data</h4>
|
||||
<p>Recent activity will appear after the first submission.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="trend-list">
|
||||
<?php foreach ($trend as $item): ?>
|
||||
<div class="trend-row">
|
||||
<span><?= htmlspecialchars($item['month_label']) ?></span>
|
||||
<div class="trend-bar-wrap">
|
||||
<div class="trend-bar" style="width: <?= max(18, min(100, ((int)$item['total']) * 24)) ?>%"></div>
|
||||
</div>
|
||||
<strong><?= (int)$item['total'] ?></strong>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<?php renderPageEnd(); ?>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$request = $id > 0 ? tenantScopedRequestById($id) : null;
|
||||
|
||||
if (!$request) {
|
||||
flash('warning', 'That request could not be found inside the active tenant.');
|
||||
redirectWithCompany('/leave_requests.php');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
$reason = trim((string)($_POST['rejection_reason'] ?? ''));
|
||||
|
||||
if (in_array($action, ['approve', 'reject'], true) && reviewLeaveRequest((int)$request['id'], $action, $reason)) {
|
||||
flash('success', $action === 'approve' ? 'Leave request approved.' : 'Leave request rejected with reason recorded.');
|
||||
redirectWithCompany('/leave_request.php', ['id' => (int)$request['id']]);
|
||||
}
|
||||
|
||||
flash('danger', 'Unable to update request. Rejections require a reason and only pending items can be reviewed.');
|
||||
redirectWithCompany('/leave_request.php', ['id' => (int)$request['id']]);
|
||||
}
|
||||
|
||||
$request = tenantScopedRequestById($id);
|
||||
|
||||
renderPageStart(
|
||||
'Leave Request Detail',
|
||||
'Inspect a tenant-scoped leave request, review the audit trail, and approve or reject safely.',
|
||||
'requests'
|
||||
);
|
||||
?>
|
||||
<section class="row g-4">
|
||||
<div class="col-xl-8">
|
||||
<article class="panel-card">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<span class="section-kicker">Request detail</span>
|
||||
<h2 class="panel-title mb-1"><?= htmlspecialchars($request['employee_name']) ?> · <?= htmlspecialchars($request['leave_type']) ?></h2>
|
||||
<p class="text-secondary mb-0">Request #<?= (int)$request['id'] ?> for <?= htmlspecialchars($request['company_name']) ?>.</p>
|
||||
</div>
|
||||
<span class="badge <?= htmlspecialchars(requestStatusBadgeClass($request['status'])) ?> fs-6"><?= htmlspecialchars(ucfirst($request['status'])) ?></span>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-card"><span>Employee</span><strong><?= htmlspecialchars($request['employee_name']) ?></strong><small><?= htmlspecialchars($request['employee_email']) ?></small></div>
|
||||
<div class="detail-card"><span>Department</span><strong><?= htmlspecialchars($request['department']) ?></strong><small><?= htmlspecialchars($request['submitted_by']) ?> submitted</small></div>
|
||||
<div class="detail-card"><span>Dates</span><strong><?= htmlspecialchars(formatDateLabel($request['start_date'])) ?> → <?= htmlspecialchars(formatDateLabel($request['end_date'])) ?></strong><small><?= (int)$request['days_requested'] ?> day(s)</small></div>
|
||||
<div class="detail-card"><span>Created</span><strong><?= htmlspecialchars(formatDateLabel($request['created_at'])) ?></strong><small><?= htmlspecialchars(date('H:i', strtotime($request['created_at']))) ?> UTC</small></div>
|
||||
</div>
|
||||
|
||||
<div class="info-block mt-4">
|
||||
<div class="small text-secondary text-uppercase mb-2">Reason</div>
|
||||
<p class="mb-0"><?= nl2br(htmlspecialchars($request['reason'])) ?></p>
|
||||
</div>
|
||||
|
||||
<?php if ($request['status'] === 'rejected' && $request['rejection_reason']): ?>
|
||||
<div class="info-block danger mt-4">
|
||||
<div class="small text-danger text-uppercase mb-2">Rejection reason</div>
|
||||
<p class="mb-0"><?= nl2br(htmlspecialchars($request['rejection_reason'])) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<article class="panel-card mb-4">
|
||||
<span class="section-kicker">Review actions</span>
|
||||
<h3 class="panel-title">Approve or reject</h3>
|
||||
<?php if ($request['status'] !== 'pending'): ?>
|
||||
<div class="empty-state compact left">
|
||||
<h4>Already reviewed</h4>
|
||||
<p>This request is locked after status resolution for a cleaner audit trail.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" class="d-grid gap-3">
|
||||
<button type="submit" name="action" value="approve" class="btn btn-dark">Approve request</button>
|
||||
<div>
|
||||
<label for="rejection_reason" class="form-label">Rejection reason</label>
|
||||
<textarea class="form-control" id="rejection_reason" name="rejection_reason" rows="4" placeholder="Explain the business reason if you reject."></textarea>
|
||||
</div>
|
||||
<button type="submit" name="action" value="reject" class="btn btn-outline-danger">Reject request</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
|
||||
<article class="panel-card">
|
||||
<span class="section-kicker">Isolation check</span>
|
||||
<h3 class="panel-title">Tenant safety</h3>
|
||||
<ul class="feature-list mb-0">
|
||||
<li>Detail query is filtered by request id + active <code>company_id</code>.</li>
|
||||
<li>List, dashboard, and create flow reuse the same tenant context.</li>
|
||||
<li>Switching companies changes visible records without cross-tenant leakage.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<?php renderPageEnd(); ?>
|
||||
@ -1,70 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
|
||||
$statusFilter = isset($_GET['status']) ? (string)$_GET['status'] : null;
|
||||
$requests = filteredLeaveRequests($statusFilter);
|
||||
|
||||
renderPageStart(
|
||||
'Leave Request Queue',
|
||||
'Review all leave requests for the active tenant with status filters and quick access to detail.',
|
||||
'requests'
|
||||
);
|
||||
?>
|
||||
<section class="panel-card">
|
||||
<div class="panel-head flex-column flex-lg-row gap-3 align-items-lg-center">
|
||||
<div>
|
||||
<span class="section-kicker">Approval queue</span>
|
||||
<h2 class="panel-title mb-1">All leave requests</h2>
|
||||
<p class="text-secondary mb-0">Filtered by active company context: <?= htmlspecialchars(currentCompanyName()) ?>.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<?php foreach (['all' => 'All', 'pending' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'] as $value => $label): ?>
|
||||
<?php $isActive = (($statusFilter ?? 'all') === $value) || ($value === 'all' && !$statusFilter); ?>
|
||||
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_requests.php', $value === 'all' ? [] : ['status' => $value])) ?>" class="btn btn-sm <?= $isActive ? 'btn-dark' : 'btn-outline-secondary' ?>"><?= htmlspecialchars($label) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!$requests): ?>
|
||||
<div class="empty-state">
|
||||
<h4>No matching requests</h4>
|
||||
<p>Try another filter or add the first leave request for this company.</p>
|
||||
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/request_leave.php')) ?>" class="btn btn-dark btn-sm">Create request</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle hr-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Employee</th>
|
||||
<th>Department</th>
|
||||
<th>Window</th>
|
||||
<th>Days</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($requests as $request): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= htmlspecialchars($request['employee_name']) ?></div>
|
||||
<div class="small text-secondary"><?= htmlspecialchars($request['employee_email']) ?></div>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($request['department']) ?></td>
|
||||
<td>
|
||||
<div><?= htmlspecialchars(formatDateLabel($request['start_date'])) ?></div>
|
||||
<div class="small text-secondary">to <?= htmlspecialchars(formatDateLabel($request['end_date'])) ?></div>
|
||||
</td>
|
||||
<td><?= (int)$request['days_requested'] ?></td>
|
||||
<td><span class="badge <?= htmlspecialchars(requestStatusBadgeClass($request['status'])) ?>"><?= htmlspecialchars(ucfirst($request['status'])) ?></span></td>
|
||||
<td class="text-end"><a class="btn btn-sm btn-outline-dark" href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_request.php', ['id' => (int)$request['id']])) ?>">View detail</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php renderPageEnd(); ?>
|
||||
@ -1,134 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
|
||||
$errors = [];
|
||||
$old = [
|
||||
'employee_name' => '',
|
||||
'employee_email' => '',
|
||||
'department' => 'Operations',
|
||||
'leave_type' => 'Annual leave',
|
||||
'start_date' => date('Y-m-d', strtotime('+1 day')),
|
||||
'end_date' => date('Y-m-d', strtotime('+1 day')),
|
||||
'reason' => '',
|
||||
];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$old = array_merge($old, $_POST);
|
||||
$result = createLeaveRequest($_POST);
|
||||
if (!empty($result['success'])) {
|
||||
flash('success', 'Leave request submitted and added to the tenant-scoped approval queue.');
|
||||
redirectWithCompany('/leave_request.php', ['id' => (int)$result['id'], 'created' => 1]);
|
||||
}
|
||||
$errors = $result['errors'] ?? [];
|
||||
}
|
||||
|
||||
renderPageStart(
|
||||
'Create Leave Request',
|
||||
'Submit a leave request with validation, tenant scoping, and approval-ready review status.',
|
||||
'new'
|
||||
);
|
||||
?>
|
||||
<section class="row g-4">
|
||||
<div class="col-xl-7">
|
||||
<article class="panel-card">
|
||||
<div class="panel-head align-items-start">
|
||||
<div>
|
||||
<span class="section-kicker">Request workflow</span>
|
||||
<h2 class="panel-title mb-1">Submit leave for approval</h2>
|
||||
<p class="text-secondary mb-0">Every request is stored with <code>company_id</code> and only appears inside the active tenant workspace.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
Please fix the highlighted fields and resubmit.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="row g-3" novalidate>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="employee_name">Employee name</label>
|
||||
<input type="text" class="form-control <?= isset($errors['employee_name']) ? 'is-invalid' : '' ?>" id="employee_name" name="employee_name" value="<?= htmlspecialchars((string)$old['employee_name']) ?>" required>
|
||||
<?php if (isset($errors['employee_name'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['employee_name']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="employee_email">Work email</label>
|
||||
<input type="email" class="form-control <?= isset($errors['employee_email']) ? 'is-invalid' : '' ?>" id="employee_email" name="employee_email" value="<?= htmlspecialchars((string)$old['employee_email']) ?>" required>
|
||||
<?php if (isset($errors['employee_email'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['employee_email']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="department">Department</label>
|
||||
<select class="form-select <?= isset($errors['department']) ? 'is-invalid' : '' ?>" id="department" name="department">
|
||||
<?php foreach (departmentOptions() as $department): ?>
|
||||
<option value="<?= htmlspecialchars($department) ?>" <?= $old['department'] === $department ? 'selected' : '' ?>><?= htmlspecialchars($department) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (isset($errors['department'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['department']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="leave_type">Leave type</label>
|
||||
<select class="form-select <?= isset($errors['leave_type']) ? 'is-invalid' : '' ?>" id="leave_type" name="leave_type">
|
||||
<?php foreach (leaveTypeOptions() as $leaveType): ?>
|
||||
<option value="<?= htmlspecialchars($leaveType) ?>" <?= $old['leave_type'] === $leaveType ? 'selected' : '' ?>><?= htmlspecialchars($leaveType) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (isset($errors['leave_type'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['leave_type']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="start_date">Start date</label>
|
||||
<input type="date" class="form-control <?= isset($errors['dates']) ? 'is-invalid' : '' ?> js-date-input" id="start_date" name="start_date" value="<?= htmlspecialchars((string)$old['start_date']) ?>" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="end_date">End date</label>
|
||||
<input type="date" class="form-control <?= isset($errors['dates']) ? 'is-invalid' : '' ?> js-date-input" id="end_date" name="end_date" value="<?= htmlspecialchars((string)$old['end_date']) ?>" required>
|
||||
<?php if (isset($errors['dates'])): ?><div class="invalid-feedback d-block"><?= htmlspecialchars($errors['dates']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="reason">Business reason</label>
|
||||
<textarea class="form-control <?= isset($errors['reason']) ? 'is-invalid' : '' ?> js-reason-input" id="reason" name="reason" rows="5" maxlength="500" required><?= htmlspecialchars((string)$old['reason']) ?></textarea>
|
||||
<div class="form-hint d-flex justify-content-between mt-2">
|
||||
<span>Keep this short, factual, and audit-friendly.</span>
|
||||
<span class="text-secondary"><span class="js-reason-count"><?= textLength((string)$old['reason']) ?></span>/500</span>
|
||||
</div>
|
||||
<?php if (isset($errors['reason'])): ?><div class="invalid-feedback d-block"><?= htmlspecialchars($errors['reason']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 pt-2 border-top">
|
||||
<div class="request-preview">
|
||||
<div class="small text-secondary text-uppercase">Calculated duration</div>
|
||||
<div class="fw-semibold"><span class="js-days-preview">1</span> business days requested</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= htmlspecialchars(currentCompanyFilterQuery('/leave_requests.php')) ?>" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-dark">Submit request</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-5">
|
||||
<article class="panel-card mb-4">
|
||||
<span class="section-kicker">What this proves</span>
|
||||
<h3 class="panel-title">Thin but complete SaaS slice</h3>
|
||||
<ul class="feature-list">
|
||||
<li>Tenant switcher demonstrates isolated data views.</li>
|
||||
<li>Server-side validation protects the workflow.</li>
|
||||
<li>Approval-ready status starts at pending.</li>
|
||||
<li>Dashboard and list update immediately after submit.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel-card">
|
||||
<span class="section-kicker">Next modules</span>
|
||||
<h3 class="panel-title">Planned expansion</h3>
|
||||
<div class="roadmap-grid">
|
||||
<div class="roadmap-card"><strong>Attendance</strong><span>Clock in/out + GPS capture</span></div>
|
||||
<div class="roadmap-card"><strong>Employees</strong><span>Directory, roles, and contracts</span></div>
|
||||
<div class="roadmap-card"><strong>Notifications</strong><span>Email + database events</span></div>
|
||||
<div class="roadmap-card"><strong>AI assistant</strong><span>Natural-language HR insights</span></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<?php renderPageEnd(); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user