From a64647811020c967dd1b24b7122f14d665a9c3ba Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 19 May 2026 10:06:37 +0000 Subject: [PATCH] MOSHA V4 --- app.php | 921 +++++++++++++++++++++ assets/css/custom.css | 951 ++++++++++++++-------- assets/js/main.js | 88 +- db/migrations/001_department_requests.sql | 26 + healthz.php | 27 + index.php | 597 ++++++++++---- request.php | 268 ++++++ 7 files changed, 2353 insertions(+), 525 deletions(-) create mode 100644 app.php create mode 100644 db/migrations/001_department_requests.sql create mode 100644 healthz.php create mode 100644 request.php 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', + }; + ?> +
+
+
+
+ +
+
+
+ + + + + + + { - 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… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — 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.

+
+ +
+ + +
+ +
+
+
+
+ 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.

+ + + +
+ + +
+ + +
+
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + +
UserRoleDepartment
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
Signed in as
+

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.

+
+ +
+ + +
+ +
+ +
+
+
+
Current profile
+
+
+ Name + +
+
+ Role + +
+
+ Department + +
+
+ Access + 0 ? 'Review + approve' : 'Submit + track' ?> +
+
+
+
+
+
+ +
+
+
+ 0 || is_admin_finance($currentUser) ? 'Visible requests' : 'My requests' ?> + + Requests in your current scope +
+
+
+
+ Open + + Still moving through approval levels +
+
+
+
+ Awaiting my action + + Items currently assigned to you +
+
+
+
+ Approved + + 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.

+
+ + + + +
+ + +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+ Explain the request clearly so each approver can act without extra follow-up. + 0 characters +
+
+
+
+
Submission starts at supervisor review and automatically advances through HOD and Admin / Finance.
+ +
+
+ +
+
+ +
+
+
Approval queue
+
+
+

Requests waiting on you

+

Queue is filtered by your department and approval level.

+
+ 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 +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
Request list
+

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 most recent records +
+ +
+ No requests yet. +

Create your first request above and it will appear here immediately.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
RequestDepartmentRequesterStatusUpdatedAction
+
+
· ·
+
+
+
+
+ +
+
+ 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 +

+
+ + + +
+
+
+
Requester
+
+
·
+
+
+ +
+
Approval progress
+
+ $definition): ?> + (int) $level) { + $state = 'done'; + } elseif ((int) $request['current_stage'] === (int) $level && str_starts_with((string) $request['status'], 'Pending')) { + $state = 'current'; + } + ?> +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
Request detail
+
+
+ Request type +
+
+
+ Department +
+
+
+ Amount +
+
+
+ Needed by +
+
+
+ Created +
+
+
+ Last updated +
+
+
+
+ Business justification +

+
+ +
+
Audit trail
+

Every action on this request

+
+ +
+
+
+
+
·
+
+
+
+ +

+ +

No additional comment recorded.

+ +
+
+ +
+
+
+ +
+
+
Current status
+

Workflow snapshot

+
+
+
+
Next expected step
+
+
+ +
+
Latest note
+

+
+
+ +
+
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.

+
+ + + + +
+ + +
+ + +
+ Notes are optional for approval, but required for rejection. + 0 characters +
+
+
+ + +
+
+ +
+
+
+
+
+ + +