$type, 'message' => $message]; } function consume_flash(): ?array { $flash = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return is_array($flash) ? $flash : null; } function csrf_token(): string { if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(16)); } return (string) $_SESSION['csrf_token']; } function verify_csrf(?string $token): bool { if (!isset($_SESSION['csrf_token']) || !is_string($token)) { return false; } return hash_equals((string) $_SESSION['csrf_token'], $token); } function demo_users(): array { return [ 'mary.employee@company.local' => [ 'name' => 'Mary Njoroge', 'email' => 'mary.employee@company.local', 'role' => 'employee', 'role_label' => 'Employee', 'approval_level' => 0, 'department' => 'Operations', 'title' => 'Operations Officer', 'scope' => 'self', ], 'peter.supervisor@company.local' => [ 'name' => 'Peter Otieno', 'email' => 'peter.supervisor@company.local', 'role' => 'supervisor', 'role_label' => 'Supervisor', 'approval_level' => 1, 'department' => 'Operations', 'title' => 'Operations Supervisor', 'scope' => 'department', ], 'susan.hod@company.local' => [ 'name' => 'Susan Wanjiku', 'email' => 'susan.hod@company.local', 'role' => 'hod', 'role_label' => 'Head of Department', 'approval_level' => 2, 'department' => 'Operations', 'title' => 'Head of Operations', 'scope' => 'department', ], 'lucy.employee@company.local' => [ 'name' => 'Lucy Atieno', 'email' => 'lucy.employee@company.local', 'role' => 'employee', 'role_label' => 'Employee', 'approval_level' => 0, 'department' => 'Human Resources', 'title' => 'HR Officer', 'scope' => 'self', ], 'david.supervisor@company.local' => [ 'name' => 'David Mwangi', 'email' => 'david.supervisor@company.local', 'role' => 'supervisor', 'role_label' => 'Supervisor', 'approval_level' => 1, 'department' => 'Human Resources', 'title' => 'HR Supervisor', 'scope' => 'department', ], 'beatrice.hod@company.local' => [ 'name' => 'Beatrice Achieng', 'email' => 'beatrice.hod@company.local', 'role' => 'hod', 'role_label' => 'Head of Department', 'approval_level' => 2, 'department' => 'Human Resources', 'title' => 'Head of HR', 'scope' => 'department', ], 'finance.admin@company.local' => [ 'name' => 'Finance Desk', 'email' => 'finance.admin@company.local', 'role' => 'admin_finance', 'role_label' => 'Admin / Finance', 'approval_level' => 3, 'department' => 'All Departments', 'title' => 'Final Approver', 'scope' => 'all', ], ]; } function find_demo_user(string $email): ?array { $key = strtolower(trim($email)); $users = demo_users(); return $users[$key] ?? null; } function current_user(): ?array { $email = $_SESSION['user_email'] ?? null; if (!is_string($email) || $email === '') { return null; } return find_demo_user($email); } function set_current_user(array $user): void { $_SESSION['user_email'] = strtolower((string) $user['email']); } function logout_current_user(): void { unset($_SESSION['user_email']); } function authenticate_demo_user(string $email, string $password): ?array { $user = find_demo_user($email); if (!$user) { return null; } return password_verify($password, DEMO_PASSWORD_HASH) ? $user : null; } function require_auth(): array { $user = current_user(); if (!$user) { flash('warning', 'Please sign in to continue.'); redirect('index.php'); } return $user; } function is_admin_finance(array $user): bool { return ($user['role'] ?? '') === 'admin_finance'; } function can_submit_requests(array $user): bool { return !is_admin_finance($user); } function workflow_levels(): array { return [ 1 => ['label' => 'Supervisor review', 'role' => 'supervisor'], 2 => ['label' => 'HOD approval', 'role' => 'hod'], 3 => ['label' => 'Admin / Finance sign-off', 'role' => 'admin_finance'], ]; } function level_label(int $level): string { $levels = workflow_levels(); return $levels[$level]['label'] ?? 'Closed'; } function request_type_options(): array { return ['Purchase', 'Leave', 'Access', 'Travel', 'Maintenance']; } function priority_options(): array { return ['Standard', 'Urgent', 'Critical']; } function format_money(mixed $amount): string { if ($amount === null || $amount === '' || (float) $amount <= 0) { return '—'; } return 'KES ' . number_format((float) $amount, 2); } function format_datetime(?string $value): string { if (!$value) { return '—'; } $timestamp = strtotime($value); return $timestamp ? date('M j, Y · H:i', $timestamp) . ' UTC' : '—'; } function format_date(?string $value): string { if (!$value) { return '—'; } $timestamp = strtotime($value); return $timestamp ? date('M j, Y', $timestamp) : '—'; } function status_badge_class(string $status): string { return match ($status) { 'Approved' => 'text-bg-success', 'Rejected' => 'text-bg-danger', default => 'text-bg-primary', }; } function priority_badge_class(string $priority): string { return match ($priority) { 'Critical' => 'text-bg-danger', 'Urgent' => 'text-bg-warning', default => 'text-bg-secondary', }; } function decode_audit(string $json): array { $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } function append_audit_entry(string $existingTrail, array $entry): string { $trail = decode_audit($existingTrail); $trail[] = $entry; return json_encode($trail, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } function generate_request_code(): string { do { $code = 'REQ-' . date('ymd') . '-' . strtoupper(bin2hex(random_bytes(2))); $stmt = db()->prepare('SELECT COUNT(*) FROM department_requests WHERE request_code = :code'); $stmt->bindValue(':code', $code); $stmt->execute(); } while ((int) $stmt->fetchColumn() > 0); return $code; } function ensure_request_table(): void { static $ready = false; if ($ready) { return; } $migrationPath = __DIR__ . '/db/migrations/001_department_requests.sql'; $sql = @file_get_contents($migrationPath); if ($sql === false) { throw new RuntimeException('Migration file is missing.'); } db()->exec($sql); seed_demo_requests(); $ready = true; } function seed_demo_requests(): void { $count = (int) db()->query('SELECT COUNT(*) FROM department_requests')->fetchColumn(); if ($count > 0) { return; } $seed = [ [ 'request_code' => 'REQ-OPS-001', 'department' => 'Operations', 'request_type' => 'Purchase', 'title' => 'Laptop replacement for field coordinator', 'amount' => 148000.00, 'priority' => 'Urgent', 'needed_by' => date('Y-m-d', strtotime('+6 days')), 'justification' => 'Current device failed during field reporting and the team cannot close daily activity logs without a replacement unit.', 'requester_name' => 'Mary Njoroge', 'requester_email' => 'mary.employee@company.local', 'status' => 'Pending Level 1', 'current_stage' => 1, 'final_stage' => 3, 'last_comment' => 'Awaiting supervisor validation.', 'audit_trail' => json_encode([ [ 'action' => 'Submitted', 'actor' => 'Mary Njoroge', 'role' => 'Employee', 'comment' => 'Requested a replacement laptop for field reporting.', 'stage' => 0, 'timestamp' => date('c', strtotime('-1 day')), ], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'created_at' => date('Y-m-d H:i:s', strtotime('-1 day')), 'updated_at' => date('Y-m-d H:i:s', strtotime('-1 day')), ], [ 'request_code' => 'REQ-HR-002', 'department' => 'Human Resources', 'request_type' => 'Leave', 'title' => 'Annual leave cover approval for recruitment desk', 'amount' => null, 'priority' => 'Standard', 'needed_by' => date('Y-m-d', strtotime('+10 days')), 'justification' => 'Cover support is needed for the recruitment desk during a planned annual leave period so candidate interviews stay on schedule.', 'requester_name' => 'Lucy Atieno', 'requester_email' => 'lucy.employee@company.local', 'status' => 'Pending Level 2', 'current_stage' => 2, 'final_stage' => 3, 'last_comment' => 'Supervisor confirmed cover plan and forwarded to HOD.', 'audit_trail' => json_encode([ [ 'action' => 'Submitted', 'actor' => 'Lucy Atieno', 'role' => 'Employee', 'comment' => 'Submitted leave cover plan for the recruitment desk.', 'stage' => 0, 'timestamp' => date('c', strtotime('-2 days')), ], [ 'action' => 'Approved', 'actor' => 'David Mwangi', 'role' => 'Supervisor', 'comment' => 'Coverage roster reviewed and ready for HOD approval.', 'stage' => 1, 'timestamp' => date('c', strtotime('-36 hours')), ], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')), 'updated_at' => date('Y-m-d H:i:s', strtotime('-36 hours')), ], [ 'request_code' => 'REQ-OPS-003', 'department' => 'Operations', 'request_type' => 'Access', 'title' => 'VPN access for external data-entry contractor', 'amount' => null, 'priority' => 'Critical', 'needed_by' => date('Y-m-d', strtotime('+3 days')), 'justification' => 'Temporary secure VPN access is required for a contractor handling backlog data uploads during the month-end close.', 'requester_name' => 'Mary Njoroge', 'requester_email' => 'mary.employee@company.local', 'status' => 'Pending Level 3', 'current_stage' => 3, 'final_stage' => 3, 'last_comment' => 'Department approvals completed; waiting for final admin/finance sign-off.', 'audit_trail' => json_encode([ [ 'action' => 'Submitted', 'actor' => 'Mary Njoroge', 'role' => 'Employee', 'comment' => 'Requested temporary VPN access for contractor onboarding.', 'stage' => 0, 'timestamp' => date('c', strtotime('-3 days')), ], [ 'action' => 'Approved', 'actor' => 'Peter Otieno', 'role' => 'Supervisor', 'comment' => 'Contractor is already scoped for the month-end data backlog.', 'stage' => 1, 'timestamp' => date('c', strtotime('-58 hours')), ], [ 'action' => 'Approved', 'actor' => 'Susan Wanjiku', 'role' => 'Head of Department', 'comment' => 'Operationally required and within department controls.', 'stage' => 2, 'timestamp' => date('c', strtotime('-30 hours')), ], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')), 'updated_at' => date('Y-m-d H:i:s', strtotime('-30 hours')), ], [ 'request_code' => 'REQ-OPS-004', 'department' => 'Operations', 'request_type' => 'Travel', 'title' => 'Travel advance for western region site visit', 'amount' => 62000.00, 'priority' => 'Standard', 'needed_by' => date('Y-m-d', strtotime('+14 days')), 'justification' => 'A site visit is scheduled for western region partner reviews and requires a travel advance for transport and accommodation.', 'requester_name' => 'Mary Njoroge', 'requester_email' => 'mary.employee@company.local', 'status' => 'Approved', 'current_stage' => 3, 'final_stage' => 3, 'last_comment' => 'Funds released to operations for site travel.', 'audit_trail' => json_encode([ [ 'action' => 'Submitted', 'actor' => 'Mary Njoroge', 'role' => 'Employee', 'comment' => 'Travel advance requested for partner site review.', 'stage' => 0, 'timestamp' => date('c', strtotime('-6 days')), ], [ 'action' => 'Approved', 'actor' => 'Peter Otieno', 'role' => 'Supervisor', 'comment' => 'Travel plan checked and itinerary attached in operations tracker.', 'stage' => 1, 'timestamp' => date('c', strtotime('-5 days 18 hours')), ], [ 'action' => 'Approved', 'actor' => 'Susan Wanjiku', 'role' => 'Head of Department', 'comment' => 'Budget available under regional engagement line item.', 'stage' => 2, 'timestamp' => date('c', strtotime('-5 days 2 hours')), ], [ 'action' => 'Approved', 'actor' => 'Finance Desk', 'role' => 'Admin / Finance', 'comment' => 'Advance approved and posted for payment processing.', 'stage' => 3, 'timestamp' => date('c', strtotime('-4 days 20 hours')), ], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'created_at' => date('Y-m-d H:i:s', strtotime('-6 days')), 'updated_at' => date('Y-m-d H:i:s', strtotime('-4 days 20 hours')), ], ]; $sql = 'INSERT INTO department_requests (request_code, department, request_type, title, amount, priority, needed_by, justification, requester_name, requester_email, status, current_stage, final_stage, last_comment, audit_trail, created_at, updated_at) VALUES (:request_code, :department, :request_type, :title, :amount, :priority, :needed_by, :justification, :requester_name, :requester_email, :status, :current_stage, :final_stage, :last_comment, :audit_trail, :created_at, :updated_at)'; $stmt = db()->prepare($sql); foreach ($seed as $row) { $stmt->bindValue(':request_code', $row['request_code']); $stmt->bindValue(':department', $row['department']); $stmt->bindValue(':request_type', $row['request_type']); $stmt->bindValue(':title', $row['title']); $row['amount'] === null ? $stmt->bindValue(':amount', null, PDO::PARAM_NULL) : $stmt->bindValue(':amount', $row['amount']); $stmt->bindValue(':priority', $row['priority']); $stmt->bindValue(':needed_by', $row['needed_by']); $stmt->bindValue(':justification', $row['justification']); $stmt->bindValue(':requester_name', $row['requester_name']); $stmt->bindValue(':requester_email', $row['requester_email']); $stmt->bindValue(':status', $row['status']); $stmt->bindValue(':current_stage', $row['current_stage'], PDO::PARAM_INT); $stmt->bindValue(':final_stage', $row['final_stage'], PDO::PARAM_INT); $stmt->bindValue(':last_comment', $row['last_comment']); $stmt->bindValue(':audit_trail', $row['audit_trail']); $stmt->bindValue(':created_at', $row['created_at']); $stmt->bindValue(':updated_at', $row['updated_at']); $stmt->execute(); } } function fetch_visible_requests(array $user, int $limit = 24): array { ensure_request_table(); $limit = max(1, min($limit, 100)); if (is_admin_finance($user)) { $sql = 'SELECT * FROM department_requests ORDER BY updated_at DESC LIMIT ' . (int) $limit; return db()->query($sql)->fetchAll(); } if (($user['approval_level'] ?? 0) > 0) { $stmt = db()->prepare('SELECT * FROM department_requests WHERE department = :department ORDER BY updated_at DESC LIMIT ' . (int) $limit); $stmt->bindValue(':department', $user['department']); $stmt->execute(); return $stmt->fetchAll(); } $stmt = db()->prepare('SELECT * FROM department_requests WHERE requester_email = :requester_email ORDER BY updated_at DESC LIMIT ' . (int) $limit); $stmt->bindValue(':requester_email', $user['email']); $stmt->execute(); return $stmt->fetchAll(); } function fetch_queue_requests(array $user, int $limit = 10): array { ensure_request_table(); if (($user['approval_level'] ?? 0) < 1) { return []; } $limit = max(1, min($limit, 50)); if (is_admin_finance($user)) { $stmt = db()->prepare("SELECT * FROM department_requests WHERE status LIKE 'Pending%' AND current_stage = :stage ORDER BY updated_at ASC LIMIT " . (int) $limit); $stmt->bindValue(':stage', $user['approval_level'], PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } $stmt = db()->prepare("SELECT * FROM department_requests WHERE department = :department AND status LIKE 'Pending%' AND current_stage = :stage ORDER BY updated_at ASC LIMIT " . (int) $limit); $stmt->bindValue(':department', $user['department']); $stmt->bindValue(':stage', $user['approval_level'], PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } function fetch_request_by_id(int $id): ?array { ensure_request_table(); $stmt = db()->prepare('SELECT * FROM department_requests WHERE id = :id LIMIT 1'); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->execute(); $request = $stmt->fetch(); return $request ?: null; } function can_view_request(array $user, array $request): bool { if (is_admin_finance($user)) { return true; } if (($user['approval_level'] ?? 0) > 0) { return $request['department'] === $user['department']; } return strtolower((string) $request['requester_email']) === strtolower((string) $user['email']); } function can_approve_request(array $user, array $request): bool { if (($user['approval_level'] ?? 0) < 1) { return false; } if (!str_starts_with((string) $request['status'], 'Pending')) { return false; } if ((int) $request['current_stage'] !== (int) $user['approval_level']) { return false; } return is_admin_finance($user) || $request['department'] === $user['department']; } function dashboard_metrics(array $user): array { $requests = fetch_visible_requests($user, 100); $queue = fetch_queue_requests($user, 100); $open = 0; $approved = 0; $rejected = 0; foreach ($requests as $request) { if (str_starts_with((string) $request['status'], 'Pending')) { $open++; } elseif ($request['status'] === 'Approved') { $approved++; } elseif ($request['status'] === 'Rejected') { $rejected++; } } return [ 'visible_total' => count($requests), 'open' => $open, 'approved' => $approved, 'rejected' => $rejected, 'awaiting_action' => count($queue), ]; } function normalize_request_input(array $input): array { return [ 'request_type' => trim((string) ($input['request_type'] ?? '')), 'title' => trim((string) ($input['title'] ?? '')), 'amount' => trim((string) ($input['amount'] ?? '')), 'priority' => trim((string) ($input['priority'] ?? 'Standard')), 'needed_by' => trim((string) ($input['needed_by'] ?? '')), 'justification' => trim((string) ($input['justification'] ?? '')), ]; } function create_request(array $user, array $input): array { ensure_request_table(); $values = normalize_request_input($input); $errors = []; if (!in_array($values['request_type'], request_type_options(), true)) { $errors['request_type'] = 'Select a valid request type.'; } $titleLength = strlen($values['title']); if ($titleLength < 8 || $titleLength > 140) { $errors['title'] = 'Use a short title between 8 and 140 characters.'; } $amount = null; if ($values['amount'] !== '') { $normalizedAmount = str_replace([',', ' '], '', $values['amount']); if (!is_numeric($normalizedAmount) || (float) $normalizedAmount < 0) { $errors['amount'] = 'Enter a valid amount or leave it blank.'; } else { $amount = round((float) $normalizedAmount, 2); } } if (!in_array($values['priority'], priority_options(), true)) { $errors['priority'] = 'Select a valid priority.'; } if ($values['needed_by'] !== '') { $timestamp = strtotime($values['needed_by']); if (!$timestamp) { $errors['needed_by'] = 'Choose a valid required-by date.'; } } if (strlen($values['justification']) < 24) { $errors['justification'] = 'Provide enough detail so approvers understand why this request is needed.'; } if ($errors) { return ['success' => false, 'errors' => $errors, 'values' => $values]; } $requestCode = generate_request_code(); $auditTrail = json_encode([ [ 'action' => 'Submitted', 'actor' => $user['name'], 'role' => $user['role_label'], 'comment' => $values['justification'], 'stage' => 0, 'timestamp' => date('c'), ], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $stmt = db()->prepare('INSERT INTO department_requests (request_code, department, request_type, title, amount, priority, needed_by, justification, requester_name, requester_email, status, current_stage, final_stage, last_comment, audit_trail) VALUES (:request_code, :department, :request_type, :title, :amount, :priority, :needed_by, :justification, :requester_name, :requester_email, :status, :current_stage, :final_stage, :last_comment, :audit_trail)'); $stmt->bindValue(':request_code', $requestCode); $stmt->bindValue(':department', $user['department']); $stmt->bindValue(':request_type', $values['request_type']); $stmt->bindValue(':title', $values['title']); $amount === null ? $stmt->bindValue(':amount', null, PDO::PARAM_NULL) : $stmt->bindValue(':amount', $amount); $stmt->bindValue(':priority', $values['priority']); $values['needed_by'] === '' ? $stmt->bindValue(':needed_by', null, PDO::PARAM_NULL) : $stmt->bindValue(':needed_by', $values['needed_by']); $stmt->bindValue(':justification', $values['justification']); $stmt->bindValue(':requester_name', $user['name']); $stmt->bindValue(':requester_email', $user['email']); $stmt->bindValue(':status', 'Pending Level 1'); $stmt->bindValue(':current_stage', 1, PDO::PARAM_INT); $stmt->bindValue(':final_stage', 3, PDO::PARAM_INT); $stmt->bindValue(':last_comment', 'Awaiting supervisor review.'); $stmt->bindValue(':audit_trail', $auditTrail); $stmt->execute(); return [ 'success' => true, 'id' => (int) db()->lastInsertId(), 'request_code' => $requestCode, ]; } function apply_request_decision(array $request, array $user, string $decision, string $comment): array { ensure_request_table(); if (!can_approve_request($user, $request)) { return ['success' => false, 'message' => 'You cannot act on this request right now.']; } $decision = strtolower($decision); if (!in_array($decision, ['approve', 'reject'], true)) { return ['success' => false, 'message' => 'Unsupported approval action.']; } $comment = trim($comment); if ($decision === 'reject' && strlen($comment) < 8) { return ['success' => false, 'message' => 'Add a short rejection reason before sending this back.']; } $currentStage = (int) $request['current_stage']; $finalStage = (int) $request['final_stage']; if ($decision === 'approve') { if ($currentStage >= $finalStage) { $newStatus = 'Approved'; $newStage = $finalStage; $message = 'Request approved and closed.'; } else { $newStage = $currentStage + 1; $newStatus = 'Pending Level ' . $newStage; $message = 'Request approved and routed to ' . strtolower(level_label($newStage)) . '.'; } $actionLabel = 'Approved'; $lastComment = $comment !== '' ? $comment : 'Approved without additional note.'; } else { $newStage = $currentStage; $newStatus = 'Rejected'; $actionLabel = 'Rejected'; $message = 'Request rejected and requester notified in the activity trail.'; $lastComment = $comment; } $auditTrail = append_audit_entry((string) $request['audit_trail'], [ 'action' => $actionLabel, 'actor' => $user['name'], 'role' => $user['role_label'], 'comment' => $lastComment, 'stage' => $currentStage, 'timestamp' => date('c'), ]); $stmt = db()->prepare('UPDATE department_requests SET status = :status, current_stage = :current_stage, last_comment = :last_comment, audit_trail = :audit_trail, updated_at = NOW() WHERE id = :id'); $stmt->bindValue(':status', $newStatus); $stmt->bindValue(':current_stage', $newStage, PDO::PARAM_INT); $stmt->bindValue(':last_comment', $lastComment); $stmt->bindValue(':audit_trail', $auditTrail); $stmt->bindValue(':id', (int) $request['id'], PDO::PARAM_INT); $stmt->execute(); return ['success' => true, 'message' => $message]; } function render_head(string $pageTitle, string $pageDescription = ''): void { $projectName = trim((string) ($_SERVER['PROJECT_NAME'] ?? '')); $projectDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? '')); $projectImageUrl = trim((string) ($_SERVER['PROJECT_IMAGE_URL'] ?? '')); $appTitle = $projectName !== '' ? $projectName : app_name(); $metaTitle = ($pageTitle !== '' && $pageTitle !== $appTitle) ? $pageTitle . ' · ' . $appTitle : $appTitle; $metaDescription = $projectDescription !== '' ? $projectDescription : ($pageDescription !== '' ? $pageDescription : app_base_description()); $cssVersion = asset_version('assets/css/custom.css'); ?>