From cae66e53613dd5c9df9fbc06bfc199b2b992a1ec Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 5 Apr 2026 18:46:54 +0200 Subject: [PATCH] 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) --- db/init/init.sql | 2 +- ...4-05-helpdesk-team-workload-permission.sql | 9 ++ modules/helpdesk/i18n/default_de.json | 12 +- modules/helpdesk/i18n/default_en.json | 12 +- .../Helpdesk/HelpdeskAuthorizationPolicy.php | 5 +- .../Providers/HelpdeskLayoutProvider.php | 12 +- .../Helpdesk/Service/BcODataGateway.php | 23 ++++ .../Helpdesk/Service/DebitorCacheControl.php | 5 + .../Helpdesk/Service/DebitorDetailService.php | 120 +++++++++++++++++ modules/helpdesk/module.php | 8 ++ .../pages/helpdesk/team-workload-data().php | 84 ++++++++++++ .../helpdesk/pages/helpdesk/team/index().php | 16 +++ .../pages/helpdesk/team/index(default).phtml | 115 +++++++++++++++++ .../templates/aside-helpdesk-panel.phtml | 10 +- .../Service/DebitorDetailServiceTest.php | 122 ++++++++++++++++++ modules/helpdesk/web/css/helpdesk.css | 44 +++++++ modules/helpdesk/web/js/helpdesk-team.js | 120 +++++++++++++++++ web/css/vendor-overrides/gridjs.css | 4 +- 18 files changed, 711 insertions(+), 12 deletions(-) create mode 100644 db/updates/2026-04-05-helpdesk-team-workload-permission.sql create mode 100644 modules/helpdesk/pages/helpdesk/team-workload-data().php create mode 100644 modules/helpdesk/pages/helpdesk/team/index().php create mode 100644 modules/helpdesk/pages/helpdesk/team/index(default).phtml create mode 100644 modules/helpdesk/web/js/helpdesk-team.js diff --git a/db/init/init.sql b/db/init/init.sql index 251d89f..81dc7c5 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1551,7 +1551,7 @@ JOIN permissions p ON p.`key` IN ( 'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge', 'stats.view', 'system_info.view', 'roles.assign_all', - 'helpdesk.access', 'helpdesk.settings.manage' + 'helpdesk.access', 'helpdesk.settings.manage', 'helpdesk.team-workload.view' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/db/updates/2026-04-05-helpdesk-team-workload-permission.sql b/db/updates/2026-04-05-helpdesk-team-workload-permission.sql new file mode 100644 index 0000000..7bfcb79 --- /dev/null +++ b/db/updates/2026-04-05-helpdesk-team-workload-permission.sql @@ -0,0 +1,9 @@ +-- Helpdesk module: assign team workload dashboard permission to Admin role. +-- Idempotent: safe to run multiple times. + +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` = 'helpdesk.team-workload.view' +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 14bf440..0954e39 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -284,5 +284,15 @@ "Advanced options": "Erweiterte Optionen", "Min. overdue": "Mind. überfällig", "Min. age": "Mindestalter", - "Min. open": "Mind. offen" + "Min. open": "Mind. offen", + "Team": "Team", + "Team workload": "Team-Auslastung", + "Support agent": "Support-Mitarbeiter", + "Open": "Offen", + "Critical (>48h open)": "Kritisch (>48h offen)", + "Avg. age (h)": "Ø Alter (h)", + "Resolved in period": "Gelöst im Zeitraum", + "Not assigned": "Nicht zugewiesen", + "Could not load team dashboard.": "Team-Dashboard konnte nicht geladen werden.", + "No team data available.": "Keine Team-Daten verfügbar." } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 0d29e20..1ef6c84 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -284,5 +284,15 @@ "Advanced options": "Advanced options", "Min. overdue": "Min. overdue", "Min. age": "Min. age", - "Min. open": "Min. open" + "Min. open": "Min. open", + "Team": "Team", + "Team workload": "Team workload", + "Support agent": "Support agent", + "Open": "Open", + "Critical (>48h open)": "Critical (>48h open)", + "Avg. age (h)": "Avg. age (h)", + "Resolved in period": "Resolved in period", + "Not assigned": "Not assigned", + "Could not load team dashboard.": "Could not load team dashboard.", + "No team data available.": "No team data available." } diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php index a5dc45b..e84d600 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php @@ -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, }; diff --git a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php index e23c46b..a3637b7 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php @@ -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, + ]]; } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index d8085c0..0448976 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -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> + */ + 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. * diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php index e0357ee..d5c3f8f 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php @@ -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 */ diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index a99a316..0acc4fa 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -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> $tickets Raw OData tickets + * @param int $periodDays One of 30, 90, 180, 365 + * + * @return array{kpis: array, members: array>, meta: array} + */ + public static function buildTeamWorkloadDashboard(array $tickets, int $periodDays): array + { + $nowTs = time(); + $periodStart = $nowTs - ($periodDays * 86400); + $criticalThresholdHours = 48; + + /** @var array $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 !== '')), + ], + ]; + } } diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 2370dbf..6e2f975 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -31,6 +31,8 @@ return [ ['path' => 'helpdesk/debitor-communication-data', 'target' => 'helpdesk/debitor-communication-data'], ['path' => 'helpdesk/debitor-sales-dashboard-data', 'target' => 'helpdesk/debitor-sales-dashboard-data'], ['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-dashboard-data'], + ['path' => 'helpdesk/team', 'target' => 'helpdesk/team'], + ['path' => 'helpdesk/team-workload-data', 'target' => 'helpdesk/team-workload-data'], ], 'public_paths' => [], @@ -71,6 +73,12 @@ return [ 'active' => true, 'is_system' => true, ], + [ + 'key' => 'helpdesk.team-workload.view', + 'description' => 'View helpdesk team workload dashboard', + 'active' => true, + 'is_system' => true, + ], ], 'search_resources' => [], diff --git a/modules/helpdesk/pages/helpdesk/team-workload-data().php b/modules/helpdesk/pages/helpdesk/team-workload-data().php new file mode 100644 index 0000000..6b57c89 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/team-workload-data().php @@ -0,0 +1,84 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +$periodDays = (int) $request->query('periodDays', '90'); +$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', '')); + +$allowedPeriods = [30, 90, 180, 365]; +if (!in_array($periodDays, $allowedPeriods, true)) { + $periodDays = 90; +} + +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantScope = DebitorCacheControl::resolveTenantScope($session); + +$cacheKey = DebitorCacheControl::teamWorkloadKey($tenantScope); +$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS; +$cached = $sessionStore->get($cacheKey); +$tickets = null; +$cacheUsed = false; + +if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + $tickets = is_array($cached['tickets'] ?? null) ? $cached['tickets'] : []; + $cacheUsed = true; +} + +if ($tickets === null) { + if ($refreshRequested) { + $sessionStore->remove($cacheKey); + } + + $gateway = app(BcODataGateway::class); + + try { + $tickets = $gateway->getTicketsForTeam(); + } catch (\Throwable $e) { + Router::json([ + 'ok' => false, + 'error' => 'Failed to load team workload data: ' . $e->getMessage(), + ]); + + return; + } + + if (!is_array($tickets)) { + $tickets = []; + } + + $sessionStore->set($cacheKey, [ + 'tickets' => $tickets, + 'fetched_at' => time(), + ]); +} + +$dashboard = DebitorDetailService::buildTeamWorkloadDashboard($tickets, $periodDays); + +Router::json([ + 'ok' => true, + 'kpis' => $dashboard['kpis'], + 'members' => $dashboard['members'], + 'meta' => array_merge($dashboard['meta'], [ + 'cache_used' => $cacheUsed, + 'cache_bypassed' => $refreshRequested, + 'cache_refreshed' => $refreshRequested, + ]), +]); diff --git a/modules/helpdesk/pages/helpdesk/team/index().php b/modules/helpdesk/pages/helpdesk/team/index().php new file mode 100644 index 0000000..c368a97 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/team/index().php @@ -0,0 +1,16 @@ + t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], + ['label' => t('Team')], +]; diff --git a/modules/helpdesk/pages/helpdesk/team/index(default).phtml b/modules/helpdesk/pages/helpdesk/team/index(default).phtml new file mode 100644 index 0000000..454017b --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/team/index(default).phtml @@ -0,0 +1,115 @@ + t('30 days'), + 90 => t('90 days'), + 180 => t('6 months'), + 365 => t('1 year'), +]; +?> +
+
+ t('Team workload'), + 'backHref' => lurl('helpdesk'), + 'backTitle' => t('Back'), + 'actions' => [ + [ + 'label' => t('Refresh data'), + 'type' => 'button', + 'name' => 'team-refresh', + 'class' => 'secondary outline', + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + require templatePath('partials/app-flash.phtml'); + ?> + +
+ + + +
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ + + + + + +
+
+ + diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml index 45c9f99..34e8e35 100644 --- a/modules/helpdesk/templates/aside-helpdesk-panel.phtml +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -3,10 +3,12 @@ $layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; $helpdeskNav = is_array($layoutNav['helpdesk.nav'] ?? null) ? $layoutNav['helpdesk.nav'] : []; $canManageSettings = !empty($helpdeskNav['can_manage_settings']); +$canViewTeam = !empty($helpdeskNav['can_view_team']); $settingsActive = navActive('helpdesk/settings', true); +$teamActive = navActive('helpdesk/team', true); $customersActive = navActive('helpdesk', true); -if ($settingsActive['isActive']) { +if ($settingsActive['isActive'] || $teamActive['isActive']) { $customersActive = ['class' => '', 'aria' => '', 'isActive' => false]; } @@ -17,6 +19,12 @@ $helpdeskNavItems = [ 'active' => $customersActive, 'visible' => true, ], + [ + 'label' => t('Team'), + 'path' => 'helpdesk/team', + 'active' => $teamActive, + 'visible' => $canViewTeam, + ], [ 'label' => t('Settings'), 'path' => 'helpdesk/settings', diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php index 5b3c00d..4d4319a 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php @@ -1123,4 +1123,126 @@ class DebitorDetailServiceTest extends TestCase } // --- Contract metrics --- + + // --- buildTeamWorkloadDashboard() --- + + /** + * @return array + */ + 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']); + } } diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index f4eda07..f820a67 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -126,6 +126,50 @@ } } + /* Team workload dashboard */ + .helpdesk-team-period-selector { + display: inline-flex; + gap: calc(var(--app-spacing) * 0.25); + margin-bottom: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-team-period-button { + padding: calc(var(--app-spacing) * 0.25) calc(var(--app-spacing) * 0.6); + font-size: var(--text-sm, 0.875rem); + border: var(--app-border-width) solid var(--app-muted-border-color); + background: var(--app-background-color); + color: var(--app-color); + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + } + + .helpdesk-team-period-button.active { + background: var(--app-primary); + border-color: var(--app-primary); + color: var(--app-primary-inverse); + } + + .helpdesk-team-table { + width: 100%; + margin-top: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-team-table th, + .helpdesk-team-table td { + text-align: left; + padding: calc(var(--app-spacing) * 0.35) calc(var(--app-spacing) * 0.5); + } + + .helpdesk-team-table th:not(:first-child), + .helpdesk-team-table td:not(:first-child) { + text-align: right; + } + + .helpdesk-team-row-unassigned { + opacity: 0.7; + font-style: italic; + } + /* Clickable rows — shared between search results and ticket list */ .app-clickable-row { cursor: pointer; diff --git a/modules/helpdesk/web/js/helpdesk-team.js b/modules/helpdesk/web/js/helpdesk-team.js new file mode 100644 index 0000000..5680857 --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-team.js @@ -0,0 +1,120 @@ +/** + * Team workload dashboard — fetches and renders team metrics. + */ + +const container = document.querySelector('.app-details-container[data-team-workload-url]'); +if (container) { + const dataUrl = container.dataset.teamWorkloadUrl; + const labelNotAssigned = container.dataset.labelNotAssigned || '—'; + + const elLoading = document.getElementById('team-loading'); + const elError = document.getElementById('team-error'); + const elEmpty = document.getElementById('team-empty'); + const elContent = document.getElementById('team-content'); + const elBody = document.getElementById('team-table-body'); + const elRefresh = container.querySelector('button[name="team-refresh"]'); + const periodButtons = container.querySelectorAll('.helpdesk-team-period-button'); + + let currentPeriod = 90; + + function showState(state) { + if (elLoading) elLoading.hidden = state !== 'loading'; + if (elError) elError.hidden = state !== 'error'; + if (elEmpty) elEmpty.hidden = state !== 'empty'; + if (elContent) elContent.hidden = state !== 'success'; + if (elLoading) elLoading.setAttribute('aria-busy', state === 'loading' ? 'true' : 'false'); + } + + function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; + } + + function renderKpis(kpis) { + setText('team-kpi-open', kpis.open_tickets ?? 0); + setText('team-kpi-unassigned', kpis.unassigned ?? 0); + setText('team-kpi-avg-age', kpis.avg_age_hours ?? 0); + setText('team-kpi-resolved', kpis.resolved_in_period ?? 0); + } + + function createCell(text) { + const td = document.createElement('td'); + td.textContent = text; + return td; + } + + function renderTable(members) { + if (!elBody) return; + elBody.replaceChildren(); + + for (const m of members) { + const tr = document.createElement('tr'); + const isUnassigned = m.support_user === ''; + const name = isUnassigned ? labelNotAssigned : m.display_name; + + const nameCell = document.createElement('td'); + nameCell.textContent = name; + + tr.appendChild(nameCell); + tr.appendChild(createCell(String(m.open_tickets))); + tr.appendChild(createCell(String(m.critical_tickets))); + tr.appendChild(createCell(String(m.avg_age_hours))); + tr.appendChild(createCell(String(m.resolved_in_period))); + + if (isUnassigned) { + tr.classList.add('helpdesk-team-row-unassigned'); + } + + elBody.appendChild(tr); + } + } + + async function fetchData(refresh = false) { + showState('loading'); + + const params = new URLSearchParams({ periodDays: String(currentPeriod) }); + if (refresh) params.set('refresh', '1'); + + try { + const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' }); + const json = await res.json(); + + if (!json.ok) { + showState('error'); + return; + } + + if (!json.members || json.members.length === 0) { + showState('empty'); + return; + } + + renderKpis(json.kpis); + renderTable(json.members); + showState('success'); + } catch { + showState('error'); + } + } + + for (const button of periodButtons) { + button.addEventListener('click', () => { + const period = parseInt(button.dataset.period, 10); + if (period === currentPeriod) return; + currentPeriod = period; + + for (const b of periodButtons) { + b.classList.toggle('active', b === button); + b.setAttribute('aria-pressed', b === button ? 'true' : 'false'); + } + + fetchData(); + }); + } + + if (elRefresh) { + elRefresh.addEventListener('click', () => fetchData(true)); + } + + fetchData(); +} diff --git a/web/css/vendor-overrides/gridjs.css b/web/css/vendor-overrides/gridjs.css index bfcf95f..4d31b2b 100644 --- a/web/css/vendor-overrides/gridjs.css +++ b/web/css/vendor-overrides/gridjs.css @@ -303,9 +303,7 @@ - .gridjs-pagination .gridjs-pages button:last-child { - border-right: none; - } + .gridjs-pagination .gridjs-pages button:first-child, .gridjs-pagination .gridjs-pages button:last-child {