This commit is contained in:
Flatlogic Bot 2026-05-19 10:06:37 +00:00
parent 682d472f26
commit a646478110
7 changed files with 2353 additions and 525 deletions

921
app.php Normal file
View 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

View File

@ -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);
}
});
});
});

View 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
View 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
View File

@ -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 requesters 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
View 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(); ?>