feat: complete jobcard client management foundation

This commit is contained in:
Marco0300
2026-09-01 20:43:24 +02:00
parent 168c15c6ae
commit de2bf277c4
34 changed files with 2072 additions and 74 deletions
+159 -16
View File
@@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/bootstrap.php';
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/Jobcard/JobcardReference.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php';
@@ -15,6 +16,11 @@ 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/Reporting/CsvExporter.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
ini_set('session.use_strict_mode', '1');
$forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
@@ -46,7 +52,7 @@ function render_footer(): void
$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'); }
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Logout requires POST'); }
verify_csrf();
if (current_user()) audit('logout', 'user', (int)current_user()['id']);
$_SESSION = [];
@@ -58,7 +64,7 @@ if ($route === 'logout') {
if ($route === 'login') {
if (current_user()) { header('Location: /?route=dashboard'); exit; }
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === '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))) ]);
@@ -101,6 +107,23 @@ if ($route === 'dashboard') {
}
$permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view'];
if ($route === 'attachment') {
require_login();
$attachmentId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
$attachmentStmt = db()->prepare('SELECT a.*, j.id AS jobcard_id FROM attachments a JOIN jobcards j ON j.id = a.jobcard_id WHERE a.id = :id');
$attachmentStmt->execute(['id' => $attachmentId]);
$attachment = $attachmentStmt->fetch();
if (!$attachment || !can_access_jobcard((int)$attachment['jobcard_id']) || !can('attachments.view')) { http_response_code(404); exit('Attachment not found'); }
$path = dirname(__DIR__) . '/storage/uploads/' . basename($attachment['stored_name']);
if (!is_file($path) || !is_readable($path)) { http_response_code(404); exit('Attachment not found'); }
audit('attachment_downloaded', 'attachment', $attachmentId, ['jobcard_id' => (int)$attachment['jobcard_id']]);
header('Content-Type: ' . $attachment['mime_type']);
header('Content-Length: ' . (string)filesize($path));
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $attachment['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
readfile($path); exit;
}
if ($route === 'jobcard') {
require_permission('jobcards.view');
$jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
@@ -111,7 +134,7 @@ if ($route === 'jobcard') {
if (!$jobcard) { http_response_code(404); exit('Jobcard not found'); }
if (!can_access_jobcard($jobcardId)) { http_response_code(404); exit('Jobcard not found'); }
$actionErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$action = scalar_input($_POST['action'] ?? null);
if ($action === 'status') {
@@ -183,12 +206,57 @@ if ($route === 'jobcard') {
audit('time_entry_created', 'jobcard', $jobcardId, ['hours' => $time['hours']]);
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
}
} elseif ($action === 'attachment') {
require_permission('attachments.manage');
$file = $_FILES['attachment'] ?? null;
if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) {
$actionErrors[] = 'Select a valid attachment.';
} else {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$signatureValid = match ($mime) {
'image/png' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 8), 0, 8) === "\x89PNG\x0D\x0A\x1A\x0A",
'image/jpeg' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 3), 0, 3) === "\xFF\xD8\xFF",
'image/gif' => in_array(substr((string)file_get_contents($file['tmp_name'], false, null, 0, 6), 0, 6), ['GIF87a', 'GIF89a'], true),
'application/pdf' => str_starts_with((string)file_get_contents($file['tmp_name'], false, null, 0, 5), '%PDF-'),
default => true,
};
if (!$signatureValid) $actionErrors[] = 'Attachment content does not match its detected type.';
if ($actionErrors) { /* validation stops before storage */ }
else {
$attachment = (new \App\Domain\Attachment\AttachmentValidator())->validate(['name' => $file['name'] ?? '', 'mime_type' => $mime, 'size_bytes' => $file['size'] ?? -1, 'client_visible' => isset($_POST['client_visible']), 'client_approved' => isset($_POST['client_approved'])]);
$actionErrors = array_values($attachment['errors']);
if (!$actionErrors) {
$uploadDir = dirname(__DIR__) . '/storage/uploads';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0750, true) && !is_dir($uploadDir)) $actionErrors[] = 'Attachment storage is unavailable.';
if (!$actionErrors) {
$storedName = bin2hex(random_bytes(24)) . '.' . $attachment['extension'];
if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $storedName)) $actionErrors[] = 'Attachment could not be stored.';
else {
try {
db()->beginTransaction();
db()->prepare('INSERT INTO attachments (jobcard_id, original_name, stored_name, mime_type, file_size, client_visible, uploaded_by) VALUES (:jobcard, :original, :stored, :mime, :size, :visible, :user)')->execute(['jobcard' => $jobcardId, 'original' => $attachment['name'], 'stored' => $storedName, 'mime' => $attachment['mime_type'], 'size' => $attachment['size_bytes'], 'visible' => $attachment['client_visible'] ? 1 : 0, 'user' => $user['id']]);
$attachmentId = (int)db()->lastInsertId();
audit('attachment_uploaded', 'attachment', $attachmentId, ['jobcard_id' => $jobcardId]);
db()->commit();
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
} catch (Throwable $exception) {
if (db()->inTransaction()) db()->rollBack();
@unlink($uploadDir . '/' . $storedName);
$actionErrors[] = 'Attachment metadata could not be saved.';
}
}
}
}
}
}
}
}
$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();
$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']);
echo '<div class="d-flex justify-content-between align-items-start mb-4"><div><a href="/?route=jobcards" class="text-decoration-none">← Back to jobcards</a><h1 class="h3 mt-2 mb-1">' . e($jobcard['reference_no']) . '</h1><p class="text-muted mb-0">' . e($jobcard['client_name']) . '</p></div><span class="badge text-bg-primary">' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</span></div>' . (isset($_GET['updated']) ? '<div class="alert alert-success">Jobcard updated.</div>' : '') . ($actionErrors ? '<div class="alert alert-danger">' . e(implode(' ', $actionErrors)) . '</div>' : '');
@@ -203,13 +271,20 @@ if ($route === 'jobcard') {
if (!$assigned) echo '<p class="text-muted">No technicians assigned.</p>'; foreach ($assigned as $assignment) echo '<div class="py-1">' . e($assignment['name']) . '</div>';
if (can('jobcards.assign')) { echo '<hr><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="assign"><select class="form-select mb-2" name="technician_id"><option value="">Select technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select><button class="btn btn-outline-primary w-100">Assign</button></form>'; }
echo '</div></div></div></div>';
if (can('attachments.view') || can('attachments.manage')) {
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Attachments</h2>';
if (!$attachments) echo '<p class="text-muted">No attachments.</p>';
foreach ($attachments as $attachment) echo '<div class="border-bottom py-2"><a href="/?route=attachment&id=' . (int)$attachment['id'] . '"><strong>' . e($attachment['original_name']) . '</strong></a> <span class="small text-muted">' . e($attachment['mime_type']) . ' · ' . e((string)$attachment['file_size']) . ' bytes · ' . ($attachment['client_visible'] ? 'Client approved' : 'Internal') . '</span></div>';
if (can('attachments.manage')) echo '<hr><form method="post" enctype="multipart/form-data" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="attachment"><div class="col-md-6"><input class="form-control" type="file" name="attachment" required></div><div class="col-md-3 form-check pt-2"><input class="form-check-input" type="checkbox" name="client_visible" value="1" id="attachment-visible"><label class="form-check-label" for="attachment-visible">Client visible</label></div><div class="col-md-3 form-check pt-2"><input class="form-check-input" type="checkbox" name="client_approved" value="1" id="attachment-approved"><label class="form-check-label" for="attachment-approved">Client approval confirmed</label></div><div class="col-12"><button class="btn btn-outline-primary">Upload attachment</button></div></form>';
echo '</div></div>';
}
render_footer(); exit;
}
if ($route === 'jobcards') {
require_permission('jobcards.view');
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
require_permission('jobcards.manage');
verify_csrf();
$command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST);
@@ -218,8 +293,8 @@ if ($route === 'jobcards') {
$priority = $command['priority'];
$errors = array_values($command['errors']);
if (!$errors) {
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'");
$clientCheck->execute(['id' => $clientId]);
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'" . ($user['role_name'] === 'Technician' ? ' AND EXISTS (SELECT 1 FROM jobcards assigned_j JOIN jobcard_assignments assigned_a ON assigned_a.jobcard_id = assigned_j.id WHERE assigned_j.client_id = clients.id AND assigned_a.user_id = :user)' : ''));
$clientCheck->execute($user['role_name'] === 'Technician' ? ['id' => $clientId, 'user' => $user['id']] : ['id' => $clientId]);
if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.';
}
if (!$errors) {
@@ -246,7 +321,13 @@ if ($route === 'jobcards') {
}
}
}
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
if ($user['role_name'] === 'Technician') {
$clientListForJobcard = db()->prepare("SELECT DISTINCT c.id, c.name 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 WHERE c.status = 'active' ORDER BY c.name");
$clientListForJobcard->execute(['user' => $user['id']]);
$clients = $clientListForJobcard->fetchAll();
} else {
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
}
if ($user['role_name'] === 'Technician') {
$jobcardList = db()->prepare('SELECT j.id, 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 JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user ORDER BY j.created_at DESC LIMIT 100');
$jobcardList->execute(['user' => $user['id']]);
@@ -272,6 +353,7 @@ 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'); }
if (!can_access_client($clientId)) { http_response_code(404); exit('Client not found'); }
$stmt = db()->prepare('SELECT * FROM clients WHERE id = :id');
$stmt->execute(['id' => $clientId]);
$client = $stmt->fetch();
@@ -279,10 +361,36 @@ if ($route === 'client') {
$contactErrors = [];
$contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false];
$slaErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$credentialErrors = [];
$revealedCredential = null;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$clientAction = scalar_input($_POST['action'] ?? null, 'contact');
if ($clientAction === 'sla') {
if ($clientAction === 'credential_reveal') {
require_permission('credentials.view');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
$credentialId = filter_var(scalar_input($_POST['credential_id'] ?? null), FILTER_VALIDATE_INT);
$credentialStmt = db()->prepare('SELECT * FROM credentials WHERE id = :id AND client_id = :client AND is_active = 1');
$credentialStmt->execute(['id' => $credentialId, 'client' => $clientId]);
$credential = $credentialStmt->fetch();
if (!$credential) $credentialErrors[] = 'Credential not found.';
else {
try { $revealedCredential = ['id' => (int)$credential['id'], 'secret' => (new \App\Domain\Credential\CredentialVault())->decrypt($credential['secret_ciphertext'])]; audit('credential_revealed', 'credential', (int)$credential['id'], ['client_id' => $clientId]); }
catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be decrypted.'; }
}
} elseif ($clientAction === 'client_update') {
require_permission('clients.manage');
$existingClients = db()->query('SELECT id, name FROM clients')->fetchAll();
$clientUpdate = (new \App\Domain\Client\ClientUpdateCommand())->validateForEdit($clientId, $_POST, $existingClients);
$contactErrors = $clientUpdate['errors'];
if (!$contactErrors) {
$stmt = db()->prepare('UPDATE clients SET name = :name, registration_number = :registration, status = :status, support_email = :email, support_phone = :phone, preferred_contact_method = :method, physical_address = :physical, postal_address = :postal, general_notes = :notes WHERE id = :id');
$stmt->execute(['name' => $clientUpdate['name'], 'registration' => $clientUpdate['registration_number'], 'status' => $clientUpdate['status'], 'email' => $clientUpdate['support_email'], 'phone' => $clientUpdate['support_phone'], 'method' => $clientUpdate['preferred_contact_method'], 'physical' => $clientUpdate['physical_address'], 'postal' => $clientUpdate['postal_address'], 'notes' => $clientUpdate['general_notes'], 'id' => $clientId]);
audit('client_updated', 'client', $clientId);
header('Location: /?route=client&id=' . $clientId . '&client_updated=1'); exit;
}
} elseif ($clientAction === 'sla') {
require_permission('sla.manage');
$sla = (new \App\Domain\SLA\SlaAgreement())->validate([...$_POST, 'client_id' => $clientId, 'enabled' => isset($_POST['enabled']) ? '1' : '0', 'rollover_enabled' => isset($_POST['rollover_enabled']) ? '1' : '0']);
$slaErrors = array_values($sla['errors']);
@@ -291,6 +399,16 @@ if ($route === 'client') {
audit('sla_agreement_updated', 'client', $clientId);
header('Location: /?route=client&id=' . $clientId . '&sla_updated=1'); exit;
}
} elseif ($clientAction === 'credential') {
require_permission('credentials.manage');
try {
$vault = new \App\Domain\Credential\CredentialVault();
$storedCredential = $vault->encryptCredential(['category' => $_POST['category'] ?? null, 'label' => $_POST['label'] ?? null, 'username' => $_POST['username'] ?? null, 'notes' => $_POST['credential_notes'] ?? null, 'secret' => scalar_input($_POST['secret'] ?? null)]);
$credentialInsert = db()->prepare('INSERT INTO credentials (client_id, category, label, username, secret_ciphertext, notes, created_by) VALUES (:client, :category, :label, :username, :ciphertext, :notes, :user)');
$credentialInsert->execute(['client' => $clientId, 'category' => $storedCredential['category'], 'label' => $storedCredential['label'], 'username' => $storedCredential['username'], 'ciphertext' => $storedCredential['secret_ciphertext'], 'notes' => $storedCredential['notes'], 'user' => $user['id']]);
$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.'; }
} else {
require_permission('clients.manage');
$contact = validate_client_contact($_POST);
@@ -325,6 +443,12 @@ if ($route === 'client') {
$slaStmt->execute(['client' => $clientId]);
$slaAgreement = $slaStmt->fetch() ?: null;
}
$credentialRows = [];
if (can('credentials.view') || can('credentials.manage')) {
$credentialStmt = db()->prepare('SELECT id, category, label, username, secret_ciphertext, notes FROM credentials WHERE client_id = :client AND is_active = 1 ORDER BY category, label');
$credentialStmt->execute(['client' => $clientId]);
$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>';
if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>';
@@ -338,6 +462,19 @@ if ($route === 'client') {
else echo '<p class="text-muted mb-0">No SLA agreement configured.</p>';
echo '</div></div>';
}
if (can('credentials.view') || can('credentials.manage')) {
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Protected credentials</h2>';
if ($credentialErrors) echo '<div class="alert alert-danger">' . e(implode(' ', $credentialErrors)) . '</div>';
if (isset($_GET['credential_created'])) echo '<div class="alert alert-success">Credential saved securely.</div>';
foreach ($credentialRows as $credentialRow) {
echo '<div class="border-bottom py-2"><strong>' . e($credentialRow['label']) . '</strong> <span class="badge text-bg-secondary">' . e($credentialRow['category']) . '</span><div class="small text-muted">Username: ' . e((string)($credentialRow['username'] ?? '—')) . ' · Secret: ' . ($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'] ? '<code>' . e($revealedCredential['secret']) . '</code>' : '••••••••••••••••••••') . '</div>';
if (can('credentials.view') && !($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'])) echo '<form method="post" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="credential_reveal"><input type="hidden" name="credential_id" value="' . (int)$credentialRow['id'] . '"><button class="btn btn-sm btn-link p-0">Reveal once</button></form>';
echo '</div>';
}
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('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;
}
@@ -346,7 +483,7 @@ if ($route === 'clients') {
require_permission('clients.view');
$errors = [];
$old = ['name' => '', 'status' => 'active'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
require_permission('clients.manage');
verify_csrf();
$validated = validate_client($_POST);
@@ -363,8 +500,14 @@ if ($route === 'clients') {
}
$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();
if ($user['role_name'] === 'Technician') {
$clientList = db()->prepare("SELECT DISTINCT c.id, c.name, c.status, c.support_email, c.support_phone, c.created_at 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 WHERE (:search = '' OR c.name LIKE :like_name OR c.support_email LIKE :like_email) ORDER BY c.name LIMIT 100");
$clientList->execute(['user' => $user['id'], 'search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']);
$clients = $clientList->fetchAll();
} else {
$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>';
@@ -385,7 +528,7 @@ if ($route === 'users') {
require_permission('users.manage');
$userErrors = [];
$userOld = ['name' => '', 'email' => '', 'role_id' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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);
@@ -415,7 +558,7 @@ if ($route === 'users') {
if (false) {
require_permission('users.manage');
$userErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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);
@@ -446,8 +589,8 @@ if ($route === 'reports') {
$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(te.hours), 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 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']]);
$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();