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
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class ClientHistoryReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class ClientJobcardReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/** Framework-free HTML renderer intended for browser print/save-to-PDF. */
final class PrintReportRenderer
{
/** @param list<string> $headers @param list<list<mixed>> $rows */
public function render(string $title, array $headers, array $rows): string
{
$head = implode('', array_map(fn(mixed $value): string => '<th>' . $this->escape($value) . '</th>', $headers));
$body = '';
foreach ($rows as $row) {
$body .= '<tr>' . implode('', array_map(fn(mixed $value): string => '<td>' . $this->escape($value) . '</td>', $row)) . '</tr>';
}
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $this->escape($title) . '</title><style>body{font-family:Arial,sans-serif;color:#222;margin:2rem}h1{font-size:1.5rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:.5rem;text-align:left;vertical-align:top}th{background:#eee}@media print{body{margin:0}h1{font-size:1.2rem}table{font-size:10pt}tr{page-break-inside:avoid}}</style></head><body><main><h1>' . $this->escape($title) . '</h1><table><thead><tr>' . $head . '</tr></thead><tbody>' . $body . '</tbody></table></main></body></html>';
}
/** @param list<array<string,mixed>> $rows */
public function renderRecords(string $title, array $rows): string
{
$headers = $rows === [] ? [] : array_keys($rows[0]);
return $this->render($title, $headers, array_map(fn(array $row): array => array_values($row), $rows));
}
private function escape(mixed $value): string { return htmlspecialchars((string)($value ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
}
+26 -52
View File
@@ -1,64 +1,38 @@
<?php
declare(strict_types=1);
/**
* Maps raw domain rows into an explicitly allow-listed client view and a
* separately retained internal view. This class has no framework or storage
* dependency and is safe to use before an export format is selected.
*/
final class ReportDataMapper
{
/** @var list<string> */
private const CLIENT_FIELDS = [
'id',
'name',
'registration_number',
'status',
'support_email',
'support_phone',
'preferred_contact_method',
'physical_address',
'postal_address',
'general_notes',
'client_id',
'client_name',
'reference_no',
'priority',
'work_requested',
'completed_at',
'closed_at',
'allocated_hours',
'used_hours',
'remaining_hours',
'usage_percentage',
'status_label',
'period_type',
'start_date',
'end_date',
'hours',
'id','name','status','support_email','client_id','client_name','reference_no','priority','work_requested','created_at','completed_at','closed_at','changed_at','from_status','to_status','hours','sla_hours','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date',
];
private const INTERNAL_FIELDS = [
'id','client_id','client_name','reference_no','status','priority','work_requested','technician_id','technician_name','created_at','completed_at','closed_at','changed_at','from_status','to_status','changed_by','changed_by_name','work_date','hours','sla_hours','counts_toward_sla','sla_status','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date','internal_notes','technician_notes',
];
/** @return array<string, mixed> */
public function clientFacing(array $record): array
{
$safe = [];
foreach (self::CLIENT_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $record[$field];
}
}
return $safe;
}
/** @return array<string,mixed> */
public function clientFacing(array $record): array { return $this->allow($record, self::CLIENT_FIELDS); }
/** @return array<string,mixed> */
public function internal(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array{client:array<string,mixed>,internal:array<string,mixed>} */
public function map(array $record): array { return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)]; }
/** @return array<string,mixed> */
public function clientJobcard(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','client_name','reference_no','status','priority','work_requested','created_at','completed_at','closed_at'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalJobcard(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientHistory(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','reference_no','from_status','to_status','changed_at'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalHistory(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientActivity(array $record): array { return $this->clientFacing($record); }
/** @return array<string,mixed> */
public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string, mixed> */
public function internal(array $record): array
private function allow(array $record, array $fields): array
{
return array_diff_key($record, $this->clientFacing($record));
}
/** @return array{client: array<string, mixed>, internal: array<string, mixed>} */
public function map(array $record): array
{
return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)];
$result = [];
foreach ($fields as $field) if (array_key_exists($field, $record)) $result[$field] = $record[$field];
return $result;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/** Value object for the common report filter vocabulary. */
final class ReportFilters
{
public function __construct(
public readonly ?int $clientId = null,
public readonly ?string $dateFrom = null,
public readonly ?string $dateTo = null,
public readonly ?int $technicianId = null,
public readonly ?string $status = null,
public readonly ?string $priority = null,
public readonly ?string $sla = null,
) {
$this->validate();
}
public static function fromArray(array $input): self
{
return new self(
self::positiveInt($input['client_id'] ?? $input['clientId'] ?? null),
self::date($input['date_from'] ?? $input['dateFrom'] ?? null),
self::date($input['date_to'] ?? $input['dateTo'] ?? null),
self::positiveInt($input['technician_id'] ?? $input['technicianId'] ?? null),
self::text($input['status'] ?? null),
self::text($input['priority'] ?? null),
self::text($input['sla'] ?? $input['sla_status'] ?? null),
);
}
public function matches(array $row): bool
{
if ($this->clientId !== null && (int)($row['client_id'] ?? 0) !== $this->clientId) return false;
if ($this->technicianId !== null && (int)($row['technician_id'] ?? 0) !== $this->technicianId) return false;
if ($this->status !== null && (string)($row['status'] ?? $row['to_status'] ?? '') !== $this->status) return false;
if ($this->priority !== null && (string)($row['priority'] ?? '') !== $this->priority) return false;
if ($this->sla !== null && (string)($row['sla_status'] ?? $row['sla'] ?? '') !== $this->sla) return false;
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? '');
if ($this->dateFrom !== null && ($date === '' || substr($date, 0, 10) < $this->dateFrom)) return false;
if ($this->dateTo !== null && ($date === '' || substr($date, 0, 10) > $this->dateTo)) return false;
return true;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return ['client_id' => $this->clientId, 'date_from' => $this->dateFrom, 'date_to' => $this->dateTo, 'technician_id' => $this->technicianId, 'status' => $this->status, 'priority' => $this->priority, 'sla' => $this->sla];
}
private function validate(): void
{
if ($this->dateFrom !== null && $this->dateTo !== null && $this->dateFrom > $this->dateTo) throw new InvalidArgumentException('date_from must not be after date_to.');
}
private static function positiveInt(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return (int)$value;
return null;
}
private static function text(mixed $value): ?string { return is_scalar($value) && trim((string)$value) !== '' ? trim((string)$value) : null; }
private static function date(mixed $value): ?string
{
if (!is_scalar($value) || trim((string)$value) === '') return null;
$value = trim((string)$value);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = DateTimeImmutable::getLastErrors();
if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) || $date->format('Y-m-d') !== $value) throw new InvalidArgumentException('Report dates must use YYYY-MM-DD.');
return $value;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
/** Common contract for deterministic, storage-agnostic report queries. */
interface ReportQuery
{
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array;
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class TechnicianActivityReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'internal'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $totals = [];
foreach ($rows as $row) {
if (!$filters->matches($row)) continue;
$key = (string)(int)($row['technician_id'] ?? 0) . ':' . (string)(int)($row['client_id'] ?? 0);
if (!isset($totals[$key])) $totals[$key] = ['technician_id' => (int)($row['technician_id'] ?? 0), 'technician_name' => (string)($row['technician_name'] ?? ''), 'client_id' => (int)($row['client_id'] ?? 0), 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0];
$hours = max(0.0, (float)($row['hours'] ?? 0)); $totals[$key]['hours'] += $hours;
if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours;
}
$result = array_values($totals);
foreach ($result as &$item) { $item['hours'] = round($item['hours'], 2); $item['sla_hours'] = round($item['sla_hours'], 2); if ($audience === 'client') $item = $mapper->clientActivity($item); }
unset($item);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)($a['technician_id'] ?? 0) <=> (int)($b['technician_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'internal'): array { return $this->build($rows, $audience); }
}