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:
@@ -10,9 +10,11 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY_ACCESS = 'helpdesk.access';
|
||||
public const ABILITY_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
public const ABILITY_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
|
||||
|
||||
public const PERMISSION_ACCESS = 'helpdesk.access';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
public const PERMISSION_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService
|
||||
@@ -21,7 +23,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE], true);
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD], true);
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
@@ -34,6 +36,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
$permissionKey = match ($ability) {
|
||||
self::ABILITY_ACCESS => self::PERMISSION_ACCESS,
|
||||
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
|
||||
self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD,
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -17,13 +17,17 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
|
||||
|
||||
try {
|
||||
$authorizationService = $container->get(AuthorizationService::class);
|
||||
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', [
|
||||
'actor_user_id' => $userId,
|
||||
])->isAllowed();
|
||||
$actorContext = ['actor_user_id' => $userId];
|
||||
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', $actorContext)->isAllowed();
|
||||
$canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed();
|
||||
} catch (\Throwable) {
|
||||
$canManageSettings = false;
|
||||
$canViewTeam = false;
|
||||
}
|
||||
|
||||
return ['helpdesk.nav' => ['can_manage_settings' => $canManageSettings]];
|
||||
return ['helpdesk.nav' => [
|
||||
'can_manage_settings' => $canManageSettings,
|
||||
'can_view_team' => $canViewTeam,
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +587,29 @@ class BcODataGateway
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ticket rows across all customers for team workload aggregation.
|
||||
*
|
||||
* Uses PBI_LV_Tickets without Customer_No filter to return a global snapshot.
|
||||
* Support_User_Name is used for per-agent grouping.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTicketsForTeam(): array
|
||||
{
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
||||
. '?$top=500'
|
||||
. '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name')
|
||||
. '&$orderby=' . rawurlencode('Created_On desc');
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contracts for a customer by customer number.
|
||||
*
|
||||
|
||||
@@ -82,6 +82,11 @@ final class DebitorCacheControl
|
||||
return 'module.helpdesk.debitor.v1.escalation-definitions.' . $tenantScope;
|
||||
}
|
||||
|
||||
public static function teamWorkloadKey(string $tenantScope): string
|
||||
{
|
||||
return 'module.helpdesk.team.v1.workload.' . $tenantScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
@@ -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 !== '')),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user