forked from fa/breadcrumb-the-shire
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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user