feat: expand client jobcard and reporting workflows

This commit is contained in:
Marco0300
2026-09-01 19:34:41 +02:00
parent 323afaf832
commit 703c3ca67d
10 changed files with 608 additions and 18 deletions
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Domain\Client;
/**
* Normalizes and validates client records without a framework or persistence
* dependency. The output shape is stable for PDO adapters and controllers.
*/
final class ClientRecord
{
/** @var list<string> */
private const FIELDS = [
'name',
'registration_number',
'status',
'support_email',
'support_phone',
'preferred_contact_method',
'physical_address',
'postal_address',
'general_notes',
];
/** @var list<string> */
private const DISPLAY_FIELDS = [
'id',
'name',
'registration_number',
'status',
'support_email',
'support_phone',
'preferred_contact_method',
'physical_address',
'postal_address',
'general_notes',
];
/** @return array<string, string|null> */
public function normalize(array $record): array
{
return [
'name' => $this->text($record['name'] ?? null) ?? '',
'registration_number' => $this->text($record['registration_number'] ?? null),
'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'),
'support_email' => $this->lowerText($record['support_email'] ?? null),
'support_phone' => $this->text($record['support_phone'] ?? null),
'preferred_contact_method' => $this->text($record['preferred_contact_method'] ?? null),
'physical_address' => $this->text($record['physical_address'] ?? null),
'postal_address' => $this->text($record['postal_address'] ?? null),
'general_notes' => $this->text($record['general_notes'] ?? null),
];
}
/** @return array{valid: bool, errors: array<string, string>, name: string, registration_number: string|null, status: string, support_email: string|null, support_phone: string|null, preferred_contact_method: string|null, physical_address: string|null, postal_address: string|null, general_notes: string|null} */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if ($normalized['name'] === '') {
$errors['name'] = 'Client name is required.';
} elseif (mb_strlen($normalized['name']) > 190) {
$errors['name'] = 'Client name must be 190 characters or fewer.';
}
if (!in_array($normalized['status'], ['active', 'inactive'], true)) {
$errors['status'] = 'Invalid client status.';
}
if ($normalized['registration_number'] !== null && mb_strlen($normalized['registration_number']) > 120) {
$errors['registration_number'] = 'Registration number must be 120 characters or fewer.';
}
if ($normalized['support_email'] !== null) {
if (filter_var($normalized['support_email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['support_email'] = 'Support email must be a valid email address.';
} elseif (mb_strlen($normalized['support_email']) > 190) {
$errors['support_email'] = 'Support email must be 190 characters or fewer.';
}
}
if ($normalized['support_phone'] !== null && !$this->validPhone($normalized['support_phone'])) {
$errors['support_phone'] = 'Support phone must contain a valid phone number.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
/** @return array<string, mixed> */
public function display(array $record): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $record[$field];
}
}
return $safe;
}
/** @return array<string, mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
private function text(mixed $value): ?string
{
if ($value === null) return null;
$text = trim(is_scalar($value) ? (string) $value : '');
return $text === '' ? null : $text;
}
private function lowerText(mixed $value): ?string
{
$text = $this->text($value);
return $text === null ? null : strtolower($text);
}
private function validPhone(string $phone): bool
{
if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) {
return false;
}
return preg_match('/\d.*\d.*\d.*\d.*\d.*\d.*\d/', $phone) === 1;
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ final class JobcardReference
{ {
public static function generate(int $sequence, ?int $year = null): string public static function generate(int $sequence, ?int $year = null): string
{ {
if ($sequence < 1) { if ($sequence < 1 || $sequence > 999999) {
throw new \InvalidArgumentException('Sequence must be positive.'); throw new \InvalidArgumentException('Sequence must be between 1 and 999999.');
} }
$year ??= (int) date('Y'); $year ??= (int) date('Y');
if ($year < 2000 || $year > 9999) { if ($year < 2000 || $year > 9999) {
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
require_once __DIR__ . '/StatusTransitionValidator.php';
final class JobcardWorkflow
{
public const INITIAL_STATUS = 'new';
public const PRIORITIES = ['low', 'normal', 'high', 'critical'];
private const MAX_WORK_REQUESTED_LENGTH = 10000;
public function __construct(private readonly ?StatusTransitionValidator $transitions = null)
{
}
/** @return list<string> */
public function allowedInitialStatuses(): array
{
return [self::INITIAL_STATUS];
}
/** @return list<string> */
public function allowedPriorities(): array
{
return self::PRIORITIES;
}
/**
* @return array{valid: bool, client_id: ?int, work_requested: string, priority: string, status: string, errors: array<string, string>}
*/
public function validateCommand(array $command): array
{
$errors = [];
$clientId = $this->positiveInteger($command['client_id'] ?? null);
$workRequested = is_scalar($command['work_requested'] ?? null)
? trim((string) $command['work_requested'])
: '';
$priority = is_scalar($command['priority'] ?? null)
? (string) $command['priority']
: '';
if ($clientId === null) {
$errors['client_id'] = 'Client ID must be a positive integer.';
}
if ($workRequested === '') {
$errors['work_requested'] = 'Work requested is required.';
} elseif (mb_strlen($workRequested) > self::MAX_WORK_REQUESTED_LENGTH) {
$errors['work_requested'] = 'Work requested must be 10000 characters or fewer.';
}
if (!in_array($priority, self::PRIORITIES, true)) {
$errors['priority'] = 'Invalid jobcard priority.';
}
return [
'valid' => $errors === [],
'client_id' => $clientId,
'work_requested' => $workRequested,
'priority' => $priority,
'status' => self::INITIAL_STATUS,
'errors' => $errors,
];
}
/**
* Validate a status change and the timestamps required by terminal statuses.
*
* @return array{valid: bool, from: string, to: string, completed_at: ?string, closed_at: ?string, errors: array<string, string>}
*/
public function validateTransition(
string $from,
string $to,
?string $completedAt = null,
?string $closedAt = null,
): array {
$errors = [];
$transitionValidator = $this->transitions ?? new StatusTransitionValidator();
if (!$transitionValidator->canTransition($from, $to)) {
$errors['transition'] = 'Jobcard status transition is not allowed.';
}
$completed = $this->parseTimestamp($completedAt);
$closed = $this->parseTimestamp($closedAt);
if ($completedAt !== null && $completed === null) {
$errors['completed_at'] = 'Completion timestamp must be a valid datetime.';
}
if ($closedAt !== null && $closed === null) {
$errors['closed_at'] = 'Closure timestamp must be a valid datetime.';
}
if (in_array($to, ['completed', 'closed'], true) && $completedAt === null) {
$errors['completed_at'] = 'Completion timestamp is required.';
}
if ($to === 'closed' && $closedAt === null) {
$errors['closed_at'] = 'Closure timestamp is required.';
}
if ($completed !== null && $closed !== null && $closed < $completed) {
$errors['timestamps'] = 'Closure timestamp must not precede completion timestamp.';
}
return [
'valid' => $errors === [],
'from' => $from,
'to' => $to,
'completed_at' => $completedAt,
'closed_at' => $closedAt,
'errors' => $errors,
];
}
private function positiveInteger(mixed $value): ?int
{
if (is_bool($value) || (is_int($value) && $value > 0)) {
return is_int($value) && $value > 0 ? $value : null;
}
if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) {
$integer = filter_var($value, FILTER_VALIDATE_INT);
return $integer !== false && $integer > 0 ? $integer : null;
}
return null;
}
private function parseTimestamp(?string $value): ?\DateTimeImmutable
{
if ($value === null) return null;
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value);
$errors = \DateTimeImmutable::getLastErrors();
if ($parsed === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
return null;
}
return $parsed->format('Y-m-d H:i:s') === $value ? $parsed : null;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Serializes tabular data as RFC 4180-compatible CSV.
*/
final class CsvExporter
{
private const UTF8_BOM = "\xEF\xBB\xBF";
/**
* @param list<mixed> $headers
* @param list<list<mixed>> $rows
*/
public function export(array $headers, array $rows, bool $withBom = false): string
{
$records = [];
$records[] = $this->formatRecord($headers);
$headerCount = count($headers);
$rowNumber = 0;
foreach ($rows as $row) {
$rowNumber++;
if (!is_array($row)) {
throw new InvalidArgumentException(sprintf('CSV row %d must be an array.', $rowNumber));
}
$fieldCount = count($row);
if ($fieldCount !== $headerCount) {
throw new InvalidArgumentException(sprintf(
'CSV row %d has %d fields; expected %d fields for %d headers.',
$rowNumber,
$fieldCount,
$headerCount,
$headerCount,
));
}
$records[] = $this->formatRecord($row);
}
$csv = implode("\r\n", $records) . "\r\n";
return $withBom ? self::UTF8_BOM . $csv : $csv;
}
/**
* @param list<mixed> $fields
*/
private function formatRecord(array $fields): string
{
return implode(',', array_map(
fn (mixed $field): string => $this->escapeField($field),
$fields,
));
}
private function escapeField(mixed $field): string
{
if ($field === null) {
$value = '';
} elseif (is_scalar($field)) {
$value = (string)$field;
} else {
throw new InvalidArgumentException('CSV fields must be scalar values or null.');
}
// Prefix formula-like values so spreadsheet programs treat them as text.
if ($value !== '' && preg_match('/^[=+\-@]/', $value) === 1) {
$value = "'" . $value;
}
if (strpbrk($value, ",\"\r\n") === false) {
return $value;
}
return '"' . str_replace('"', '""', $value) . '"';
}
}
+8 -1
View File
@@ -58,11 +58,18 @@ CREATE TABLE IF NOT EXISTS client_contacts (
email VARCHAR(190) NULL, email VARCHAR(190) NULL,
phone VARCHAR(60) NULL, phone VARCHAR(60) NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE, is_primary BOOLEAN NOT NULL DEFAULT FALSE,
primary_client_id BIGINT UNSIGNED AS (IF(is_primary, client_id, NULL)) STORED,
notes TEXT NULL, notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
INDEX contacts_client_idx (client_id) INDEX contacts_client_idx (client_id),
UNIQUE KEY one_primary_contact (primary_client_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS jobcard_sequences (
sequence_year SMALLINT UNSIGNED PRIMARY KEY,
next_sequence INT UNSIGNED NOT NULL
) ENGINE=InnoDB; ) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS sla_agreements ( CREATE TABLE IF NOT EXISTS sla_agreements (
+79 -12
View File
@@ -3,7 +3,11 @@ declare(strict_types=1);
require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../config/bootstrap.php';
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.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/Jobcard/JobcardReference.php'; require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php';
ini_set('session.use_strict_mode', '1'); ini_set('session.use_strict_mode', '1');
$forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; $forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
@@ -79,26 +83,37 @@ if ($route === 'jobcards') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('jobcards.manage'); require_permission('jobcards.manage');
verify_csrf(); verify_csrf();
$clientId = filter_var(scalar_input($_POST['client_id'] ?? null), FILTER_VALIDATE_INT); $command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST);
$workRequested = trim(scalar_input($_POST['work_requested'] ?? null)); $clientId = $command['client_id'];
$priority = scalar_input($_POST['priority'] ?? null, 'normal'); $workRequested = $command['work_requested'];
if (!$clientId || $workRequested === '' || mb_strlen($workRequested) > 10000 || !in_array($priority, ['low', 'normal', 'high', 'critical'], true)) { $priority = $command['priority'];
$errors[] = 'Select a client, enter the requested work, and choose a valid priority.'; $errors = array_values($command['errors']);
} else { if (!$errors) {
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'"); $clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'");
$clientCheck->execute(['id' => $clientId]); $clientCheck->execute(['id' => $clientId]);
if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.'; if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.';
} }
if (!$errors) { if (!$errors) {
$year = (int)date('Y'); $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'); $pdo = db();
$sequenceStmt->execute(['prefix' => 'JC-' . $year . '-%']); try {
$reference = \App\Domain\Jobcard\JobcardReference::generate((int)$sequenceStmt->fetchColumn(), $year); $pdo->beginTransaction();
$stmt = db()->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)'); $sequenceStmt = $pdo->prepare('INSERT INTO jobcard_sequences (sequence_year, next_sequence) VALUES (:year, 2) ON DUPLICATE KEY UPDATE next_sequence = next_sequence + 1');
$sequenceStmt->execute(['year' => $year]);
$sequenceStmt = $pdo->prepare('SELECT next_sequence - 1 FROM jobcard_sequences WHERE sequence_year = :year FOR UPDATE');
$sequenceStmt->execute(['year' => $year]);
$sequence = (int)$sequenceStmt->fetchColumn();
$reference = \App\Domain\Jobcard\JobcardReference::generate($sequence, $year);
$stmt = $pdo->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]); $stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]);
$jobcardId = (int)db()->lastInsertId(); $jobcardId = (int)$pdo->lastInsertId();
audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]); audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]);
$pdo->commit();
header('Location: /?route=jobcards&created=1'); exit; header('Location: /?route=jobcards&created=1'); exit;
} catch (Throwable $exception) {
if ($pdo->inTransaction()) $pdo->rollBack();
$errors[] = 'The jobcard could not be created. Please try again.';
}
} }
} }
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll(); $clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
@@ -125,13 +140,41 @@ if ($route === 'client') {
$stmt->execute(['id' => $clientId]); $stmt->execute(['id' => $clientId]);
$client = $stmt->fetch(); $client = $stmt->fetch();
if (!$client) { http_response_code(404); exit('Client not found'); } if (!$client) { http_response_code(404); exit('Client not found'); }
$contactErrors = [];
$contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('clients.manage');
verify_csrf();
$contact = validate_client_contact($_POST);
$contactOld = $contact;
$contactErrors = $contact['errors'];
if ($contactErrors === []) {
$pdo = db();
try {
$pdo->beginTransaction();
if ($contact['is_primary']) {
$pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]);
}
$contactInsert = $pdo->prepare('INSERT INTO client_contacts (client_id, name, email, phone, is_primary) VALUES (:client, :name, :email, :phone, :primary)');
$contactInsert->execute(['client' => $clientId, 'name' => $contact['name'], 'email' => $contact['email'], 'phone' => $contact['phone'], 'primary' => $contact['is_primary'] ? 1 : 0]);
$contactId = (int)$pdo->lastInsertId();
audit('client_contact_created', 'client_contact', $contactId, ['client_id' => $clientId]);
$pdo->commit();
header('Location: /?route=client&id=' . $clientId . '&contact_created=1'); exit;
} catch (Throwable $exception) {
if ($pdo->inTransaction()) $pdo->rollBack();
$contactErrors[] = 'The contact could not be created. Please try again.';
}
}
}
$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 name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name');
$contactsStmt->execute(['id' => $clientId]); $contactsStmt->execute(['id' => $clientId]);
$contacts = $contactsStmt->fetchAll(); $contacts = $contactsStmt->fetchAll();
render_header('Client details'); 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>'; 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>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</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>'; 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></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()) . '"><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>'; echo '</div></div></div></div>';
render_footer(); render_footer();
exit; exit;
@@ -176,6 +219,30 @@ if ($route === 'clients') {
exit; exit;
} }
if ($route === 'reports') {
require_permission('reports.view');
$format = scalar_input($_GET['format'] ?? null);
if ($format === 'csv') require_permission('reports.export');
$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();
$rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
if ($format === 'csv') {
$csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="hours-per-client.csv"');
header('Cache-Control: no-store');
echo $csv;
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>';
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])) { if (isset($permissionByRoute[$route])) {
require_permission($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; 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;
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php';
use App\Domain\Client\ClientRecord;
function client_record_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$service = new ClientRecord();
$normalized = $service->normalize([
'name' => ' Acme IT ',
'registration_number' => ' REG-42 ',
'status' => ' ACTIVE ',
'support_email' => ' SUPPORT@EXAMPLE.TEST ',
'support_phone' => ' +27 11 555 0100 ',
'preferred_contact_method' => ' email ',
'physical_address' => ' 1 Main Street ',
'postal_address' => '',
'general_notes' => ' Call first ',
]);
client_record_assert_same([
'name' => 'Acme IT',
'registration_number' => 'REG-42',
'status' => 'active',
'support_email' => 'support@example.test',
'support_phone' => '+27 11 555 0100',
'preferred_contact_method' => 'email',
'physical_address' => '1 Main Street',
'postal_address' => null,
'general_notes' => 'Call first',
], $normalized, 'Client records should normalize accepted fields deterministically.');
$invalid = $service->validate([
'name' => 'Client',
'support_email' => 'not-an-email',
'support_phone' => 'abc',
'registration_number' => str_repeat('R', 121),
]);
if ($invalid['valid'] !== false || !isset($invalid['errors']['support_email'])
|| !isset($invalid['errors']['support_phone']) || !isset($invalid['errors']['registration_number'])) {
throw new RuntimeException('Expected optional contact and registration fields to be validated.');
}
$valid = $service->validate(['name' => ' Client ']);
client_record_assert_same([], $valid['errors'], 'A client may omit optional contact and registration fields.');
client_record_assert_same(true, $valid['valid'], 'Valid client records should report valid=true.');
client_record_assert_same(null, $valid['support_email'], 'Missing email should normalize to null.');
client_record_assert_same(null, $valid['support_phone'], 'Missing phone should normalize to null.');
client_record_assert_same(null, $valid['registration_number'], 'Missing registration should normalize to null.');
$display = $service->display([
'id' => 7,
'name' => 'Acme IT',
'registration_number' => 'REG-42',
'status' => 'active',
'support_email' => 'support@example.test',
'support_phone' => '+27 11 555 0100',
'password' => 'secret',
'password_hash' => 'hash',
'credentials' => 'token',
'created_by' => 99,
'unknown_field' => 'omit',
]);
client_record_assert_same([
'id' => 7,
'name' => 'Acme IT',
'registration_number' => 'REG-42',
'status' => 'active',
'support_email' => 'support@example.test',
'support_phone' => '+27 11 555 0100',
], $display, 'Display projection must be explicitly allow-listed.');
printf("Client record tests: 4 passed\n");
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php';
function csv_exporter_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$exporter = new CsvExporter();
csv_exporter_assert_same(
"Name,Notes,Amount\r\nAlice,\"Comma, quote \"\"inside\"\"\",12.5\r\n\"Bob\r\nJones\",plain,\r\n",
$exporter->export(
['Name', 'Notes', 'Amount'],
[
['Alice', 'Comma, quote "inside"', 12.5],
["Bob\r\nJones", 'plain', null],
],
),
'CSV fields must follow RFC 4180 quoting and deterministic CRLF records.'
);
$withoutBom = $exporter->export(['Name'], [['Alice']]);
$withBom = $exporter->export(['Name'], [['Alice']], true);
csv_exporter_assert_same("Name\r\nAlice\r\n", $withoutBom, 'CSV must not include a BOM by default.');
csv_exporter_assert_same("\xEF\xBB\xBFName\r\nAlice\r\n", $withBom, 'CSV must include a UTF-8 BOM only when requested.');
csv_exporter_assert_same("Name\r\n'=SUM(A1:A2)\r\n", $exporter->export(['Name'], [['=SUM(A1:A2)']]), 'CSV must neutralize spreadsheet formulas.');
$mismatchRaised = false;
try {
$exporter->export(['Name', 'Amount'], [['Alice']]);
} catch (InvalidArgumentException $exception) {
$mismatchRaised = str_contains($exception->getMessage(), 'row 1')
&& str_contains($exception->getMessage(), '2 headers')
&& str_contains($exception->getMessage(), '1 fields');
}
if (!$mismatchRaised) {
throw new RuntimeException('Mismatched row lengths must be rejected with a useful error.');
}
printf("CSV exporter tests: 4 passed\n");
+3
View File
@@ -14,6 +14,9 @@ use App\Domain\Jobcard\TimeAggregator;
$reference = JobcardReference::generate(42, 2026); $reference = JobcardReference::generate(42, 2026);
if ($reference !== 'JC-2026-000042') throw new RuntimeException('Expected generated jobcard reference.'); if ($reference !== 'JC-2026-000042') throw new RuntimeException('Expected generated jobcard reference.');
if (!JobcardReference::isValid($reference) || JobcardReference::isValid('bad-reference')) throw new RuntimeException('Expected reference format validation.'); if (!JobcardReference::isValid($reference) || JobcardReference::isValid('bad-reference')) throw new RuntimeException('Expected reference format validation.');
$tooLargeRejected = false;
try { JobcardReference::generate(1000000, 2026); } catch (InvalidArgumentException $exception) { $tooLargeRejected = true; }
if (!$tooLargeRejected) throw new RuntimeException('Reference sequences above six digits must be rejected.');
$transitions = new StatusTransitionValidator(); $transitions = new StatusTransitionValidator();
if (!$transitions->canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.'); if (!$transitions->canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.');
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Jobcard/StatusTransitionValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
use App\Domain\Jobcard\JobcardWorkflow;
$workflow = new JobcardWorkflow();
if ($workflow->allowedInitialStatuses() !== ['new']) {
throw new RuntimeException('Expected new to be the only allowed initial status.');
}
if ($workflow->allowedPriorities() !== ['low', 'normal', 'high', 'critical']) {
throw new RuntimeException('Expected the four supported priorities.');
}
$valid = $workflow->validateCommand([
'client_id' => '42',
'work_requested' => ' Replace the failed pump ',
'priority' => 'high',
]);
if (!$valid['valid'] || $valid['errors'] !== [] || $valid['client_id'] !== 42 || $valid['work_requested'] !== 'Replace the failed pump') {
throw new RuntimeException('Expected a valid jobcard command to be normalized.');
}
$invalid = $workflow->validateCommand(['client_id' => 0, 'work_requested' => '', 'priority' => 'urgent']);
if ($invalid['valid'] || !isset($invalid['errors']['client_id'], $invalid['errors']['work_requested'], $invalid['errors']['priority'])) {
throw new RuntimeException('Expected invalid jobcard command fields to be reported.');
}
$transition = $workflow->validateTransition('in_progress', 'completed', '2026-09-01 12:00:00');
if (!$transition['valid'] || $transition['errors'] !== []) {
throw new RuntimeException('Expected completion transition with a timestamp to be valid.');
}
$closed = $workflow->validateTransition('completed', 'closed', '2026-09-01 12:00:00', '2026-09-01 13:00:00');
if (!$closed['valid'] || $closed['errors'] !== []) {
throw new RuntimeException('Expected closure transition with ordered timestamps to be valid.');
}
$invalidTransition = $workflow->validateTransition('new', 'completed');
if ($invalidTransition['valid'] || !isset($invalidTransition['errors']['transition'], $invalidTransition['errors']['completed_at'])) {
throw new RuntimeException('Expected invalid transition and missing completion timestamp errors.');
}
$invalidDates = $workflow->validateTransition('completed', 'closed', '2026-09-01 14:00:00', '2026-09-01 13:00:00');
if ($invalidDates['valid'] || !isset($invalidDates['errors']['timestamps'])) {
throw new RuntimeException('Expected closure before completion to be rejected.');
}
printf("Jobcard workflow tests: 7 passed\n");