MOSHA V4
This commit is contained in:
parent
682d472f26
commit
a646478110
921
app.php
Normal file
921
app.php
Normal file
@ -0,0 +1,921 @@
|
||||
<?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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,39 +1,59 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
26
db/migrations/001_department_requests.sql
Normal file
26
db/migrations/001_department_requests.sql
Normal file
@ -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;
|
||||
27
healthz.php
Normal file
27
healthz.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
ensure_request_table();
|
||||
$totalRequests = (int) db()->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);
|
||||
}
|
||||
597
index.php
597
index.php
@ -1,150 +1,457 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
ensure_request_table();
|
||||
|
||||
$flash = consume_flash();
|
||||
$currentUser = current_user();
|
||||
$loginError = null;
|
||||
$formErrors = [];
|
||||
$oldForm = [
|
||||
'request_type' => '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.'
|
||||
);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
<?php render_nav($currentUser, 'dashboard'); ?>
|
||||
<?php render_flash_toast($flash); ?>
|
||||
|
||||
<?php if (!$currentUser): ?>
|
||||
<main class="public-shell">
|
||||
<section class="container py-4 py-lg-5">
|
||||
<div class="row g-4 align-items-stretch">
|
||||
<div class="col-lg-7">
|
||||
<div class="shell-card hero-panel h-100">
|
||||
<div class="eyebrow">Internal workflow portal</div>
|
||||
<h1 class="hero-title">Track departmental requests from submission to final decision.</h1>
|
||||
<p class="hero-copy">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.</p>
|
||||
<div class="route-band mt-4">
|
||||
<?php foreach ($timelineSample as $sample): ?>
|
||||
<div class="route-chip">
|
||||
<span class="route-step"><?= e($sample['step']) ?></span>
|
||||
<span class="route-label"><?= e($sample['label']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="row g-3 mt-4">
|
||||
<div class="col-sm-4">
|
||||
<div class="mini-stat">
|
||||
<span class="mini-stat-label">Workflow</span>
|
||||
<strong>Create → Approve → Track</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="mini-stat">
|
||||
<span class="mini-stat-label">Departments</span>
|
||||
<strong>Operations + HR demos</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="mini-stat">
|
||||
<span class="mini-stat-label">Demo access</span>
|
||||
<strong>Password: Twende2026</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface-panel mt-4">
|
||||
<div class="surface-panel-title">What you can test immediately</div>
|
||||
<ul class="feature-list mb-0">
|
||||
<li>Submit a purchase, leave, access, travel, or maintenance request.</li>
|
||||
<li>Switch roles using the demo accounts and approve at each department level.</li>
|
||||
<li>Open a request detail page to review the full audit trail and latest note.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="shell-card auth-card h-100" id="login-panel">
|
||||
<div class="section-kicker">Portal sign in</div>
|
||||
<h2 class="section-title">Open the workflow dashboard</h2>
|
||||
<p class="section-copy">Use a demo staff account to experience the employee, approver, and admin states.</p>
|
||||
<?php if ($loginError): ?>
|
||||
<div class="alert alert-danger small" role="alert"><?= e($loginError) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="vstack gap-3 mt-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="login">
|
||||
<div>
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input class="form-control" id="email" name="email" type="email" placeholder="mary.employee@company.local" required>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<button type="button" class="btn btn-link btn-sm px-0" data-copy-password="Twende2026">Copy demo password</button>
|
||||
</div>
|
||||
<input class="form-control" id="password" name="password" type="password" placeholder="Twende2026" required>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
</form>
|
||||
<div class="table-responsive mt-4 demo-table-wrap">
|
||||
<table class="table align-middle demo-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Department</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (demo_users() as $demoUser): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= e($demoUser['name']) ?></div>
|
||||
<div class="text-muted small"><?= e($demoUser['email']) ?></div>
|
||||
</td>
|
||||
<td><?= e($demoUser['role_label']) ?></td>
|
||||
<td><?= e($demoUser['department']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php else: ?>
|
||||
<main class="app-shell">
|
||||
<section class="container py-4" id="overview">
|
||||
<div class="shell-card hero-panel dashboard-hero mb-4">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-8">
|
||||
<div class="eyebrow">Signed in as <?= e($currentUser['role_label']) ?></div>
|
||||
<h1 class="hero-title">Department request workflow, ready for daily use.</h1>
|
||||
<p class="hero-copy">Submit requests, route them through the department chain, and keep every decision visible. The current policy is fixed to <strong>Supervisor → HOD → Admin / Finance</strong> for all departments in this first release.</p>
|
||||
<div class="route-band mt-4">
|
||||
<?php foreach ($timelineSample as $sample): ?>
|
||||
<div class="route-chip">
|
||||
<span class="route-step"><?= e($sample['step']) ?></span>
|
||||
<span class="route-label"><?= e($sample['label']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="hero-actions mt-4">
|
||||
<?php if (can_submit_requests($currentUser)): ?>
|
||||
<a href="#submit" class="btn btn-primary">Create request</a>
|
||||
<?php endif; ?>
|
||||
<a href="#queue" class="btn btn-outline-secondary">View approval queue</a>
|
||||
<a href="#requests" class="btn btn-outline-secondary">Open request list</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="surface-panel compact-panel">
|
||||
<div class="surface-panel-title">Current profile</div>
|
||||
<div class="profile-grid">
|
||||
<div>
|
||||
<span class="text-muted d-block small">Name</span>
|
||||
<strong><?= e($currentUser['name']) ?></strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted d-block small">Role</span>
|
||||
<strong><?= e($currentUser['role_label']) ?></strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted d-block small">Department</span>
|
||||
<strong><?= e($currentUser['department']) ?></strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted d-block small">Access</span>
|
||||
<strong><?= ($currentUser['approval_level'] ?? 0) > 0 ? 'Review + approve' : 'Submit + track' ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="shell-card metric-card h-100">
|
||||
<span class="metric-label"><?= ($currentUser['approval_level'] ?? 0) > 0 || is_admin_finance($currentUser) ? 'Visible requests' : 'My requests' ?></span>
|
||||
<strong class="metric-value"><?= (int) ($metrics['visible_total'] ?? 0) ?></strong>
|
||||
<span class="metric-subtext">Requests in your current scope</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="shell-card metric-card h-100">
|
||||
<span class="metric-label">Open</span>
|
||||
<strong class="metric-value"><?= (int) ($metrics['open'] ?? 0) ?></strong>
|
||||
<span class="metric-subtext">Still moving through approval levels</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="shell-card metric-card h-100">
|
||||
<span class="metric-label">Awaiting my action</span>
|
||||
<strong class="metric-value"><?= (int) ($metrics['awaiting_action'] ?? 0) ?></strong>
|
||||
<span class="metric-subtext">Items currently assigned to you</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="shell-card metric-card h-100">
|
||||
<span class="metric-label">Approved</span>
|
||||
<strong class="metric-value"><?= (int) ($metrics['approved'] ?? 0) ?></strong>
|
||||
<span class="metric-subtext">Closed requests in your scope</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-xl-7" id="submit">
|
||||
<div class="shell-card h-100">
|
||||
<div class="section-kicker">Create / input</div>
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="section-title mb-1">Submit a new department request</h2>
|
||||
<p class="section-copy mb-0">Capture the essentials once, then let the workflow route it automatically to each level.</p>
|
||||
</div>
|
||||
<?php if (!can_submit_requests($currentUser)): ?>
|
||||
<span class="badge text-bg-light border">Submission disabled for Admin / Finance</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!can_submit_requests($currentUser)): ?>
|
||||
<div class="surface-panel">
|
||||
<div class="surface-panel-title">Why submission is hidden</div>
|
||||
<p class="mb-0 text-muted">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.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php if ($formErrors): ?>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<div class="fw-semibold mb-2">Please fix the following before submitting:</div>
|
||||
<ul class="mb-0 small ps-3">
|
||||
<?php foreach ($formErrors as $error): ?>
|
||||
<li><?= e($error) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="create_request">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="request_type">Request type</label>
|
||||
<select class="form-select <?= isset($formErrors['request_type']) ? 'is-invalid' : '' ?>" id="request_type" name="request_type" required>
|
||||
<?php foreach (request_type_options() as $type): ?>
|
||||
<option value="<?= e($type) ?>" <?= $oldForm['request_type'] === $type ? 'selected' : '' ?>><?= e($type) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (isset($formErrors['request_type'])): ?><div class="invalid-feedback"><?= e($formErrors['request_type']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="priority">Priority</label>
|
||||
<select class="form-select <?= isset($formErrors['priority']) ? 'is-invalid' : '' ?>" id="priority" name="priority" required>
|
||||
<?php foreach (priority_options() as $priority): ?>
|
||||
<option value="<?= e($priority) ?>" <?= $oldForm['priority'] === $priority ? 'selected' : '' ?>><?= e($priority) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (isset($formErrors['priority'])): ?><div class="invalid-feedback"><?= e($formErrors['priority']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="title">Short title</label>
|
||||
<input class="form-control <?= isset($formErrors['title']) ? 'is-invalid' : '' ?>" id="title" name="title" type="text" maxlength="140" value="<?= e($oldForm['title']) ?>" placeholder="e.g. Laptop replacement for field coordinator" required>
|
||||
<?php if (isset($formErrors['title'])): ?><div class="invalid-feedback"><?= e($formErrors['title']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="department_view">Department</label>
|
||||
<input class="form-control" id="department_view" type="text" value="<?= e($currentUser['department']) ?>" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="amount">Amount (optional)</label>
|
||||
<input class="form-control <?= isset($formErrors['amount']) ? 'is-invalid' : '' ?>" id="amount" name="amount" type="text" inputmode="decimal" value="<?= e($oldForm['amount']) ?>" placeholder="120000">
|
||||
<?php if (isset($formErrors['amount'])): ?><div class="invalid-feedback"><?= e($formErrors['amount']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="needed_by">Needed by</label>
|
||||
<input class="form-control <?= isset($formErrors['needed_by']) ? 'is-invalid' : '' ?>" id="needed_by" name="needed_by" type="date" value="<?= e($oldForm['needed_by']) ?>">
|
||||
<?php if (isset($formErrors['needed_by'])): ?><div class="invalid-feedback"><?= e($formErrors['needed_by']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="justification">Business justification</label>
|
||||
<textarea class="form-control <?= isset($formErrors['justification']) ? 'is-invalid' : '' ?>" id="justification" name="justification" rows="5" maxlength="1000" data-char-count-target="#justification-count" required><?= e($oldForm['justification']) ?></textarea>
|
||||
<div class="d-flex justify-content-between mt-2 small text-muted">
|
||||
<span>Explain the request clearly so each approver can act without extra follow-up.</span>
|
||||
<span id="justification-count">0 characters</span>
|
||||
</div>
|
||||
<?php if (isset($formErrors['justification'])): ?><div class="invalid-feedback d-block"><?= e($formErrors['justification']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mt-2">
|
||||
<div class="small text-muted">Submission starts at supervisor review and automatically advances through HOD and Admin / Finance.</div>
|
||||
<button class="btn btn-primary" type="submit">Submit request</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-5" id="queue">
|
||||
<div class="shell-card mb-4">
|
||||
<div class="section-kicker">Approval queue</div>
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="section-title mb-1">Requests waiting on you</h2>
|
||||
<p class="section-copy mb-0">Queue is filtered by your department and approval level.</p>
|
||||
</div>
|
||||
<span class="badge text-bg-light border"><?= (int) count($queueRequests) ?> active</span>
|
||||
</div>
|
||||
<?php if (!$queueRequests): ?>
|
||||
<div class="empty-state">
|
||||
<strong>No items in your queue.</strong>
|
||||
<p class="mb-0">When a request reaches your level, it will appear here with a direct link to approve or reject.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="queue-list">
|
||||
<?php foreach ($queueRequests as $queued): ?>
|
||||
<a class="queue-item" href="request.php?id=<?= (int) $queued['id'] ?>">
|
||||
<div class="queue-item-top">
|
||||
<span class="queue-code"><?= e($queued['request_code']) ?></span>
|
||||
<span class="badge <?= e(status_badge_class($queued['status'])) ?>"><?= e($queued['status']) ?></span>
|
||||
</div>
|
||||
<div class="queue-title"><?= e($queued['title']) ?></div>
|
||||
<div class="queue-meta"><?= e($queued['department']) ?> · <?= e($queued['request_type']) ?> · Requested by <?= e($queued['requester_name']) ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="shell-card">
|
||||
<div class="section-kicker">Routing policy</div>
|
||||
<h2 class="section-title">Current approval design</h2>
|
||||
<p class="section-copy">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.</p>
|
||||
<div class="policy-grid">
|
||||
<?php foreach (workflow_levels() as $level => $definition): ?>
|
||||
<div class="policy-row">
|
||||
<span class="policy-step">Level <?= (int) $level ?></span>
|
||||
<div>
|
||||
<div class="fw-semibold"><?= e($definition['label']) ?></div>
|
||||
<div class="text-muted small"><?= $level === 3 ? 'Applies across all departments' : 'Applies inside the requester’s department' ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell-card mt-4" id="requests">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
|
||||
<div>
|
||||
<div class="section-kicker">Request list</div>
|
||||
<h2 class="section-title mb-1"><?= ($currentUser['approval_level'] ?? 0) > 0 || is_admin_finance($currentUser) ? 'Requests in view' : 'My request history' ?></h2>
|
||||
<p class="section-copy mb-0">Open a row to inspect the approval trail, current stage, and latest decision comment.</p>
|
||||
</div>
|
||||
<span class="text-muted small">Showing <?= (int) count($visibleRequests) ?> most recent records</span>
|
||||
</div>
|
||||
<?php if (!$visibleRequests): ?>
|
||||
<div class="empty-state">
|
||||
<strong>No requests yet.</strong>
|
||||
<p class="mb-0">Create your first request above and it will appear here immediately.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle request-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Department</th>
|
||||
<th>Requester</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($visibleRequests as $request): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= e($request['title']) ?></div>
|
||||
<div class="text-muted small"><?= e($request['request_code']) ?> · <?= e($request['request_type']) ?> · <?= format_money($request['amount']) ?></div>
|
||||
</td>
|
||||
<td><?= e($request['department']) ?></td>
|
||||
<td>
|
||||
<div><?= e($request['requester_name']) ?></div>
|
||||
<div class="text-muted small"><?= e($request['requester_email']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?= e(status_badge_class($request['status'])) ?>"><?= e($request['status']) ?></span>
|
||||
<div class="text-muted small mt-1"><?= e(level_label((int) $request['current_stage'])) ?></div>
|
||||
</td>
|
||||
<td><?= e(format_datetime($request['updated_at'])) ?></td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="request.php?id=<?= (int) $request['id'] ?>">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php render_footer(); ?>
|
||||
<?php render_scripts(); ?>
|
||||
|
||||
268
request.php
Normal file
268
request.php
Normal file
@ -0,0 +1,268 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
ensure_request_table();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'logout') {
|
||||
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||
flash('danger', 'Your session expired. Please try again.');
|
||||
} else {
|
||||
logout_current_user();
|
||||
flash('primary', 'Signed out successfully.');
|
||||
}
|
||||
redirect('index.php');
|
||||
}
|
||||
|
||||
$currentUser = require_auth();
|
||||
$flash = consume_flash();
|
||||
$requestId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||
$request = $requestId > 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.');
|
||||
?>
|
||||
<body>
|
||||
<?php render_nav($currentUser, 'dashboard'); ?>
|
||||
<main class="app-shell">
|
||||
<section class="container py-5">
|
||||
<div class="shell-card narrow-panel">
|
||||
<div class="section-kicker">Not found</div>
|
||||
<h1 class="section-title">Request record not found.</h1>
|
||||
<p class="section-copy">The request may have been removed or the link is incomplete.</p>
|
||||
<a href="index.php#requests" class="btn btn-primary">Back to request list</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php render_footer(); ?>
|
||||
<?php render_scripts(); ?>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!can_view_request($currentUser, $request)) {
|
||||
http_response_code(403);
|
||||
render_head('Access denied', 'You do not have permission to view this request.');
|
||||
?>
|
||||
<body>
|
||||
<?php render_nav($currentUser, 'dashboard'); ?>
|
||||
<main class="app-shell">
|
||||
<section class="container py-5">
|
||||
<div class="shell-card narrow-panel">
|
||||
<div class="section-kicker">Access denied</div>
|
||||
<h1 class="section-title">You cannot open this request.</h1>
|
||||
<p class="section-copy">Employees only see their own requests, while approvers only see requests inside their department.</p>
|
||||
<a href="index.php#requests" class="btn btn-primary">Back to request list</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php render_footer(); ?>
|
||||
<?php render_scripts(); ?>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||
flash('danger', 'Your session expired. Please refresh and try again.');
|
||||
redirect('request.php?id=' . $requestId);
|
||||
}
|
||||
|
||||
$action = trim((string) ($_POST['action'] ?? ''));
|
||||
if ($action === 'approve_request') {
|
||||
$result = apply_request_decision($request, $currentUser, (string) ($_POST['decision'] ?? ''), (string) ($_POST['comment'] ?? ''));
|
||||
if (!empty($result['success'])) {
|
||||
flash('success', $result['message']);
|
||||
redirect('request.php?id=' . $requestId);
|
||||
}
|
||||
|
||||
$decisionError = $result['message'] ?? 'Unable to update this request.';
|
||||
$request = fetch_request_by_id($requestId);
|
||||
}
|
||||
}
|
||||
|
||||
$request = fetch_request_by_id($requestId);
|
||||
$canApprove = can_approve_request($currentUser, $request);
|
||||
$auditTrail = array_reverse(decode_audit((string) $request['audit_trail']));
|
||||
$workflow = workflow_levels();
|
||||
|
||||
render_head(
|
||||
$request['request_code'] . ' · ' . $request['title'],
|
||||
'Request detail with status tracking, approval controls, and audit trail for departmental workflow.'
|
||||
);
|
||||
?>
|
||||
<body>
|
||||
<?php render_nav($currentUser, 'dashboard'); ?>
|
||||
<?php render_flash_toast($flash); ?>
|
||||
|
||||
<main class="app-shell">
|
||||
<section class="container py-4">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<a href="index.php#requests" class="text-decoration-none small text-muted">← Back to request list</a>
|
||||
<h1 class="detail-title mt-2 mb-2"><?= e($request['title']) ?></h1>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<span class="badge text-bg-light border"><?= e($request['request_code']) ?></span>
|
||||
<span class="badge <?= e(status_badge_class($request['status'])) ?>"><?= e($request['status']) ?></span>
|
||||
<span class="badge <?= e(priority_badge_class($request['priority'])) ?>"><?= e($request['priority']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-md-end detail-summary-text">
|
||||
<div class="text-muted small">Requester</div>
|
||||
<div class="fw-semibold"><?= e($request['requester_name']) ?></div>
|
||||
<div class="text-muted small"><?= e($request['department']) ?> · <?= e($request['request_type']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell-card mb-4">
|
||||
<div class="section-kicker">Approval progress</div>
|
||||
<div class="progress-route">
|
||||
<?php foreach ($workflow as $level => $definition): ?>
|
||||
<?php
|
||||
$state = 'upcoming';
|
||||
if ($request['status'] === 'Rejected' && (int) $request['current_stage'] === (int) $level) {
|
||||
$state = 'rejected';
|
||||
} elseif ($request['status'] === 'Approved' || (int) $request['current_stage'] > (int) $level) {
|
||||
$state = 'done';
|
||||
} elseif ((int) $request['current_stage'] === (int) $level && str_starts_with((string) $request['status'], 'Pending')) {
|
||||
$state = 'current';
|
||||
}
|
||||
?>
|
||||
<div class="progress-step <?= e('state-' . $state) ?>">
|
||||
<span class="progress-index"><?= (int) $level ?></span>
|
||||
<div>
|
||||
<div class="fw-semibold"><?= e($definition['label']) ?></div>
|
||||
<div class="text-muted small"><?= $level === 3 ? 'Global approval desk' : e($request['department']) . ' approver' ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-xl-7">
|
||||
<div class="shell-card mb-4">
|
||||
<div class="section-kicker">Request detail</div>
|
||||
<div class="detail-grid">
|
||||
<div>
|
||||
<span class="detail-label">Request type</span>
|
||||
<div class="detail-value"><?= e($request['request_type']) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="detail-label">Department</span>
|
||||
<div class="detail-value"><?= e($request['department']) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="detail-label">Amount</span>
|
||||
<div class="detail-value"><?= e(format_money($request['amount'])) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="detail-label">Needed by</span>
|
||||
<div class="detail-value"><?= e(format_date($request['needed_by'])) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="detail-label">Created</span>
|
||||
<div class="detail-value"><?= e(format_datetime($request['created_at'])) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="detail-label">Last updated</span>
|
||||
<div class="detail-value"><?= e(format_datetime($request['updated_at'])) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4">
|
||||
<span class="detail-label">Business justification</span>
|
||||
<p class="detail-paragraph mb-0"><?= nl2br(e($request['justification'])) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="shell-card">
|
||||
<div class="section-kicker">Audit trail</div>
|
||||
<h2 class="section-title mb-3">Every action on this request</h2>
|
||||
<div class="timeline-list">
|
||||
<?php foreach ($auditTrail as $entry): ?>
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker"></div>
|
||||
<div>
|
||||
<div class="d-flex flex-column flex-md-row justify-content-md-between gap-1">
|
||||
<div class="fw-semibold"><?= e($entry['action'] ?? 'Update') ?> · <?= e($entry['actor'] ?? 'System') ?></div>
|
||||
<div class="text-muted small"><?= e(format_datetime($entry['timestamp'] ?? '')) ?></div>
|
||||
</div>
|
||||
<div class="text-muted small mb-2"><?= e($entry['role'] ?? 'Workflow') ?></div>
|
||||
<?php if (!empty($entry['comment'])): ?>
|
||||
<p class="mb-0 detail-paragraph"><?= nl2br(e($entry['comment'])) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="mb-0 text-muted small">No additional comment recorded.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-5">
|
||||
<div class="shell-card mb-4">
|
||||
<div class="section-kicker">Current status</div>
|
||||
<h2 class="section-title mb-3">Workflow snapshot</h2>
|
||||
<div class="surface-panel compact-panel">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<div class="surface-panel-title">Next expected step</div>
|
||||
<div class="fw-semibold"><?= str_starts_with((string) $request['status'], 'Pending') ? e(level_label((int) $request['current_stage'])) : e($request['status']) ?></div>
|
||||
</div>
|
||||
<span class="badge <?= e(status_badge_class($request['status'])) ?>"><?= e($request['status']) ?></span>
|
||||
</div>
|
||||
<div class="small text-muted">Latest note</div>
|
||||
<p class="mb-0 detail-paragraph"><?= e((string) ($request['last_comment'] ?: 'No comment recorded yet.')) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell-card mb-4">
|
||||
<div class="section-kicker">Access rules</div>
|
||||
<h2 class="section-title mb-3">Who can act next</h2>
|
||||
<ul class="rule-list mb-0">
|
||||
<li>Employees can submit and monitor their own requests.</li>
|
||||
<li>Supervisors and HODs approve only inside their department.</li>
|
||||
<li>Admin / Finance sees every request and closes level 3 approvals.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="shell-card">
|
||||
<div class="section-kicker">Decision</div>
|
||||
<h2 class="section-title mb-3">Approve or reject</h2>
|
||||
<?php if (!$canApprove): ?>
|
||||
<div class="empty-state">
|
||||
<strong>No action required from you.</strong>
|
||||
<p class="mb-0">This request is either already closed or currently assigned to another approval level.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php if ($decisionError): ?>
|
||||
<div class="alert alert-danger" role="alert"><?= e($decisionError) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="vstack gap-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="approve_request">
|
||||
<div>
|
||||
<label class="form-label" for="comment">Approval note</label>
|
||||
<textarea class="form-control" id="comment" name="comment" rows="5" maxlength="600" data-char-count-target="#comment-count" placeholder="Add context for the next approver or explain a rejection."></textarea>
|
||||
<div class="d-flex justify-content-between mt-2 small text-muted">
|
||||
<span>Notes are optional for approval, but required for rejection.</span>
|
||||
<span id="comment-count">0 characters</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<button type="submit" name="decision" value="approve" class="btn btn-primary flex-fill">Approve and continue</button>
|
||||
<button type="submit" name="decision" value="reject" class="btn btn-outline-danger flex-fill">Reject request</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<?php render_footer(); ?>
|
||||
<?php render_scripts(); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user