feat: complete jobcard management workflows and UI
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user