922 lines
34 KiB
PHP
922 lines
34 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
require_once __DIR__ . '/db/config.php';
|
|
|
|
const DEMO_PASSWORD_HASH = '$2y$10$wQB8HzBkzxvXZ3f4s3ItLeH2E7UEvxONlEjuhOHTosSsAXEAIS1ny';
|
|
|
|
function app_name(): string
|
|
{
|
|
$projectName = trim((string) ($_SERVER['PROJECT_NAME'] ?? ''));
|
|
return $projectName !== '' ? $projectName : 'Department Request & Approval Manager';
|
|
}
|
|
|
|
function app_base_description(): string
|
|
{
|
|
return 'Department-level request routing with employee submission, supervisor review, HOD approval, and admin/finance sign-off.';
|
|
}
|
|
|
|
function asset_version(string $relativePath): string
|
|
{
|
|
$absolutePath = __DIR__ . '/' . ltrim($relativePath, '/');
|
|
$mtime = @filemtime($absolutePath);
|
|
return $mtime ? (string) $mtime : (string) time();
|
|
}
|
|
|
|
function e(mixed $value): string
|
|
{
|
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
function redirect(string $location): never
|
|
{
|
|
header('Location: ' . $location);
|
|
exit;
|
|
}
|
|
|
|
function flash(string $type, string $message): void
|
|
{
|
|
$_SESSION['flash'] = ['type' => $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');
|
|
|
|
?>
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><?= e($metaTitle) ?></title>
|
|
<meta name="description" content="<?= e($metaDescription) ?>">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<meta property="og:title" content="<?= e($metaTitle) ?>">
|
|
<meta property="og:description" content="<?= e($metaDescription) ?>">
|
|
<meta property="twitter:title" content="<?= e($metaTitle) ?>">
|
|
<meta property="twitter:description" content="<?= e($metaDescription) ?>">
|
|
<?php if ($projectImageUrl !== ''): ?>
|
|
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
|
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
|
|
<?php endif; ?>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
|
<link rel="stylesheet" href="assets/css/custom.css?v=<?= e($cssVersion) ?>">
|
|
</head>
|
|
<?php
|
|
}
|
|
|
|
function render_nav(?array $user, string $active = 'dashboard'): void
|
|
{
|
|
?>
|
|
<nav class="navbar navbar-expand-lg site-nav sticky-top">
|
|
<div class="container">
|
|
<a class="navbar-brand d-flex align-items-center gap-2" href="index.php">
|
|
<span class="brand-mark">RA</span>
|
|
<span>
|
|
<span class="brand-title"><?= e(app_name()) ?></span>
|
|
<small class="brand-subtitle">Department workflow</small>
|
|
</span>
|
|
</a>
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
<span class="navbar-toggler-icon"></span>
|
|
</button>
|
|
<div class="collapse navbar-collapse" id="mainNav">
|
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
|
<li class="nav-item"><a class="nav-link <?= $active === 'dashboard' ? 'active' : '' ?>" href="index.php#overview">Overview</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="index.php#submit">New request</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="index.php#queue">Approval queue</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="index.php#requests">Requests</a></li>
|
|
<li class="nav-item"><a class="nav-link <?= $active === 'health' ? 'active' : '' ?>" href="healthz.php">Health</a></li>
|
|
</ul>
|
|
<?php if ($user): ?>
|
|
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-2 gap-lg-3 ms-lg-3">
|
|
<div class="user-pill">
|
|
<span class="user-name"><?= e($user['name']) ?></span>
|
|
<span class="user-meta"><?= e($user['department']) ?> · <?= e($user['role_label']) ?></span>
|
|
</div>
|
|
<form method="post" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
|
|
<input type="hidden" name="action" value="logout">
|
|
<button type="submit" class="btn btn-outline-secondary btn-sm">Sign out</button>
|
|
</form>
|
|
</div>
|
|
<?php else: ?>
|
|
<a class="btn btn-primary btn-sm ms-lg-3" href="#login-panel">Open portal</a>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
<?php
|
|
}
|
|
|
|
function render_flash_toast(?array $flash): void
|
|
{
|
|
if (!$flash) {
|
|
return;
|
|
}
|
|
|
|
$class = match ($flash['type']) {
|
|
'success' => 'text-bg-success',
|
|
'warning' => 'text-bg-warning',
|
|
'danger' => 'text-bg-danger',
|
|
default => 'text-bg-primary',
|
|
};
|
|
?>
|
|
<div class="toast-container position-fixed top-0 end-0 p-3 app-toast-zone">
|
|
<div class="toast align-items-center border-0 <?= e($class) ?>" role="status" aria-live="polite" aria-atomic="true" data-auto-toast="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body"><?= e($flash['message']) ?></div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
function render_footer(): void
|
|
{
|
|
?>
|
|
<footer class="site-footer border-top">
|
|
<div class="container py-3 d-flex flex-column flex-lg-row justify-content-between gap-2 small text-muted">
|
|
<div>Demo workflow: Supervisor → Head of Department → Admin / Finance.</div>
|
|
<div>
|
|
<a href="healthz.php" class="footer-link">System health</a>
|
|
<span class="mx-2">•</span>
|
|
<span>Default demo password: <code>Twende2026</code></span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
<?php
|
|
}
|
|
|
|
function render_scripts(): void
|
|
{
|
|
$jsVersion = asset_version('assets/js/main.js');
|
|
?>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
|
<script src="assets/js/main.js?v=<?= e($jsVersion) ?>"></script>
|
|
</html>
|
|
<?php
|
|
}
|