feat: bootstrap JOBcard CRM foundation

This commit is contained in:
Marco0300
2026-09-01 18:54:47 +02:00
commit 480494c5ed
31 changed files with 1300 additions and 0 deletions
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportDataMapper.php';
final class HoursPerClientReport
{
/** @param list<array<string, mixed>> $entries
* @return list<array{client_id:int, client_name:string, hours:float}>
*/
public function aggregate(array $entries): array
{
$totals = [];
foreach ($entries as $entry) {
$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]['hours'] += max(0.0, (float)($entry['hours'] ?? 0));
}
$rows = array_values($totals);
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']);
return $rows;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?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',
];
/** @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 internal(array $record): 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)];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
final class SlaReport
{
/** @param list<array<string, mixed>> $agreements
* @return list<array<string, mixed>>
*/
public function rows(array $agreements): array
{
$rows = [];
foreach ($agreements as $agreement) {
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
$used = 0.0;
foreach ((array)($agreement['hours'] ?? []) as $hours) {
$used += max(0.0, (float)$hours);
}
$used = round($used, 2);
$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'));
$rows[] = [
'client_id' => (int)($agreement['client_id'] ?? 0),
'client_name' => (string)($agreement['client_name'] ?? ''),
'allocated_hours' => $allocated,
'used_hours' => $used,
'remaining_hours' => $remaining,
'usage_percentage' => $percentage,
'status' => $status,
];
}
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
return $rows;
}
}