feat: complete jobcard management workflows and UI
This commit is contained in:
+142
-46
@@ -6,20 +6,32 @@ require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/SLA/SlaAgreement.php';
|
||||
require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationRepository.php';
|
||||
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
@@ -37,6 +49,8 @@ function render_header(string $title): void
|
||||
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('roles.manage')) echo '<a class="nav-link sidebar-link" href="/?route=roles">Roles & permissions</a>';
|
||||
if (can('notifications.view')) echo '<a class="nav-link sidebar-link" href="/?route=notifications">Notifications</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 {
|
||||
@@ -87,16 +101,16 @@ $user = require_login();
|
||||
if ($route === 'dashboard') {
|
||||
require_permission('dashboard.view');
|
||||
$jobcardMetrics = db()->query("SELECT SUM(status = 'new') AS new_count, SUM(status NOT IN ('completed','closed')) AS open_count FROM jobcards")->fetch();
|
||||
$hoursThisWeek = (float)db()->query('SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND work_date <= CURDATE()')->fetchColumn();
|
||||
$slaRows = db()->query("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (s.period_type = 'custom' AND te.work_date BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, CURDATE()))) THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s LEFT JOIN jobcards j ON j.client_id = s.client_id LEFT JOIN time_entries te ON te.jobcard_id = j.id WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date")->fetchAll();
|
||||
$hoursThisWeek = (float)db()->query("SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = time_entries.id AND av.action = 'time_entry_voided')")->fetchColumn();
|
||||
$slaRows = db()->query("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (s.period_type = 'custom' AND te.work_date BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, CURDATE()))) THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s LEFT JOIN jobcards j ON j.client_id = s.client_id LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date")->fetchAll();
|
||||
if ($user['role_name'] === 'Technician') {
|
||||
$metricStmt = db()->prepare("SELECT SUM(j.status = 'new') AS new_count, SUM(j.status NOT IN ('completed','closed')) AS open_count FROM jobcards j JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user");
|
||||
$metricStmt->execute(['user' => $user['id']]);
|
||||
$jobcardMetrics = $metricStmt->fetch();
|
||||
$hoursStmt = db()->prepare('SELECT COALESCE(SUM(te.hours), 0) FROM time_entries te JOIN jobcard_assignments ja ON ja.jobcard_id = te.jobcard_id AND ja.user_id = :user WHERE te.work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND te.work_date <= CURDATE()');
|
||||
$hoursStmt = db()->prepare("SELECT COALESCE(SUM(te.hours), 0) FROM time_entries te JOIN jobcard_assignments ja ON ja.jobcard_id = te.jobcard_id AND ja.user_id = :user WHERE te.work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND te.work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided')");
|
||||
$hoursStmt->execute(['user' => $user['id']]);
|
||||
$hoursThisWeek = (float)$hoursStmt->fetchColumn();
|
||||
$slaRowsStmt = db()->prepare("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND te.work_date >= COALESCE(s.start_date, '1000-01-01') AND te.work_date <= COALESCE(s.end_date, CURDATE()) AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR s.period_type = 'custom') THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s JOIN jobcards j ON j.client_id = s.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date");
|
||||
$slaRowsStmt = db()->prepare("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND te.work_date >= COALESCE(s.start_date, '1000-01-01') AND te.work_date <= COALESCE(s.end_date, CURDATE()) AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR s.period_type = 'custom') THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s JOIN jobcards j ON j.client_id = s.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date");
|
||||
$slaRowsStmt->execute(['user' => $user['id']]);
|
||||
$slaRows = $slaRowsStmt->fetchAll();
|
||||
}
|
||||
@@ -124,6 +138,29 @@ if ($route === 'attachment') {
|
||||
readfile($path); exit;
|
||||
}
|
||||
|
||||
if ($route === 'client_history') {
|
||||
require_permission('clients.view');
|
||||
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
||||
if (!$clientId || !can_access_client($clientId)) { http_response_code(404); exit('Client not found'); }
|
||||
try { $filters = \ReportFilters::fromArray([...$_GET, 'client_id' => $clientId]); } catch (Throwable $exception) { http_response_code(400); exit('Invalid history filters'); }
|
||||
$stmt = db()->prepare('SELECT h.id, j.client_id, j.reference_no, h.from_status, h.to_status, h.changed_at, u.name AS changed_by_name FROM jobcard_status_history h JOIN jobcards j ON j.id = h.jobcard_id LEFT JOIN users u ON u.id = h.changed_by WHERE j.client_id = :client ORDER BY h.changed_at ASC, h.id ASC'); $stmt->execute(['client' => $clientId]);
|
||||
$history = (new \App\Domain\Reporting\ClientHistoryReport($filters))->build($stmt->fetchAll(), 'client');
|
||||
if (scalar_input($_GET['format'] ?? null) === 'print') { header('Content-Type: text/html; charset=UTF-8'); echo (new \PrintReportRenderer())->render('Client history', ['Reference', 'From', 'To', 'Changed'], array_map(static fn (array $row): array => [$row['reference_no'], $row['from_status'], $row['to_status'], $row['changed_at']], $history)); exit; }
|
||||
render_header('Client history'); echo '<div class="d-flex justify-content-between mb-4"><div><a href="/?route=client&id=' . (int)$clientId . '">← Back to client</a><h1 class="h3 mt-2">Client history</h1></div><a class="btn btn-outline-secondary" href="/?route=client_history&id=' . (int)$clientId . '&format=print">Print view</a></div><div class="card"><div class="table-responsive"><table class="table"><thead><tr><th>Jobcard</th><th>From</th><th>To</th><th>Changed</th></tr></thead><tbody>'; foreach ($history as $row) echo '<tr><td>' . e($row['reference_no']) . '</td><td>' . e($row['from_status']) . '</td><td>' . e($row['to_status']) . '</td><td>' . e($row['changed_at']) . '</td></tr>'; echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'time_entry') {
|
||||
require_permission('time_entries.record');
|
||||
$entryId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
||||
$stmt = db()->prepare("SELECT t.*, j.client_id FROM time_entries t JOIN jobcards j ON j.id = t.jobcard_id WHERE t.id = :id AND NOT EXISTS (SELECT 1 FROM audit_events ae WHERE ae.entity_type = 'time_entry' AND ae.entity_id = t.id AND ae.action = 'time_entry_voided')"); $stmt->execute(['id' => $entryId]); $entry = $stmt->fetch();
|
||||
if (!$entry || !can_access_jobcard((int)$entry['jobcard_id']) || ($user['role_name'] === 'Technician' && (int)$entry['technician_id'] !== (int)$user['id'])) { http_response_code(404); exit('Time entry not found'); }
|
||||
$errors = [];
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $command = scalar_input($_POST['action'] ?? null); $auditAction = $command === 'void' ? 'time_entry_voided' : 'time_entry_corrected'; $validator = new \App\Domain\Jobcard\TimeEntryCorrectionCommand(); $changes = [];
|
||||
foreach (['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'] as $field) if (array_key_exists($field, $_POST)) $changes[$field] = $_POST[$field];
|
||||
$result = $command === 'void' ? $validator->validateVoid($entry, ['reason' => $_POST['reason'] ?? null]) : $validator->validateCorrection($entry, $changes); $errors = array_values($result['errors']); if (!$errors) { if ($command === 'void') audit($auditAction, 'time_entry', $entryId, ['reason' => $result['void_reason']]); else { db()->prepare('UPDATE time_entries SET work_date = :date, start_time = :start, end_time = :end, hours = :hours, notes = :notes, counts_toward_sla = :sla WHERE id = :id')->execute(['date' => $result['entry']['work_date'], 'start' => $result['entry']['start_time'] ?? null, 'end' => $result['entry']['end_time'] ?? null, 'hours' => $result['entry']['hours'], 'notes' => $result['entry']['notes'] ?? null, 'sla' => !empty($result['entry']['counts_toward_sla']) ? 1 : 0, 'id' => $entryId]); audit($auditAction, 'time_entry', $entryId); } header('Location: /?route=jobcard&id=' . (int)$entry['jobcard_id'] . '&updated=1'); exit; } }
|
||||
render_header('Time entry correction'); echo '<h1 class="h3">Correct or void time entry</h1>' . ($errors ? '<div class="alert alert-danger">' . e(implode(' ', $errors)) . '</div>' : '') . '<form method="post" class="card card-body mb-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="correct"><div class="row g-2"><div class="col-md-3"><label class="form-label">Date</label><input class="form-control" type="date" name="work_date" value="' . e($entry['work_date']) . '"></div><div class="col-md-3"><label class="form-label">Hours</label><input class="form-control" type="number" step="0.01" name="hours" value="' . e((string)$entry['hours']) . '"></div><div class="col-md-6"><label class="form-label">Notes</label><input class="form-control" name="notes" value="' . e((string)($entry['notes'] ?? '')) . '"></div></div><button class="btn btn-primary mt-3">Save correction</button></form><form method="post" class="card card-body"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="void"><label class="form-label">Void reason</label><textarea class="form-control mb-2" name="reason" required></textarea><button class="btn btn-outline-danger">Void entry</button></form>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'jobcard') {
|
||||
require_permission('jobcards.view');
|
||||
$jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
||||
@@ -160,6 +197,7 @@ if ($route === 'jobcard') {
|
||||
$pdo->prepare('INSERT INTO jobcard_status_history (jobcard_id, from_status, to_status, changed_by) VALUES (:jobcard, :from_status, :to_status, :user)')->execute(['jobcard' => $jobcardId, 'from_status' => $locked['status'], 'to_status' => $to, 'user' => $user['id']]);
|
||||
audit('jobcard_status_changed', 'jobcard', $jobcardId, ['from' => $locked['status'], 'to' => $to]);
|
||||
$pdo->commit();
|
||||
try { $recipientStmt = db()->prepare('SELECT u.email FROM users u JOIN jobcard_assignments ja ON ja.user_id = u.id WHERE ja.jobcard_id = :jobcard AND u.is_active = 1'); $recipientStmt->execute(['jobcard' => $jobcardId]); $recipients = array_column($recipientStmt->fetchAll(), 'email'); if ($recipients) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'jobcard_status_changed', 'recipients' => $recipients, 'title' => 'Jobcard status changed', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' changed to ' . $to . '.', 'deduplication_key' => 'jobcard:' . $jobcardId . ':status:' . $to]); } catch (Throwable) { /* notification failure must not undo a committed status transition */ }
|
||||
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
|
||||
}
|
||||
} catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Status update failed.'; }
|
||||
@@ -189,6 +227,7 @@ if ($route === 'jobcard') {
|
||||
$pdo->prepare('INSERT INTO jobcard_assignments (jobcard_id, user_id, assigned_by) VALUES (:jobcard, :user, :by_user)')->execute(['jobcard' => $jobcardId, 'user' => $technicianId, 'by_user' => $user['id']]);
|
||||
audit('jobcard_assigned', 'jobcard', $jobcardId, ['technician_id' => $technicianId]);
|
||||
$pdo->commit();
|
||||
try { $recipientStmt = db()->prepare('SELECT email FROM users WHERE id = :id AND is_active = 1'); $recipientStmt->execute(['id' => $technicianId]); $recipient = $recipientStmt->fetchColumn(); if ($recipient) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'assignment_created', 'recipients' => [$recipient], 'title' => 'Jobcard assigned', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' was assigned to you.', 'deduplication_key' => 'assignment:' . $jobcardId . ':' . $technicianId]); } catch (Throwable) { /* notification failure must not undo a committed assignment */ }
|
||||
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
|
||||
} catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Assignment update failed.'; }
|
||||
}
|
||||
@@ -255,7 +294,7 @@ if ($route === 'jobcard') {
|
||||
$jobcardStmt->execute(['id' => $jobcardId]); $jobcard = $jobcardStmt->fetch();
|
||||
$assignments = db()->prepare('SELECT u.id, u.name FROM jobcard_assignments a JOIN users u ON u.id = a.user_id WHERE a.jobcard_id = :id ORDER BY u.name'); $assignments->execute(['id' => $jobcardId]); $assigned = $assignments->fetchAll();
|
||||
$technicians = db()->query("SELECT u.id, u.name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.is_active = 1 AND r.name = 'Technician' ORDER BY u.name")->fetchAll();
|
||||
$timeStmt = db()->prepare('SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id ORDER BY t.work_date DESC, t.id DESC'); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll();
|
||||
$timeStmt = db()->prepare("SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = t.id AND av.action = 'time_entry_voided') ORDER BY t.work_date DESC, t.id DESC"); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll();
|
||||
$attachmentStmt = db()->prepare('SELECT id, original_name, mime_type, file_size, client_visible, created_at FROM attachments WHERE jobcard_id = :id ORDER BY created_at DESC'); $attachmentStmt->execute(['id' => $jobcardId]); $attachments = $attachmentStmt->fetchAll();
|
||||
$totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries));
|
||||
render_header('Jobcard ' . $jobcard['reference_no']);
|
||||
@@ -263,7 +302,7 @@ if ($route === 'jobcard') {
|
||||
echo '<div class="row g-4"><div class="col-lg-8"><div class="card mb-4"><div class="card-body"><h2 class="h5">Work requested</h2><p class="mb-0">' . nl2br(e($jobcard['work_requested'])) . '</p></div></div><div class="card mb-4"><div class="card-body"><h2 class="h5">Work performed and notes</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="notes"><label class="form-label">Technician notes / Work performed</label><textarea class="form-control mb-3" name="technician_notes" rows="5">' . e((string)($jobcard['technician_notes'] ?? '')) . '</textarea>';
|
||||
if (can('jobcards.internal_notes')) echo '<label class="form-label">Internal notes</label><textarea class="form-control mb-3" name="internal_notes" rows="4">' . e((string)($jobcard['internal_notes'] ?? '')) . '</textarea>';
|
||||
echo '<button class="btn btn-primary">Save notes</button></form></div></div><div class="card"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h5">Time entries</h2><strong>' . e(number_format($totalHours, 2)) . ' hours</strong></div>';
|
||||
foreach ($timeEntries as $entry) echo '<div class="border-bottom py-2"><strong>' . e($entry['technician_name']) . '</strong> · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h<div class="small text-muted">' . e((string)($entry['notes'] ?? '')) . '</div></div>';
|
||||
foreach ($timeEntries as $entry) echo '<div class="border-bottom py-2"><strong>' . e($entry['technician_name']) . '</strong> · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h<div class="small text-muted">' . e((string)($entry['notes'] ?? '')) . '</div>' . (can('time_entries.record') ? '<a class="small" href="/?route=time_entry&id=' . (int)$entry['id'] . '">Correct or void</a>' : '') . '</div>';
|
||||
if (can('time_entries.record')) { echo '<hr><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="time">'; if ($user['role_name'] !== 'Technician') { echo '<div class="col-md-4"><select class="form-select" name="technician_id" required><option value="">Technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select></div>'; } echo '<div class="col-md-4"><input class="form-control" type="date" name="work_date" value="' . e(date('Y-m-d')) . '" required></div><div class="col-md-4"><input class="form-control" type="number" step="0.01" min="0.01" name="hours" placeholder="Hours"></div><div class="col-md-4 form-check pt-2"><input class="form-check-input" type="checkbox" name="counts_toward_sla" value="1" id="sla-time" checked><label class="form-check-label" for="sla-time">Counts toward SLA</label></div><div class="col-12"><input class="form-control" name="notes" placeholder="Time entry notes"></div><div class="col-12"><button class="btn btn-outline-primary">Add time</button></div></form>'; }
|
||||
echo '</div></div></div><div class="col-lg-4"><div class="card mb-4"><div class="card-body"><h2 class="h5">Status</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="status"><select class="form-select mb-2" name="status">';
|
||||
foreach ((new \App\Domain\Jobcard\StatusTransitionValidator())->allowedFrom($jobcard['status']) as $status) echo '<option value="' . e($status) . '"' . ($status === $jobcard['status'] ? ' selected' : '') . '>' . e(ucwords(str_replace('_', ' ', $status))) . '</option>';
|
||||
@@ -409,6 +448,46 @@ if ($route === 'client') {
|
||||
$credentialId = (int)db()->lastInsertId(); audit('credential_created', 'credential', $credentialId, ['client_id' => $clientId]);
|
||||
header('Location: /?route=client&id=' . $clientId . '&credential_created=1'); exit;
|
||||
} catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be saved.'; }
|
||||
} elseif (in_array($clientAction, ['contact_edit', 'contact_delete', 'contact_primary'], true)) {
|
||||
require_permission('clients.manage');
|
||||
$contactId = filter_var(scalar_input($_POST['contact_id'] ?? null), FILTER_VALIDATE_INT);
|
||||
$existingStmt = db()->prepare('SELECT id, client_id, name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :client ORDER BY id');
|
||||
$existingStmt->execute(['client' => $clientId]);
|
||||
$existingContacts = $existingStmt->fetchAll();
|
||||
$editor = new \App\Domain\Client\ContactEditCommand();
|
||||
$result = $clientAction === 'contact_delete' ? $editor->validateDelete((int)$contactId, $existingContacts) : ($clientAction === 'contact_primary' ? $editor->validatePrimary((int)$contactId, $existingContacts) : $editor->validateForEdit((int)$contactId, [...$_POST, 'client_id' => $clientId], $existingContacts));
|
||||
$contactErrors = array_values($result['errors']);
|
||||
if (!$contactErrors) {
|
||||
$pdo = db();
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
if ($clientAction === 'contact_delete') {
|
||||
$pdo->prepare('DELETE FROM client_contacts WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]);
|
||||
if (!empty($result['replacement_primary_contact_id'])) $pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $result['replacement_primary_contact_id'], 'client' => $clientId]);
|
||||
audit('client_contact_deleted', 'client_contact', (int)$contactId, ['client_id' => $clientId]);
|
||||
} elseif ($clientAction === 'contact_primary') {
|
||||
$pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]);
|
||||
$pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]);
|
||||
audit('client_contact_primary_changed', 'client_contact', (int)$contactId, ['client_id' => $clientId]);
|
||||
} else {
|
||||
$pdo->prepare('UPDATE client_contacts SET name = :name, email = :email, phone = :phone, is_primary = 0, notes = :notes WHERE id = :id AND client_id = :client')->execute(['name' => $result['name'], 'email' => $result['email'], 'phone' => $result['phone'], 'notes' => $result['notes'], 'id' => $contactId, 'client' => $clientId]);
|
||||
if ($result['is_primary']) $pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]);
|
||||
audit('client_contact_updated', 'client_contact', (int)$contactId, ['client_id' => $clientId]);
|
||||
}
|
||||
$pdo->commit(); header('Location: /?route=client&id=' . $clientId . '&contact_updated=1'); exit;
|
||||
} catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $contactErrors[] = 'The contact action could not be completed.'; }
|
||||
}
|
||||
} elseif ($clientAction === 'technical') {
|
||||
require_permission('technical.manage');
|
||||
$technicalData = $_POST;
|
||||
unset($technicalData['_csrf'], $technicalData['action'], $technicalData['category']);
|
||||
try {
|
||||
$command = (new \App\Domain\Credential\TechnicalInformationCommand())->validate(['client_id' => $clientId, 'category' => scalar_input($_POST['category'] ?? null), 'data' => $technicalData]);
|
||||
if (!$command['valid']) throw new InvalidArgumentException('Invalid technical information.');
|
||||
$record = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->upsert($clientId, $command['record']['category'], $command['record']['data'], (int)$user['id']);
|
||||
audit('technical_information_updated', 'technical_information', (int)$record['id'], ['client_id' => $clientId, 'category' => $record['category']]);
|
||||
header('Location: /?route=client&id=' . $clientId . '&technical_updated=1'); exit;
|
||||
} catch (Throwable $exception) { $contactErrors[] = 'Technical information could not be saved.'; }
|
||||
} else {
|
||||
require_permission('clients.manage');
|
||||
$contact = validate_client_contact($_POST);
|
||||
@@ -434,7 +513,7 @@ if ($route === 'client') {
|
||||
}
|
||||
}
|
||||
}
|
||||
$contactsStmt = db()->prepare('SELECT name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name');
|
||||
$contactsStmt = db()->prepare('SELECT id, client_id, 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();
|
||||
$slaAgreement = null;
|
||||
@@ -450,9 +529,9 @@ if ($route === 'client') {
|
||||
$credentialRows = $credentialStmt->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>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . (isset($_GET['sla_updated']) ? '<div class="alert alert-success">SLA agreement updated.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . ($slaErrors ? '<div class="alert alert-danger">' . e(implode(' ', $slaErrors)) . '</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>';
|
||||
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> <a href="/?route=client_history&id=' . (int)$clientId . '" class="text-decoration-none">View history</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>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . (isset($_GET['sla_updated']) ? '<div class="alert alert-success">SLA agreement updated.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . ($slaErrors ? '<div class="alert alert-danger">' . e(implode(' ', $slaErrors)) . '</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>';
|
||||
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>'; if (can('clients.manage')) { echo '<form method="post" class="d-inline me-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_primary"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><button class="btn btn-sm btn-link p-0">Set primary</button></form><form method="post" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_delete"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><button class="btn btn-sm btn-link text-danger p-0">Delete</button></form><form method="post" class="row g-1 mt-1"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_edit"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><div class="col-md-3"><input class="form-control form-control-sm" name="name" value="' . e($contact['name']) . '" required></div><div class="col-md-3"><input class="form-control form-control-sm" type="email" name="email" value="' . e((string)($contact['email'] ?? '')) . '"></div><div class="col-md-3"><input class="form-control form-control-sm" name="phone" value="' . e((string)($contact['phone'] ?? '')) . '"></div><div class="col-md-3"><button class="btn btn-sm btn-outline-secondary">Save edit</button></div></form>'; } echo '</div>'; }
|
||||
if (can('clients.manage')) echo '<hr><h3 class="h6 mt-3">Add contact</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact"><div class="col-12"><input class="form-control" name="name" placeholder="Full name" value="' . e((string)$contactOld['name']) . '" required></div><div class="col-md-6"><input class="form-control" type="email" name="email" placeholder="Email" value="' . e((string)($contactOld['email'] ?? '')) . '"></div><div class="col-md-6"><input class="form-control" name="phone" placeholder="Phone" value="' . e((string)($contactOld['phone'] ?? '')) . '"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="is_primary" value="1" id="contact-primary"><label class="form-check-label" for="contact-primary">Primary contact</label></div><div class="col-12"><button class="btn btn-sm btn-outline-primary">Add contact</button></div></form>';
|
||||
echo '</div></div></div></div>';
|
||||
if (can('sla.view') || can('sla.manage')) {
|
||||
@@ -474,6 +553,13 @@ if ($route === 'client') {
|
||||
if (can('credentials.manage')) { echo '<hr><h3 class="h6">Add credential</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="credential"><div class="col-md-3"><select class="form-select" name="category">'; foreach (\App\Domain\Credential\TechnicalInformation::categories() as $category) echo '<option value="' . e($category) . '">' . e(ucfirst($category)) . '</option>'; echo '</select></div><div class="col-md-3"><input class="form-control" name="label" placeholder="Label" required></div><div class="col-md-3"><input class="form-control" name="username" placeholder="Username"></div><div class="col-md-3"><input class="form-control" type="password" name="secret" placeholder="Secret" required></div><div class="col-12"><textarea class="form-control" name="credential_notes" rows="2" placeholder="Notes"></textarea></div><div class="col-12"><button class="btn btn-primary">Encrypt and save</button></div></form>'; }
|
||||
echo '</div></div>';
|
||||
}
|
||||
if (can('technical.view') || can('technical.manage')) {
|
||||
$technicalRows = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->forClient($clientId);
|
||||
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Technical information</h2>';
|
||||
foreach ($technicalRows as $technical) { $display = $technical['display']; echo '<div class="border-bottom py-2"><strong>' . e(ucfirst($technical['category'])) . '</strong><div>' . e((string)$display['label']) . ($display['username'] ? ' · ' . e((string)$display['username']) : '') . '</div><div class="small text-muted">' . nl2br(e((string)($display['notes'] ?? ''))) . '</div></div>'; }
|
||||
if (can('technical.manage')) { echo '<hr><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="technical"><div class="col-md-3"><select class="form-select" name="category">'; foreach (\App\Domain\Credential\TechnicalInformationCommand::categories() as $category) echo '<option value="' . e($category) . '">' . e(ucfirst($category)) . '</option>'; echo '</select></div><div class="col-md-3"><input class="form-control" name="label" placeholder="Label" required></div><div class="col-md-3"><input class="form-control" name="username" placeholder="Username"></div><div class="col-md-3"><input class="form-control" name="notes" placeholder="Notes"></div><div class="col-12"><button class="btn btn-outline-primary">Save technical information</button></div></form>'; }
|
||||
echo '</div></div>';
|
||||
}
|
||||
if (can('clients.manage')) echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Edit client</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="client_update"><div class="col-md-6"><label class="form-label">Client name</label><input class="form-control" name="name" value="' . e($client['name']) . '" required></div><div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="active"' . ($client['status'] === 'active' ? ' selected' : '') . '>Active</option><option value="inactive"' . ($client['status'] === 'inactive' ? ' selected' : '') . '>Inactive</option></select></div><div class="col-md-3"><label class="form-label">Preferred contact</label><input class="form-control" name="preferred_contact_method" value="' . e((string)($client['preferred_contact_method'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support email</label><input class="form-control" type="email" name="support_email" value="' . e((string)($client['support_email'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support phone</label><input class="form-control" name="support_phone" value="' . e((string)($client['support_phone'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Physical address</label><textarea class="form-control" name="physical_address" rows="3">' . e((string)($client['physical_address'] ?? '')) . '</textarea></div><div class="col-md-6"><label class="form-label">Postal address</label><textarea class="form-control" name="postal_address" rows="3">' . e((string)($client['postal_address'] ?? '')) . '</textarea></div><div class="col-12"><label class="form-label">General notes</label><textarea class="form-control" name="general_notes" rows="3">' . e((string)($client['general_notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save client</button></div></form></div></div>';
|
||||
render_footer();
|
||||
exit;
|
||||
@@ -555,47 +641,56 @@ if ($route === 'users') {
|
||||
echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
if (false) {
|
||||
require_permission('users.manage');
|
||||
$userErrors = [];
|
||||
if ($route === 'roles') {
|
||||
require_permission('roles.manage');
|
||||
$roleErrors = [];
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
verify_csrf();
|
||||
$userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null];
|
||||
$validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput);
|
||||
$userErrors = $validatedUser['errors'];
|
||||
if (!$userErrors) {
|
||||
$roleCheck = db()->prepare('SELECT id FROM roles WHERE id = :id'); $roleCheck->execute(['id' => $validatedUser['role_id']]);
|
||||
if (!$roleCheck->fetchColumn()) $userErrors['role_id'] = 'Selected role does not exist.';
|
||||
$emailCheck = db()->prepare('SELECT id FROM users WHERE email = :email'); $emailCheck->execute(['email' => $validatedUser['email']]);
|
||||
if ($emailCheck->fetchColumn()) $userErrors['email'] = 'A user with this email already exists.';
|
||||
}
|
||||
if (!$userErrors) {
|
||||
$stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash, is_active) VALUES (:role, :email, :name, :hash, 1)');
|
||||
$stmt->execute(['role' => $validatedUser['role_id'], 'email' => $validatedUser['email'], 'name' => $validatedUser['name'], 'hash' => password_hash(scalar_input($_POST['password'] ?? null), PASSWORD_DEFAULT)]);
|
||||
$newUserId = (int)db()->lastInsertId(); audit('user_created', 'user', $newUserId, ['email' => $validatedUser['email'], 'role_id' => $validatedUser['role_id']]);
|
||||
header('Location: /?route=users&created=1'); exit;
|
||||
}
|
||||
$action = scalar_input($_POST['action'] ?? 'create');
|
||||
$roleId = filter_var(scalar_input($_POST['role_id'] ?? null), FILTER_VALIDATE_INT);
|
||||
try {
|
||||
$pdo = db(); $roleRecord = new \App\Domain\User\RoleRecord(); $matrix = new \App\Domain\User\PermissionMatrix();
|
||||
$available = array_column($pdo->query('SELECT name FROM permissions ORDER BY name')->fetchAll(), 'name');
|
||||
if ($action === 'create') {
|
||||
$validated = (new \App\Domain\User\RolePermissionService())->validateForCreate($_POST, $available); $roleErrors = $validated['errors'];
|
||||
if (!$roleErrors) { $stmt = $pdo->prepare('INSERT INTO roles (name, description) VALUES (:name, :description)'); $stmt->execute(['name' => $validated['name'], 'description' => $validated['description']]); $roleId = (int)$pdo->lastInsertId(); }
|
||||
} else {
|
||||
$roleStmt = $pdo->prepare('SELECT id, name, description FROM roles WHERE id = :id'); $roleStmt->execute(['id' => $roleId]); $role = $roleStmt->fetch();
|
||||
if (!$role) $roleErrors['role'] = 'Role not found.';
|
||||
else { $assignment = (new \App\Domain\User\RolePermissionService())->validateAssignment($role, is_array($_POST['permissions'] ?? null) ? $_POST['permissions'] : [], $available); $roleErrors = $assignment['errors']; if (!$roleErrors) { $pdo->beginTransaction(); $pdo->prepare('DELETE FROM role_permissions WHERE role_id = :role')->execute(['role' => $roleId]); $insert = $pdo->prepare('INSERT INTO role_permissions (role_id, permission_id) SELECT :role, id FROM permissions WHERE name = :name'); foreach ($assignment['permissions'] as $permission) $insert->execute(['role' => $roleId, 'name' => $permission]); $pdo->commit(); } }
|
||||
}
|
||||
if (!$roleErrors) { audit('role_updated', 'role', (int)$roleId); header('Location: /?route=roles&updated=1'); exit; }
|
||||
} catch (Throwable $exception) { if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); $roleErrors['role'] = 'Role changes could not be saved.'; }
|
||||
}
|
||||
$roles = db()->query('SELECT id, name FROM roles ORDER BY name')->fetchAll();
|
||||
$users = db()->query('SELECT u.id, u.name, u.email, u.is_active, u.last_login_at, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ORDER BY u.name')->fetchAll();
|
||||
render_header('Users');
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users</h1><p class="text-muted mb-0">Create and review system accounts.</p></div><button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-user">New user</button></div>' . (isset($_GET['created']) ? '<div class="alert alert-success">User created successfully.</div>' : '') . ($userErrors ? '<div class="alert alert-danger">' . e(implode(' ', $userErrors)) . '</div>' : '') . '<div class="collapse mb-4" id="new-user"><div class="card"><div class="card-body"><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required><option value="">Choose role</option>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '">' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-6"><label class="form-label">Initial password</label><input class="form-control" type="password" name="password" minlength="12" required><div class="form-text">Use upper/lowercase, number and symbol.</div></div><div class="col-12"><button class="btn btn-primary">Create user</button></div></form></div></div></div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th></tr></thead><tbody>';
|
||||
foreach ($users as $listedUser) echo '<tr><td>' . e($listedUser['name']) . '</td><td>' . e($listedUser['email']) . '</td><td>' . e($listedUser['role_name']) . '</td><td>' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '</td><td>' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '</td></tr>';
|
||||
echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
$roles = db()->query('SELECT r.id, r.name, r.description, r.created_at, GROUP_CONCAT(p.name ORDER BY p.name SEPARATOR ", ") AS permission_names FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id LEFT JOIN permissions p ON p.id = rp.permission_id GROUP BY r.id, r.name, r.description, r.created_at ORDER BY r.name')->fetchAll();
|
||||
$permissions = db()->query('SELECT name, description FROM permissions ORDER BY name')->fetchAll(); render_header('Roles and permissions'); echo '<div class="d-flex justify-content-between mb-4"><div><h1 class="h3">Roles and permissions</h1><p class="text-muted">Create custom roles and assign available permissions.</p></div></div>' . ($roleErrors ? '<div class="alert alert-danger">' . e(implode(' ', $roleErrors)) . '</div>' : '') . (isset($_GET['updated']) ? '<div class="alert alert-success">Role changes saved.</div>' : '') . '<div class="card mb-4"><div class="card-body"><h2 class="h5">Create custom role</h2><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="create"><div class="col-md-4"><input class="form-control" name="name" placeholder="Role name" required></div><div class="col-md-5"><input class="form-control" name="description" placeholder="Description"></div><div class="col-md-3"><button class="btn btn-primary">Create role</button></div></form></div></div>';
|
||||
foreach ($roles as $role) { echo '<div class="card mb-3"><div class="card-body"><h2 class="h5">' . e($role['name']) . '</h2><p class="text-muted">' . e((string)($role['description'] ?? '')) . '</p><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="permissions"><input type="hidden" name="role_id" value="' . (int)$role['id'] . '"><div class="row">'; $assigned = $role['permission_names'] ? explode(', ', $role['permission_names']) : []; foreach ($permissions as $permission) echo '<div class="col-md-4 form-check"><input class="form-check-input" type="checkbox" name="permissions[]" value="' . e($permission['name']) . '"' . (in_array($permission['name'], $assigned, true) ? ' checked' : '') . '><label class="form-check-label">' . e($permission['name']) . '</label></div>'; echo '</div><button class="btn btn-sm btn-outline-primary mt-3">Save permissions</button></form></div></div>'; }
|
||||
render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'notifications') {
|
||||
require_permission('notifications.view');
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $notificationId = filter_var(scalar_input($_POST['notification_id'] ?? null), FILTER_VALIDATE_INT); try { if (!(new \App\Domain\Notification\NotificationQueue())->markRead(db(), (int)$user['id'], ['notification_id' => $notificationId])) { http_response_code(404); exit('Notification not found'); } audit('notification_read', 'notification', (int)$notificationId); header('Location: /?route=notifications&read=1'); exit; } catch (Throwable $exception) { http_response_code(400); exit('Invalid notification'); } }
|
||||
$stmt = db()->prepare('SELECT id, type, title, body, read_at, created_at FROM notifications WHERE user_id = :user ORDER BY created_at DESC LIMIT 100'); $stmt->execute(['user' => $user['id']]); $notifications = $stmt->fetchAll(); render_header('Notifications'); echo '<div class="d-flex justify-content-between mb-4"><h1 class="h3">Notifications</h1></div>'; foreach ($notifications as $notification) { echo '<div class="card mb-2"><div class="card-body"><div class="d-flex justify-content-between"><strong>' . e($notification['title']) . '</strong><small class="text-muted">' . e($notification['created_at']) . '</small></div><p class="mb-2">' . e((string)($notification['body'] ?? '')) . '</p>'; if (!$notification['read_at']) echo '<form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="notification_id" value="' . (int)$notification['id'] . '"><button class="btn btn-sm btn-outline-primary">Mark read</button></form>'; echo '</div></div>'; } render_footer(); exit;
|
||||
}
|
||||
|
||||
if ($route === 'reports') {
|
||||
require_permission('reports.view');
|
||||
$format = scalar_input($_GET['format'] ?? null);
|
||||
if ($format === 'csv') require_permission('reports.export');
|
||||
if ($user['role_name'] === 'Technician') {
|
||||
$reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN te.technician_id = :user THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name');
|
||||
$reportStmt->execute(['user' => $user['id'], 'user_assigned' => $user['id']]);
|
||||
$reportRows = $reportStmt->fetchAll();
|
||||
} else {
|
||||
$reportRows = db()->query('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c LEFT JOIN jobcards j ON j.client_id = c.id LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name')->fetchAll();
|
||||
}
|
||||
try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); }
|
||||
$reportParams = ['client_id' => $filters->clientId ?? 0, 'status' => $filters->status ?? '', 'status_filter' => $filters->status ?? '', 'priority' => $filters->priority ?? '', 'priority_filter' => $filters->priority ?? '', 'date_from_a' => $filters->dateFrom ?? '', 'date_from_b' => $filters->dateFrom ?? '', 'date_to_a' => $filters->dateTo ?? '', 'date_to_b' => $filters->dateTo ?? ''];
|
||||
$reportScope = $user['role_name'] === 'Technician' ? 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned' : '';
|
||||
$reportParams['user_assigned'] = $user['id'];
|
||||
$hoursCondition = $user['role_name'] === 'Technician' ? 'te.technician_id = :user' : '1 = 1';
|
||||
$reportParams['user'] = $user['id'];
|
||||
$reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN ' . $hoursCondition . ' AND (:date_from_a = "" OR te.work_date >= :date_from_b) AND (:date_to_a = "" OR te.work_date <= :date_to_b) THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id ' . $reportScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = "time_entry" AND av.entity_id = te.id AND av.action = "time_entry_voided") WHERE (:client_id = 0 OR c.id = :client_filter) AND (:status = "" OR j.status = :status_filter) AND (:priority = "" OR j.priority = :priority_filter) GROUP BY c.id, c.name ORDER BY c.name');
|
||||
$reportParams['client_filter'] = $filters->clientId ?? 0;
|
||||
$reportStmt->execute($reportParams);
|
||||
$reportRows = $reportStmt->fetchAll();
|
||||
$rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
|
||||
$filterQuery = http_build_query(array_filter(['client_id' => $filters->clientId, 'date_from' => $filters->dateFrom, 'date_to' => $filters->dateTo, 'technician_id' => $filters->technicianId, 'status' => $filters->status, 'priority' => $filters->priority], static fn($value): bool => $value !== null && $value !== ''));
|
||||
if ($format === 'print') { header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); echo (new \PrintReportRenderer())->render('Hours per client', ['Client', 'Jobcards', 'Hours'], $rows); exit; }
|
||||
if ($format === 'csv') {
|
||||
$csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true);
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
@@ -605,18 +700,19 @@ if ($route === 'reports') {
|
||||
exit;
|
||||
}
|
||||
render_header('Reports');
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Reports</h1><p class="text-muted mb-0">Internal hours summary by client.</p></div>';
|
||||
if (can('reports.export')) echo '<a class="btn btn-outline-primary" href="/?route=reports&format=csv">Export CSV</a>';
|
||||
echo '</div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Client</th><th>Jobcards</th><th>Hours</th></tr></thead><tbody>';
|
||||
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Reports</h1><p class="text-muted mb-0">Internal hours summary by client.</p></div><a class="btn btn-outline-secondary me-2" href="/?route=reports&format=print&' . e($filterQuery) . '">Print view</a>';
|
||||
if (can('reports.export')) echo '<a class="btn btn-outline-primary" href="/?route=reports&format=csv&' . e($filterQuery) . '">Export CSV</a>';
|
||||
echo '</div><form class="row g-2 mb-3" method="get"><input type="hidden" name="route" value="reports"><div class="col-md-3"><input class="form-control" type="number" min="1" name="client_id" placeholder="Client ID" value="' . e((string)($filters->clientId ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_from" value="' . e((string)($filters->dateFrom ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_to" value="' . e((string)($filters->dateTo ?? '')) . '"></div><div class="col-auto"><button class="btn btn-outline-secondary">Apply filters</button></div></form><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Client</th><th>Jobcards</th><th>Hours</th></tr></thead><tbody>';
|
||||
if (!$reportRows) echo '<tr><td colspan="3" class="text-center text-muted py-4">No report data available.</td></tr>';
|
||||
foreach ($reportRows as $row) echo '<tr><td>' . e($row['client_name']) . '</td><td>' . (int)$row['jobcards'] . '</td><td>' . e(number_format((float)$row['hours'], 2)) . '</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;
|
||||
if ($route === 'audit') {
|
||||
require_permission('audit.view');
|
||||
$stmt = db()->query('SELECT a.id, a.action, a.entity_type, a.entity_id, a.metadata, a.ip_address, a.created_at, u.name AS user_name FROM audit_events a LEFT JOIN users u ON u.id = a.user_id ORDER BY a.created_at DESC, a.id DESC LIMIT 200');
|
||||
render_header('Audit trail'); echo '<h1 class="h3 mb-4">Audit trail</h1><div class="card"><div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>When</th><th>User</th><th>Action</th><th>Entity</th><th>Metadata</th></tr></thead><tbody>'; foreach ($stmt->fetchAll() as $event) echo '<tr><td>' . e($event['created_at']) . '</td><td>' . e((string)($event['user_name'] ?? 'System')) . '</td><td>' . e($event['action']) . '</td><td>' . e($event['entity_type']) . ' #' . (int)$event['entity_id'] . '</td><td><code>' . e((string)($event['metadata'] ?? '')) . '</code></td></tr>'; echo '</tbody></table></div></div>'; render_footer(); exit;
|
||||
}
|
||||
|
||||
http_response_code(404); render_header('Not found'); ?><div class="alert alert-warning">Page not found.</div><?php render_footer();
|
||||
|
||||
Reference in New Issue
Block a user