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

@@ -1123,4 +1123,126 @@ class DebitorDetailServiceTest extends TestCase
}
// --- Contract metrics ---
// --- buildTeamWorkloadDashboard() ---
/**
* @return array<string, mixed>
*/
private static function teamTicket(string $no, string $supportUser, int $createdHoursAgo, bool $closed = false): array
{
$now = time();
$createdTs = $now - ($createdHoursAgo * 3600);
$activityTs = $createdTs + (($closed ? 2 : 0) * 3600);
return [
'No' => $no,
'Customer_No' => '10610',
'Ticket_State' => $closed ? 'Erledigt' : 'Offen',
'Process_Stage' => $closed ? 40 : 10,
'Created_On' => gmdate('Y-m-d\TH:i:s\Z', $createdTs),
'Last_Activity_Date' => gmdate('Y-m-d\TH:i:s\Z', $activityTs),
'Escalation_Code' => '',
'Support_User_Name' => $supportUser,
'Category_1_Code' => '',
'Process_Code' => '',
];
}
public function testBuildTeamWorkloadGroupsByUser(): void
{
$tickets = [
self::teamTicket('T1', 'Max Müller', 10),
self::teamTicket('T2', 'Max Müller', 20),
self::teamTicket('T3', 'Anna Schmidt', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$this->assertSame(3, $result['kpis']['open_tickets']);
$this->assertSame(0, $result['kpis']['unassigned']);
$this->assertCount(2, $result['members']);
$this->assertSame(2, $result['meta']['unique_users']);
$max = $result['members'][0];
$this->assertSame('max müller', $max['support_user']);
$this->assertSame('Max Müller', $max['display_name']);
$this->assertSame(2, $max['open_tickets']);
$anna = $result['members'][1];
$this->assertSame(1, $anna['open_tickets']);
}
public function testBuildTeamWorkloadEmptyTickets(): void
{
$result = DebitorDetailService::buildTeamWorkloadDashboard([], 30);
$this->assertSame(0, $result['kpis']['open_tickets']);
$this->assertSame(0, $result['kpis']['unassigned']);
$this->assertSame(0, $result['kpis']['avg_age_hours']);
$this->assertSame(0, $result['kpis']['resolved_in_period']);
$this->assertSame([], $result['members']);
$this->assertSame(0, $result['meta']['unique_users']);
}
public function testBuildTeamWorkloadUnassignedAlwaysLast(): void
{
$tickets = [
self::teamTicket('T1', '', 10),
self::teamTicket('T2', '', 20),
self::teamTicket('T3', 'Anna Schmidt', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$this->assertSame(2, $result['kpis']['unassigned']);
$last = end($result['members']);
$this->assertSame('', $last['support_user']);
$this->assertSame(2, $last['open_tickets']);
}
public function testBuildTeamWorkloadResolvedInPeriod(): void
{
$tickets = [
self::teamTicket('T1', 'Max', 10, true),
self::teamTicket('T2', 'Max', 800, true),
self::teamTicket('T3', 'Max', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 30);
$this->assertSame(1, $result['kpis']['open_tickets']);
$this->assertSame(1, $result['kpis']['resolved_in_period']);
$max = $result['members'][0];
$this->assertSame(1, $max['open_tickets']);
$this->assertSame(1, $max['resolved_in_period']);
}
public function testBuildTeamWorkloadCriticalTickets(): void
{
$tickets = [
self::teamTicket('T1', 'Max', 72),
self::teamTicket('T2', 'Max', 10),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$max = $result['members'][0];
$this->assertSame(1, $max['critical_tickets']);
}
public function testBuildTeamWorkloadCaseInsensitiveGrouping(): void
{
$tickets = [
self::teamTicket('T1', 'Max Müller', 10),
self::teamTicket('T2', 'max müller', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$this->assertCount(1, $result['members']);
$this->assertSame(2, $result['members'][0]['open_tickets']);
$this->assertSame('Max Müller', $result['members'][0]['display_name']);
}
}