1
0

feat(helpdesk): add team workload dashboard with per-agent metrics

New standalone page (helpdesk/team) showing support agent workload
aggregated from BC tickets. KPI bar (open, unassigned, avg age, resolved),
per-agent table sorted by open tickets, period selector (30/90/180/365d).
Global OData query via getTicketsForTeam() without customer filter.
New permission helpdesk.team-workload.view with admin role assignment.
Includes 6 PHPUnit tests for buildTeamWorkloadDashboard().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:46:54 +02:00
parent aeb9c9f9fb
commit cae66e5361
18 changed files with 711 additions and 12 deletions

View File

@@ -1575,4 +1575,124 @@ class DebitorDetailService
return $indicators;
}
/**
* Build a team workload dashboard from a global (non-customer-scoped) ticket set.
*
* Groups tickets by Support_User_Name and computes per-agent metrics.
* Unassigned tickets (empty Support_User_Name) are collected into a dedicated entry.
*
* @param array<int, array<string, mixed>> $tickets Raw OData tickets
* @param int $periodDays One of 30, 90, 180, 365
*
* @return array{kpis: array<string, mixed>, members: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
public static function buildTeamWorkloadDashboard(array $tickets, int $periodDays): array
{
$nowTs = time();
$periodStart = $nowTs - ($periodDays * 86400);
$criticalThresholdHours = 48;
/** @var array<string, array{display_name: string, support_user: string, open_tickets: int, critical_tickets: int, age_hours_sum: int, resolved_in_period: int}> $agents */
$agents = [];
$globalOpen = 0;
$globalUnassigned = 0;
$globalAgeSum = 0;
$globalOpenCount = 0;
$globalResolved = 0;
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
$isClosed = self::isClosedTicketState($state)
|| (int) ($ticket['Process_Stage'] ?? 0) === 40;
$rawUser = trim((string) ($ticket['Support_User_Name'] ?? ''));
$normalizedUser = $rawUser === '' ? '' : strtolower($rawUser);
$activityTs = self::resolveTicketActivityTimestamp($ticket);
if (!isset($agents[$normalizedUser])) {
$agents[$normalizedUser] = [
'display_name' => $rawUser,
'support_user' => $normalizedUser,
'open_tickets' => 0,
'critical_tickets' => 0,
'age_hours_sum' => 0,
'resolved_in_period' => 0,
];
}
if ($isClosed) {
if ($activityTs !== null && $activityTs >= $periodStart) {
$agents[$normalizedUser]['resolved_in_period']++;
$globalResolved++;
}
} else {
$agents[$normalizedUser]['open_tickets']++;
$globalOpen++;
if ($rawUser === '') {
$globalUnassigned++;
}
if ($activityTs !== null) {
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
$agents[$normalizedUser]['age_hours_sum'] += $ageHours;
if ($ageHours > $criticalThresholdHours) {
$agents[$normalizedUser]['critical_tickets']++;
}
$globalAgeSum += $ageHours;
$globalOpenCount++;
}
}
}
$members = [];
foreach ($agents as $key => $agent) {
$avgAge = $agent['open_tickets'] > 0
? (int) round($agent['age_hours_sum'] / $agent['open_tickets'])
: 0;
$members[] = [
'support_user' => $agent['support_user'],
'display_name' => $agent['display_name'],
'open_tickets' => $agent['open_tickets'],
'critical_tickets' => $agent['critical_tickets'],
'avg_age_hours' => $avgAge,
'resolved_in_period' => $agent['resolved_in_period'],
];
}
usort($members, static function (array $a, array $b): int {
if ($a['support_user'] === '') {
return 1;
}
if ($b['support_user'] === '') {
return -1;
}
return $b['open_tickets'] <=> $a['open_tickets'];
});
$globalAvgAge = $globalOpenCount > 0
? (int) round($globalAgeSum / $globalOpenCount)
: 0;
return [
'kpis' => [
'open_tickets' => $globalOpen,
'unassigned' => $globalUnassigned,
'avg_age_hours' => $globalAvgAge,
'resolved_in_period' => $globalResolved,
],
'members' => $members,
'meta' => [
'period_days' => $periodDays,
'total_tickets' => count($tickets),
'unique_users' => count(array_filter(array_keys($agents), static fn (string $k): bool => $k !== '')),
],
];
}
}