diff --git a/app.php b/app.php
new file mode 100644
index 0000000..51804df
--- /dev/null
+++ b/app.php
@@ -0,0 +1,921 @@
+ $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');
+
+ ?>
+
+
+
+
+
+ = e($metaTitle) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'text-bg-success',
+ 'warning' => 'text-bg-warning',
+ 'danger' => 'text-bg-danger',
+ default => 'text-bg-primary',
+ };
+ ?>
+
+
+
+
= e($flash['message']) ?>
+
+
+
+
+
+
+
+
+
+
+ {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
+ document.querySelectorAll('[data-auto-toast="true"]').forEach((toastEl) => {
+ const toast = new bootstrap.Toast(toastEl, { delay: 4200 });
+ toast.show();
+ });
- 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');
+ document.querySelectorAll('[data-char-count-target]').forEach((field) => {
+ const targetSelector = field.getAttribute('data-char-count-target');
+ const counter = targetSelector ? document.querySelector(targetSelector) : null;
+ if (!counter) {
+ return;
}
+
+ const updateCounter = () => {
+ const length = field.value.trim().length;
+ counter.textContent = `${length} characters`;
+ };
+
+ field.addEventListener('input', updateCounter);
+ updateCounter();
+ });
+
+ document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
+ anchor.addEventListener('click', (event) => {
+ const targetId = anchor.getAttribute('href');
+ if (!targetId || targetId === '#') {
+ return;
+ }
+
+ const target = document.querySelector(targetId);
+ if (!target) {
+ return;
+ }
+
+ event.preventDefault();
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ });
+ });
+
+ document.querySelectorAll('[data-copy-password]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const password = button.getAttribute('data-copy-password') || '';
+ if (!password) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(password);
+ const originalText = button.textContent;
+ button.textContent = 'Password copied';
+ window.setTimeout(() => {
+ button.textContent = originalText;
+ }, 1600);
+ } catch (error) {
+ console.error('Copy failed', error);
+ }
+ });
});
});
diff --git a/db/migrations/001_department_requests.sql b/db/migrations/001_department_requests.sql
new file mode 100644
index 0000000..dd5ebf4
--- /dev/null
+++ b/db/migrations/001_department_requests.sql
@@ -0,0 +1,26 @@
+CREATE TABLE IF NOT EXISTS department_requests (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ request_code VARCHAR(32) NOT NULL,
+ department VARCHAR(80) NOT NULL,
+ request_type VARCHAR(80) NOT NULL,
+ title VARCHAR(140) NOT NULL,
+ amount DECIMAL(12,2) DEFAULT NULL,
+ priority VARCHAR(20) NOT NULL DEFAULT 'Standard',
+ needed_by DATE DEFAULT NULL,
+ justification TEXT NOT NULL,
+ requester_name VARCHAR(120) NOT NULL,
+ requester_email VARCHAR(150) NOT NULL,
+ status VARCHAR(40) NOT NULL DEFAULT 'Pending Level 1',
+ current_stage TINYINT UNSIGNED NOT NULL DEFAULT 1,
+ final_stage TINYINT UNSIGNED NOT NULL DEFAULT 3,
+ last_comment TEXT DEFAULT NULL,
+ audit_trail LONGTEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uniq_request_code (request_code),
+ KEY idx_requester_email (requester_email),
+ KEY idx_department (department),
+ KEY idx_status_stage (status, current_stage),
+ KEY idx_updated_at (updated_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/healthz.php b/healthz.php
new file mode 100644
index 0000000..b3f6e83
--- /dev/null
+++ b/healthz.php
@@ -0,0 +1,27 @@
+query('SELECT COUNT(*) FROM department_requests')->fetchColumn();
+
+ echo json_encode([
+ 'status' => 'ok',
+ 'app' => app_name(),
+ 'time_utc' => gmdate('c'),
+ 'database' => 'connected',
+ 'requests' => $totalRequests,
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+} catch (Throwable $exception) {
+ http_response_code(500);
+ echo json_encode([
+ 'status' => 'error',
+ 'app' => app_name(),
+ 'time_utc' => gmdate('c'),
+ 'database' => 'unavailable',
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+}
diff --git a/index.php b/index.php
index 7205f3d..f5e5d6c 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,457 @@
'Purchase',
+ 'title' => '',
+ 'amount' => '',
+ 'priority' => 'Standard',
+ 'needed_by' => '',
+ 'justification' => '',
+];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $action = trim((string) ($_POST['action'] ?? ''));
+
+ if (!verify_csrf($_POST['csrf_token'] ?? null)) {
+ flash('danger', 'Your session expired. Please try again.');
+ redirect('index.php');
+ }
+
+ if ($action === 'login') {
+ $candidate = authenticate_demo_user((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
+ if ($candidate) {
+ set_current_user($candidate);
+ flash('success', 'Signed in as ' . $candidate['name'] . '.');
+ redirect('index.php');
+ }
+
+ $loginError = 'Use one of the demo accounts below and the default password Twende2026.';
+ } elseif ($action === 'logout') {
+ logout_current_user();
+ flash('primary', 'Signed out successfully.');
+ redirect('index.php');
+ } elseif ($action === 'create_request') {
+ $currentUser = require_auth();
+ if (!can_submit_requests($currentUser)) {
+ flash('warning', 'Admin / Finance accounts can review and approve requests, but submission is reserved for department staff in this first MVP slice.');
+ redirect('index.php#submit');
+ }
+
+ $result = create_request($currentUser, $_POST);
+ if (!empty($result['success'])) {
+ flash('success', 'Request ' . $result['request_code'] . ' submitted and routed to the supervisor queue.');
+ redirect('request.php?id=' . (int) $result['id']);
+ }
+
+ $formErrors = $result['errors'] ?? [];
+ $oldForm = array_merge($oldForm, $result['values'] ?? []);
+ }
+}
+
+$currentUser = current_user();
+$metrics = $currentUser ? dashboard_metrics($currentUser) : null;
+$visibleRequests = $currentUser ? fetch_visible_requests($currentUser, 12) : [];
+$queueRequests = $currentUser ? fetch_queue_requests($currentUser, 6) : [];
+$timelineSample = [
+ ['step' => 'Level 1', 'label' => 'Supervisor review'],
+ ['step' => 'Level 2', 'label' => 'Head of Department'],
+ ['step' => 'Level 3', 'label' => 'Admin / Finance'],
+];
+
+render_head(
+ 'Department Request & Approval Manager',
+ 'Submit departmental requests, route them through supervisor and HOD review, and close them with admin/finance sign-off.'
+);
?>
-
-
-
-
-
- New Style
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Analyzing your requirements and generating your website…
-
- Loading…
-
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
-
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
-
-
+
+
+
+
+
+
+
+
+
+
Internal workflow portal
+
Track departmental requests from submission to final decision.
+
This first MVP slice already covers the end-to-end flow: employees submit a request, supervisors and HODs review by department level, and Admin / Finance completes the final sign-off with a visible audit trail.
+
+
+
+ = e($sample['step']) ?>
+ = e($sample['label']) ?>
+
+
+
+
+
+
+ Workflow
+ Create → Approve → Track
+
+
+
+
+ Departments
+ Operations + HR demos
+
+
+
+
+ Demo access
+ Password: Twende2026
+
+
+
+
+
What you can test immediately
+
+ Submit a purchase, leave, access, travel, or maintenance request.
+ Switch roles using the demo accounts and approve at each department level.
+ Open a request detail page to review the full audit trail and latest note.
+
+
+
+
+
+
+
Portal sign in
+
Open the workflow dashboard
+
Use a demo staff account to experience the employee, approver, and admin states.
+
+
= e($loginError) ?>
+
+
+
+
+
+
+ User
+ Role
+ Department
+
+
+
+
+
+
+ = e($demoUser['name']) ?>
+ = e($demoUser['email']) ?>
+
+ = e($demoUser['role_label']) ?>
+ = e($demoUser['department']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Signed in as = e($currentUser['role_label']) ?>
+
Department request workflow, ready for daily use.
+
Submit requests, route them through the department chain, and keep every decision visible. The current policy is fixed to Supervisor → HOD → Admin / Finance for all departments in this first release.
+
+
+
+ = e($sample['step']) ?>
+ = e($sample['label']) ?>
+
+
+
+
+
+
+
+
Current profile
+
+
+ Name
+ = e($currentUser['name']) ?>
+
+
+ Role
+ = e($currentUser['role_label']) ?>
+
+
+ Department
+ = e($currentUser['department']) ?>
+
+
+ Access
+ = ($currentUser['approval_level'] ?? 0) > 0 ? 'Review + approve' : 'Submit + track' ?>
+
+
+
+
+
+
+
+
+
+
+ = ($currentUser['approval_level'] ?? 0) > 0 || is_admin_finance($currentUser) ? 'Visible requests' : 'My requests' ?>
+ = (int) ($metrics['visible_total'] ?? 0) ?>
+ Requests in your current scope
+
+
+
+
+ Open
+ = (int) ($metrics['open'] ?? 0) ?>
+ Still moving through approval levels
+
+
+
+
+ Awaiting my action
+ = (int) ($metrics['awaiting_action'] ?? 0) ?>
+ Items currently assigned to you
+
+
+
+
+ Approved
+ = (int) ($metrics['approved'] ?? 0) ?>
+ Closed requests in your scope
+
+
+
+
+
+
+
+
Create / input
+
+
+
Submit a new department request
+
Capture the essentials once, then let the workflow route it automatically to each level.
+
+
+
Submission disabled for Admin / Finance
+
+
+
+
+
+
Why submission is hidden
+
This MVP keeps Admin / Finance focused on final approvals and oversight. Use an employee, supervisor, or HOD account to create a request and then switch back here to complete level 3.
+
+
+
+
+
Please fix the following before submitting:
+
+
+
+
+
+
+
+
+
+
+
Approval queue
+
+
+
Requests waiting on you
+
Queue is filtered by your department and approval level.
+
+
= (int) count($queueRequests) ?> active
+
+
+
+
No items in your queue.
+
When a request reaches your level, it will appear here with a direct link to approve or reject.
+
+
+
+
+
+
+
+
Routing policy
+
Current approval design
+
All departments share the same three-step ladder in this first version. Admin can see every request; department approvers only see requests in their own department.
+
+ $definition): ?>
+
+
Level = (int) $level ?>
+
+
= e($definition['label']) ?>
+
= $level === 3 ? 'Applies across all departments' : 'Applies inside the requester’s department' ?>
+
+
+
+
+
+
+
+
+
+
+
+
Request list
+
= ($currentUser['approval_level'] ?? 0) > 0 || is_admin_finance($currentUser) ? 'Requests in view' : 'My request history' ?>
+
Open a row to inspect the approval trail, current stage, and latest decision comment.
+
+
Showing = (int) count($visibleRequests) ?> most recent records
+
+
+
+
No requests yet.
+
Create your first request above and it will appear here immediately.
+
+
+
+
+
+
+ Request
+ Department
+ Requester
+ Status
+ Updated
+ Action
+
+
+
+
+
+
+ = e($request['title']) ?>
+ = e($request['request_code']) ?> · = e($request['request_type']) ?> · = format_money($request['amount']) ?>
+
+ = e($request['department']) ?>
+
+ = e($request['requester_name']) ?>
+ = e($request['requester_email']) ?>
+
+
+ = e($request['status']) ?>
+ = e(level_label((int) $request['current_stage'])) ?>
+
+ = e(format_datetime($request['updated_at'])) ?>
+
+ Open
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/request.php b/request.php
new file mode 100644
index 0000000..f50c708
--- /dev/null
+++ b/request.php
@@ -0,0 +1,268 @@
+ 0 ? fetch_request_by_id($requestId) : null;
+$decisionError = null;
+
+if (!$request) {
+ http_response_code(404);
+ render_head('Request not found', 'The requested workflow record could not be located.');
+ ?>
+
+
+
+
+
+
Not found
+
Request record not found.
+
The request may have been removed or the link is incomplete.
+
Back to request list
+
+
+
+
+
+
+
+
+
+
+
+
Access denied
+
You cannot open this request.
+
Employees only see their own requests, while approvers only see requests inside their department.
+
Back to request list
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
← Back to request list
+
= e($request['title']) ?>
+
+ = e($request['request_code']) ?>
+ = e($request['status']) ?>
+ = e($request['priority']) ?>
+
+
+
+
Requester
+
= e($request['requester_name']) ?>
+
= e($request['department']) ?> · = e($request['request_type']) ?>
+
+
+
+
+
Approval progress
+
+ $definition): ?>
+ (int) $level) {
+ $state = 'done';
+ } elseif ((int) $request['current_stage'] === (int) $level && str_starts_with((string) $request['status'], 'Pending')) {
+ $state = 'current';
+ }
+ ?>
+
+
= (int) $level ?>
+
+
= e($definition['label']) ?>
+
= $level === 3 ? 'Global approval desk' : e($request['department']) . ' approver' ?>
+
+
+
+
+
+
+
+
+
+
Request detail
+
+
+
Request type
+
= e($request['request_type']) ?>
+
+
+
Department
+
= e($request['department']) ?>
+
+
+
Amount
+
= e(format_money($request['amount'])) ?>
+
+
+
Needed by
+
= e(format_date($request['needed_by'])) ?>
+
+
+
Created
+
= e(format_datetime($request['created_at'])) ?>
+
+
+
Last updated
+
= e(format_datetime($request['updated_at'])) ?>
+
+
+
+
Business justification
+
= nl2br(e($request['justification'])) ?>
+
+
+
+
Audit trail
+
Every action on this request
+
+
+
+
+
+
+
= e($entry['action'] ?? 'Update') ?> · = e($entry['actor'] ?? 'System') ?>
+
= e(format_datetime($entry['timestamp'] ?? '')) ?>
+
+
= e($entry['role'] ?? 'Workflow') ?>
+
+
= nl2br(e($entry['comment'])) ?>
+
+
No additional comment recorded.
+
+
+
+
+
+
+
+
+
+
+
Current status
+
Workflow snapshot
+
+
+
+
Next expected step
+
= str_starts_with((string) $request['status'], 'Pending') ? e(level_label((int) $request['current_stage'])) : e($request['status']) ?>
+
+
= e($request['status']) ?>
+
+
Latest note
+
= e((string) ($request['last_comment'] ?: 'No comment recorded yet.')) ?>
+
+
+
+
+
Access rules
+
Who can act next
+
+ Employees can submit and monitor their own requests.
+ Supervisors and HODs approve only inside their department.
+ Admin / Finance sees every request and closes level 3 approvals.
+
+
+
+
+
Decision
+
Approve or reject
+
+
+
No action required from you.
+
This request is either already closed or currently assigned to another approval level.
+
+
+
+
= e($decisionError) ?>
+
+
+
+
+
+
Approval note
+
+
+ Notes are optional for approval, but required for rejection.
+
+
+
+
+ Approve and continue
+ Reject request
+
+
+
+
+
+
+
+
+
+
+