feat: complete jobcard management workflows and UI

This commit is contained in:
Marco0300
2026-09-01 21:47:35 +02:00
parent b983f90dcb
commit 9ce08bc6f8
27 changed files with 1081 additions and 115 deletions
@@ -29,12 +29,33 @@ final class ClientHistoryService
return $this->filter($rows, $criteria);
}
/** @return array<string,mixed> */
public function validateForClient(mixed $clientId, array $rows, array $criteria = []): array
{
$id = $this->positiveId($clientId);
if ($id === null) return ['valid' => false, 'client_id' => null, 'timeline' => [], 'errors' => ['client_id' => 'Client ID must be a positive integer.']];
return ['valid' => true, 'client_id' => $id, 'timeline' => $this->forClient($rows, $id, $criteria), 'errors' => []];
}
/** Controller-ready client history timeline command. */
public function timeline(array $rows, mixed $clientId, array $criteria = []): array
{
return $this->validateForClient($clientId, $rows, $criteria);
}
/** @return list<array<string,mixed>> */
public function history(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function query(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function getHistory(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\\d*$/', trim($value)) === 1) { $id = filter_var(trim($value), FILTER_VALIDATE_INT); return $id === false ? null : $id; }
return null;
}
}
}
+28 -13
View File
@@ -1,13 +1,32 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class HoursPerClientReport
/** Deterministic hours aggregation grouped by client. */
final class HoursPerClientReport implements ReportQuery
{
/** @param list<array<string, mixed>> $entries
* @return list<array{client_id:int, client_name:string, hours:float}>
*/
public function __construct(
private readonly ?ReportFilters $filters = null,
private readonly ?ReportDataMapper $mapper = null,
) {}
/** @param list<array<string,mixed>> $entries @return list<array<string,mixed>> */
public function build(array $entries, string $audience = ReportAudience::CLIENT): array
{
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters();
$selected = array_values(array_filter($entries, static fn (array $entry): bool => $filters->matches($entry)));
$rows = $this->aggregate($selected);
// This report contains no technician or internal-note fields, so the same
// stable projection is safe for both audiences.
return $rows;
}
/** @param list<array<string,mixed>> $entries @return list<array{client_id:int,client_name:string,hours:float}> */
public function aggregate(array $entries): array
{
$totals = [];
@@ -15,20 +34,16 @@ final class HoursPerClientReport
$id = (int)($entry['client_id'] ?? 0);
$key = (string)$id;
if (!isset($totals[$key])) {
$totals[$key] = [
'client_id' => $id,
'client_name' => (string)($entry['client_name'] ?? ''),
'hours' => 0.0,
];
$totals[$key] = ['client_id' => $id, 'client_name' => (string)($entry['client_name'] ?? ''), 'hours' => 0.0];
}
$totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0));
}
$rows = array_values($totals);
foreach ($rows as &$row) {
$row['hours'] = round($row['hours'], 2);
}
foreach ($rows as &$row) $row['hours'] = round($row['hours'], 2);
unset($row);
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: ($a['client_id'] <=> $b['client_id']));
return $rows;
}
public function query(array $entries, string $audience = ReportAudience::CLIENT): array { return $this->build($entries, $audience); }
}
+1 -1
View File
@@ -18,7 +18,7 @@ final class PrintReportRenderer
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>';
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="format" content="application/pdf"><title>' . $this->escape($title) . '</title><style>@page{size:auto;margin:1.5cm}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}thead{display:table-header-group}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 */
+7 -1
View File
@@ -20,9 +20,14 @@ final class SlaReport
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters();
$mapper = $this->mapper ?? new ReportDataMapper();
// SLA status is derived below; apply all source-field filters first and
// apply the SLA filter to the computed status after usage is calculated.
$sourceFilters = $filters->sla === null
? $filters
: new ReportFilters($filters->clientId, $filters->dateFrom, $filters->dateTo, $filters->technicianId, $filters->status, $filters->priority);
$rows = [];
foreach ($agreements as $agreement) {
if (!$filters->matches($agreement)) continue;
if (!$sourceFilters->matches($agreement)) continue;
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
$used = 0.0;
foreach ((array)($agreement['hours'] ?? []) as $hours) $used += max(0.0, (float)$hours);
@@ -30,6 +35,7 @@ final class SlaReport
$remaining = round(max(0.0, $allocated - $used), 2);
$percentage = $allocated > 0 ? round(($used / $allocated) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
if ($filters->sla !== null && $filters->sla !== $status) continue;
$record = [
'client_id' => (int)($agreement['client_id'] ?? 0),
'client_name' => (string)($agreement['client_name'] ?? ''),
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
/** Deterministic technician workload totals, with a client-safe projection. */
final class TechnicianWorkloadReport 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 = ReportAudience::INTERNAL): array
{
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters();
$totals = [];
foreach ($rows as $row) {
if (!$filters->matches($row)) continue;
$technicianId = (int)($row['technician_id'] ?? 0);
$clientId = (int)($row['client_id'] ?? 0);
$key = $audience === ReportAudience::CLIENT ? 'client:' . $clientId : 'technician:' . $technicianId;
if (!isset($totals[$key])) {
$totals[$key] = $audience === ReportAudience::CLIENT
? ['client_id' => $clientId, 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0]
: ['technician_id' => $technicianId, 'technician_name' => (string)($row['technician_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);
}
unset($item);
usort($result, static fn (array $a, array $b): int => array_key_exists('client_id', $a)
? (strcmp((string)($a['client_name'] ?? ''), (string)($b['client_name'] ?? '')) ?: ((int)$a['client_id'] <=> (int)$b['client_id']))
: (strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)$a['technician_id'] <=> (int)$b['technician_id'])));
return $result;
}
public function query(array $rows, string $audience = ReportAudience::INTERNAL): array { return $this->build($rows, $audience); }
}