185 lines
18 KiB
PHP
185 lines
18 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../config/bootstrap.php';
|
|
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
|
|
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
|
|
|
|
ini_set('session.use_strict_mode', '1');
|
|
$forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
|
session_set_cookie_params(['httponly' => true, 'secure' => !empty($_SERVER['HTTPS']) || $forwardedHttps, 'samesite' => 'Lax', 'path' => '/']);
|
|
session_start();
|
|
|
|
function render_header(string $title): void
|
|
{
|
|
$user = current_user();
|
|
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . e($title) . ' · JOBcard</title><link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"><link href="/assets/app.css" rel="stylesheet"></head><body>';
|
|
if ($user) {
|
|
echo '<nav class="navbar navbar-dark bg-primary"><div class="container-fluid"><a class="navbar-brand fw-bold" href="/?route=dashboard">JOBcard</a><span class="text-white small">' . e($user['name']) . ' · ' . e($user['role_name']) . ' <form method="post" action="/?route=logout" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><button class="btn btn-sm btn-light ms-2">Sign out</button></form></span></div></nav><div class="container-fluid"><div class="row"><aside class="col-md-2 col-lg-2 border-end bg-white min-vh-100 p-3"><nav class="nav flex-column gap-1"><a class="nav-link sidebar-link" href="/?route=dashboard">Dashboard</a>';
|
|
if (can('clients.view')) echo '<a class="nav-link sidebar-link" href="/?route=clients">Clients</a>';
|
|
if (can('jobcards.view')) echo '<a class="nav-link sidebar-link" href="/?route=jobcards">Jobcards</a>';
|
|
if (can('reports.view')) echo '<a class="nav-link sidebar-link" href="/?route=reports">Reports</a>';
|
|
if (can('users.manage')) echo '<a class="nav-link sidebar-link" href="/?route=users">Users & roles</a>';
|
|
if (can('audit.view')) echo '<a class="nav-link sidebar-link" href="/?route=audit">Audit trail</a>';
|
|
echo '</nav></aside><main class="col-md-10 col-lg-10 p-3 p-lg-4">';
|
|
} else {
|
|
echo '<main class="container">';
|
|
}
|
|
}
|
|
function render_footer(): void
|
|
{
|
|
$user = current_user();
|
|
echo '</main>' . ($user ? '</div></div>' : '') . '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script></body></html>';
|
|
}
|
|
|
|
$route = scalar_input($_GET['route'] ?? null, current_user() ? 'dashboard' : 'login');
|
|
|
|
if ($route === 'logout') {
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit('Logout requires POST'); }
|
|
verify_csrf();
|
|
if (current_user()) audit('logout', 'user', (int)current_user()['id']);
|
|
$_SESSION = [];
|
|
session_destroy();
|
|
header('Location: /?route=login');
|
|
exit;
|
|
}
|
|
|
|
if ($route === 'login') {
|
|
if (current_user()) { header('Location: /?route=dashboard'); exit; }
|
|
$error = null;
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
verify_csrf();
|
|
$stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = :email LIMIT 1');
|
|
$stmt->execute(['email' => strtolower(trim(scalar_input($_POST['email'] ?? null))) ]);
|
|
$user = $stmt->fetch();
|
|
if (!$user || !$user['is_active'] || !password_verify(scalar_input($_POST['password'] ?? null), $user['password_hash'])) {
|
|
$error = 'The email or password is incorrect.';
|
|
} else {
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = (int)$user['id'];
|
|
$_SESSION['csrf'] = bin2hex(random_bytes(32));
|
|
db()->prepare('UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = :id')->execute(['id' => $user['id']]);
|
|
audit('login_success', 'user', (int)$user['id']);
|
|
header('Location: /?route=dashboard'); exit;
|
|
}
|
|
}
|
|
render_header('Sign in'); ?><div class="card shadow-sm login-card"><div class="card-body p-4"><h1 class="h3 mb-1">JOBcard</h1><p class="text-muted mb-4">Sign in to the support workspace.</p><?php if ($error): ?><div class="alert alert-danger"><?= e($error) ?></div><?php endif; ?><form method="post"><input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>"><div class="mb-3"><label class="form-label" for="email">Email</label><input class="form-control" id="email" name="email" type="email" autocomplete="username" required></div><div class="mb-4"><label class="form-label" for="password">Password</label><input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required></div><button class="btn btn-primary w-100">Sign in</button></form></div></div><?php render_footer(); exit;
|
|
}
|
|
|
|
$user = require_login();
|
|
if ($route === 'dashboard') {
|
|
require_permission('dashboard.view');
|
|
render_header('Dashboard'); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Dashboard</h1><p class="text-muted mb-0">Your operational overview.</p></div><span class="badge text-bg-primary"><?= e($user['role_name']) ?></span></div><div class="row g-3"><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">New jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Open jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Hours this week</div><div class="display-6 fw-semibold">0.0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">SLA warnings</div><div class="display-6 fw-semibold">0</div></div></div></div></div><div class="card mt-4"><div class="card-body"><h2 class="h5">Foundation ready</h2><p class="mb-0 text-muted">Authentication, role-aware navigation, CSRF protection, password hashing and audit logging are active. Client and jobcard modules will populate this dashboard in the next increments.</p></div></div><?php render_footer(); exit;
|
|
}
|
|
|
|
$permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view'];
|
|
if ($route === 'jobcards') {
|
|
require_permission('jobcards.view');
|
|
$errors = [];
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
require_permission('jobcards.manage');
|
|
verify_csrf();
|
|
$clientId = filter_var(scalar_input($_POST['client_id'] ?? null), FILTER_VALIDATE_INT);
|
|
$workRequested = trim(scalar_input($_POST['work_requested'] ?? null));
|
|
$priority = scalar_input($_POST['priority'] ?? null, 'normal');
|
|
if (!$clientId || $workRequested === '' || mb_strlen($workRequested) > 10000 || !in_array($priority, ['low', 'normal', 'high', 'critical'], true)) {
|
|
$errors[] = 'Select a client, enter the requested work, and choose a valid priority.';
|
|
} else {
|
|
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'");
|
|
$clientCheck->execute(['id' => $clientId]);
|
|
if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.';
|
|
}
|
|
if (!$errors) {
|
|
$year = (int)date('Y');
|
|
$sequenceStmt = db()->prepare('SELECT COALESCE(MAX(CAST(SUBSTRING(reference_no, 9) AS UNSIGNED)), 0) + 1 FROM jobcards WHERE reference_no LIKE :prefix');
|
|
$sequenceStmt->execute(['prefix' => 'JC-' . $year . '-%']);
|
|
$reference = \App\Domain\Jobcard\JobcardReference::generate((int)$sequenceStmt->fetchColumn(), $year);
|
|
$stmt = db()->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)');
|
|
$stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]);
|
|
$jobcardId = (int)db()->lastInsertId();
|
|
audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]);
|
|
header('Location: /?route=jobcards&created=1'); exit;
|
|
}
|
|
}
|
|
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
|
|
$jobcards = db()->query('SELECT j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id ORDER BY j.created_at DESC LIMIT 100')->fetchAll();
|
|
render_header('Jobcards');
|
|
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Jobcards</h1><p class="text-muted mb-0">Track requested work and operational status.</p></div>';
|
|
if (can('jobcards.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-jobcard">New jobcard</button>';
|
|
echo '</div>';
|
|
if (isset($_GET['created'])) echo '<div class="alert alert-success">Jobcard created successfully.</div>';
|
|
if ($errors) echo '<div class="alert alert-danger">' . e(implode(' ', $errors)) . '</div>';
|
|
if (can('jobcards.manage')) { echo '<div class="collapse mb-4" id="new-jobcard"><div class="card"><div class="card-body"><h2 class="h5">Create jobcard</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-6"><label class="form-label" for="jobcard-client">Client</label><select class="form-select" id="jobcard-client" name="client_id" required><option value="">Choose client</option>'; foreach ($clients as $client) echo '<option value="' . (int)$client['id'] . '">' . e($client['name']) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label" for="jobcard-priority">Priority</label><select class="form-select" id="jobcard-priority" name="priority"><option>low</option><option selected>normal</option><option>high</option><option>critical</option></select></div><div class="col-12"><label class="form-label" for="work-requested">Work requested</label><textarea class="form-control" id="work-requested" name="work_requested" rows="4" maxlength="10000" required></textarea></div><div class="col-12"><button class="btn btn-primary">Create jobcard</button></div></form></div></div></div>'; }
|
|
echo '<div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Reference</th><th>Client</th><th>Priority</th><th>Status</th><th>Work requested</th><th>Created</th></tr></thead><tbody>';
|
|
if (!$jobcards) echo '<tr><td colspan="6" class="text-center text-muted py-4">No jobcards found.</td></tr>';
|
|
foreach ($jobcards as $jobcard) echo '<tr><td class="fw-semibold">' . e($jobcard['reference_no']) . '</td><td>' . e($jobcard['client_name']) . '</td><td>' . e(ucfirst($jobcard['priority'])) . '</td><td>' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</td><td class="text-truncate" style="max-width: 320px">' . e($jobcard['work_requested']) . '</td><td>' . e($jobcard['created_at']) . '</td></tr>';
|
|
echo '</tbody></table></div></div>';
|
|
render_footer(); exit;
|
|
}
|
|
|
|
if ($route === 'client') {
|
|
require_permission('clients.view');
|
|
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
|
if (!$clientId) { http_response_code(400); exit('Invalid client'); }
|
|
$stmt = db()->prepare('SELECT * FROM clients WHERE id = :id');
|
|
$stmt->execute(['id' => $clientId]);
|
|
$client = $stmt->fetch();
|
|
if (!$client) { http_response_code(404); exit('Client not found'); }
|
|
$contactsStmt = db()->prepare('SELECT name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name');
|
|
$contactsStmt->execute(['id' => $clientId]);
|
|
$contacts = $contactsStmt->fetchAll();
|
|
render_header('Client details');
|
|
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div><div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>';
|
|
if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>';
|
|
foreach ($contacts as $contact) echo '<div class="border-bottom py-2"><div class="fw-semibold">' . e($contact['name']) . ($contact['is_primary'] ? ' <span class="badge text-bg-primary">Primary</span>' : '') . '</div><div class="small text-muted">' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '</div></div>';
|
|
echo '</div></div></div></div>';
|
|
render_footer();
|
|
exit;
|
|
}
|
|
|
|
if ($route === 'clients') {
|
|
require_permission('clients.view');
|
|
$errors = [];
|
|
$old = ['name' => '', 'status' => 'active'];
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
require_permission('clients.manage');
|
|
verify_csrf();
|
|
$validated = validate_client($_POST);
|
|
$old = $validated;
|
|
$errors = $validated['errors'];
|
|
if ($errors === []) {
|
|
$stmt = db()->prepare('INSERT INTO clients (name, status, created_by) VALUES (:name, :status, :created_by)');
|
|
$stmt->execute(['name' => $validated['name'], 'status' => $validated['status'], 'created_by' => $user['id']]);
|
|
$clientId = (int)db()->lastInsertId();
|
|
audit('client_created', 'client', $clientId, ['name' => $validated['name']]);
|
|
header('Location: /?route=clients&created=1');
|
|
exit;
|
|
}
|
|
}
|
|
$search = trim(scalar_input($_GET['q'] ?? null));
|
|
$stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100');
|
|
$stmt->execute(['search' => $search, 'like_name' => "%{$search}%", 'like_email' => "%{$search}%"]);
|
|
$clients = $stmt->fetchAll();
|
|
render_header('Clients');
|
|
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Clients</h1><p class="text-muted mb-0">Manage client records and support contacts.</p></div>';
|
|
if (can('clients.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-client">New client</button>';
|
|
echo '</div>';
|
|
if (isset($_GET['created'])) echo '<div class="alert alert-success">Client created successfully.</div>';
|
|
if (can('clients.manage')) {
|
|
echo '<div class="collapse mb-4" id="new-client"><div class="card"><div class="card-body"><h2 class="h5">Create client</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-8"><label class="form-label" for="client-name">Client/company name</label><input class="form-control" id="client-name" name="name" value="' . e((string)$old['name']) . '" required>' . (isset($errors['name']) ? '<div class="text-danger small">' . e($errors['name']) . '</div>' : '') . '</div><div class="col-md-4"><label class="form-label" for="client-status">Status</label><select class="form-select" id="client-status" name="status"><option value="active">Active</option><option value="inactive">Inactive</option></select></div><div class="col-12"><button class="btn btn-primary">Save client</button></div></form></div></div></div>';
|
|
}
|
|
echo '<form class="row g-2 mb-3"><input type="hidden" name="route" value="clients"><div class="col-sm-8 col-lg-5"><label class="visually-hidden" for="client-search">Search clients</label><input class="form-control" id="client-search" name="q" value="' . e($search) . '" placeholder="Search by client or support email"></div><div class="col-auto"><button class="btn btn-outline-secondary">Search</button></div></form><div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Client</th><th>Status</th><th>Support email</th><th>Phone</th></tr></thead><tbody>';
|
|
if (!$clients) echo '<tr><td colspan="4" class="text-center text-muted py-4">No clients found.</td></tr>';
|
|
foreach ($clients as $client) echo '<tr><td class="fw-semibold"><a href="/?route=client&id=' . (int)$client['id'] . '" class="text-decoration-none">' . e($client['name']) . '</a></td><td><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></td><td>' . e((string)($client['support_email'] ?? '—')) . '</td><td>' . e((string)($client['support_phone'] ?? '—')) . '</td></tr>';
|
|
echo '</tbody></table></div></div>';
|
|
render_footer();
|
|
exit;
|
|
}
|
|
|
|
if (isset($permissionByRoute[$route])) {
|
|
require_permission($permissionByRoute[$route]);
|
|
render_header(ucfirst($route)); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1"><?= e(ucfirst($route)) ?></h1><p class="text-muted mb-0">This module is scaffolded for the next implementation phase.</p></div></div><div class="alert alert-info">The route is permission-protected and ready for its domain workflow.</div><?php render_footer(); exit;
|
|
}
|
|
|
|
http_response_code(404); render_header('Not found'); ?><div class="alert alert-warning">Page not found.</div><?php render_footer();
|