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:
@@ -1551,7 +1551,7 @@ JOIN permissions p ON p.`key` IN (
|
|||||||
'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge',
|
'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge',
|
||||||
'stats.view', 'system_info.view',
|
'stats.view', 'system_info.view',
|
||||||
'roles.assign_all',
|
'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
|
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
|
||||||
ON DUPLICATE KEY UPDATE role_id = role_id;
|
ON DUPLICATE KEY UPDATE role_id = role_id;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -284,5 +284,15 @@
|
|||||||
"Advanced options": "Erweiterte Optionen",
|
"Advanced options": "Erweiterte Optionen",
|
||||||
"Min. overdue": "Mind. überfällig",
|
"Min. overdue": "Mind. überfällig",
|
||||||
"Min. age": "Mindestalter",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,5 +284,15 @@
|
|||||||
"Advanced options": "Advanced options",
|
"Advanced options": "Advanced options",
|
||||||
"Min. overdue": "Min. overdue",
|
"Min. overdue": "Min. overdue",
|
||||||
"Min. age": "Min. age",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
|||||||
{
|
{
|
||||||
public const ABILITY_ACCESS = 'helpdesk.access';
|
public const ABILITY_ACCESS = 'helpdesk.access';
|
||||||
public const ABILITY_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
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_ACCESS = 'helpdesk.access';
|
||||||
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||||
|
public const PERMISSION_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PermissionService $permissionService
|
private readonly PermissionService $permissionService
|
||||||
@@ -21,7 +23,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
|||||||
|
|
||||||
public function supports(string $ability): bool
|
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
|
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||||
@@ -34,6 +36,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
|||||||
$permissionKey = match ($ability) {
|
$permissionKey = match ($ability) {
|
||||||
self::ABILITY_ACCESS => self::PERMISSION_ACCESS,
|
self::ABILITY_ACCESS => self::PERMISSION_ACCESS,
|
||||||
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
|
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
|
||||||
|
self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$authorizationService = $container->get(AuthorizationService::class);
|
$authorizationService = $container->get(AuthorizationService::class);
|
||||||
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', [
|
$actorContext = ['actor_user_id' => $userId];
|
||||||
'actor_user_id' => $userId,
|
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', $actorContext)->isAllowed();
|
||||||
])->isAllowed();
|
$canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$canManageSettings = false;
|
$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);
|
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.
|
* Get contracts for a customer by customer number.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ final class DebitorCacheControl
|
|||||||
return 'module.helpdesk.debitor.v1.escalation-definitions.' . $tenantScope;
|
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>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1575,4 +1575,124 @@ class DebitorDetailService
|
|||||||
|
|
||||||
return $indicators;
|
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 !== '')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ return [
|
|||||||
['path' => 'helpdesk/debitor-communication-data', 'target' => 'helpdesk/debitor-communication-data'],
|
['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-sales-dashboard-data', 'target' => 'helpdesk/debitor-sales-dashboard-data'],
|
||||||
['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-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' => [],
|
'public_paths' => [],
|
||||||
|
|
||||||
@@ -71,6 +73,12 @@ return [
|
|||||||
'active' => true,
|
'active' => true,
|
||||||
'is_system' => true,
|
'is_system' => true,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'helpdesk.team-workload.view',
|
||||||
|
'description' => 'View helpdesk team workload dashboard',
|
||||||
|
'active' => true,
|
||||||
|
'is_system' => true,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'search_resources' => [],
|
'search_resources' => [],
|
||||||
|
|||||||
84
modules/helpdesk/pages/helpdesk/team-workload-data().php
Normal file
84
modules/helpdesk/pages/helpdesk/team-workload-data().php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
|
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||||
|
use MintyPHP\Router;
|
||||||
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
|
Guard::requireLogin();
|
||||||
|
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_TEAM_WORKLOAD);
|
||||||
|
|
||||||
|
$request = requestInput();
|
||||||
|
if ($request->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,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
16
modules/helpdesk/pages/helpdesk/team/index().php
Normal file
16
modules/helpdesk/pages/helpdesk/team/index().php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use MintyPHP\Buffer;
|
||||||
|
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||||
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
|
Guard::requireLogin();
|
||||||
|
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_TEAM_WORKLOAD);
|
||||||
|
|
||||||
|
Buffer::set('title', t('Team workload'));
|
||||||
|
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||||
|
$breadcrumbs = [
|
||||||
|
['label' => t('Home'), 'path' => 'admin'],
|
||||||
|
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
|
||||||
|
['label' => t('Team')],
|
||||||
|
];
|
||||||
115
modules/helpdesk/pages/helpdesk/team/index(default).phtml
Normal file
115
modules/helpdesk/pages/helpdesk/team/index(default).phtml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team workload dashboard — shows support agent metrics aggregated from BC tickets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$periods = [30, 90, 180, 365];
|
||||||
|
$periodLabels = [
|
||||||
|
30 => t('30 days'),
|
||||||
|
90 => t('90 days'),
|
||||||
|
180 => t('6 months'),
|
||||||
|
365 => t('1 year'),
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<div class="app-details-container"
|
||||||
|
data-team-workload-url="<?php e(lurl('helpdesk/team-workload-data')); ?>"
|
||||||
|
data-label-not-assigned="<?php e(t('Not assigned')); ?>"
|
||||||
|
data-label-error="<?php e(t('Could not load team dashboard.')); ?>"
|
||||||
|
data-label-empty="<?php e(t('No team data available.')); ?>"
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<?php
|
||||||
|
$titlebar = [
|
||||||
|
'title' => 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');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="helpdesk-team-period-selector">
|
||||||
|
<?php foreach ($periods as $p): ?>
|
||||||
|
<button type="button"
|
||||||
|
class="helpdesk-team-period-button<?php if ($p === 90): ?> active<?php endif; ?>"
|
||||||
|
data-period="<?php e($p); ?>"
|
||||||
|
aria-pressed="<?php e($p === 90 ? 'true' : 'false'); ?>"
|
||||||
|
><?php e($periodLabels[$p]); ?></button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="team-loading" aria-busy="true">
|
||||||
|
<div class="helpdesk-support-metrics">
|
||||||
|
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||||
|
<div class="helpdesk-skeleton-kpi">
|
||||||
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||||
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||||
|
</div>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||||
|
<div class="helpdesk-skeleton-row">
|
||||||
|
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
|
||||||
|
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||||
|
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||||
|
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||||
|
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||||
|
</div>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="team-error" class="notice" data-variant="error" role="alert" hidden>
|
||||||
|
<p><?php e(t('Could not load team dashboard.')); ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="team-empty" class="notice" data-variant="info" role="status" hidden>
|
||||||
|
<p><?php e(t('No team data available.')); ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="team-content" hidden>
|
||||||
|
<div class="helpdesk-support-metrics" id="team-kpis">
|
||||||
|
<article class="helpdesk-support-metric">
|
||||||
|
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
|
||||||
|
<p class="helpdesk-support-metric-value" id="team-kpi-open">—</p>
|
||||||
|
</article>
|
||||||
|
<article class="helpdesk-support-metric">
|
||||||
|
<p class="helpdesk-support-metric-label"><?php e(t('Unassigned')); ?></p>
|
||||||
|
<p class="helpdesk-support-metric-value" id="team-kpi-unassigned">—</p>
|
||||||
|
</article>
|
||||||
|
<article class="helpdesk-support-metric">
|
||||||
|
<p class="helpdesk-support-metric-label"><?php e(t('Avg. age (h)')); ?></p>
|
||||||
|
<p class="helpdesk-support-metric-value" id="team-kpi-avg-age">—</p>
|
||||||
|
</article>
|
||||||
|
<article class="helpdesk-support-metric">
|
||||||
|
<p class="helpdesk-support-metric-label"><?php e(t('Resolved in period')); ?></p>
|
||||||
|
<p class="helpdesk-support-metric-value" id="team-kpi-resolved">—</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="helpdesk-team-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col"><?php e(t('Support agent')); ?></th>
|
||||||
|
<th scope="col"><?php e(t('Open')); ?></th>
|
||||||
|
<th scope="col"><?php e(t('Critical (>48h open)')); ?></th>
|
||||||
|
<th scope="col"><?php e(t('Avg. age (h)')); ?></th>
|
||||||
|
<th scope="col"><?php e(t('Resolved in period')); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="team-table-body">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-team.js')); ?>"></script>
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||||
$helpdeskNav = is_array($layoutNav['helpdesk.nav'] ?? null) ? $layoutNav['helpdesk.nav'] : [];
|
$helpdeskNav = is_array($layoutNav['helpdesk.nav'] ?? null) ? $layoutNav['helpdesk.nav'] : [];
|
||||||
$canManageSettings = !empty($helpdeskNav['can_manage_settings']);
|
$canManageSettings = !empty($helpdeskNav['can_manage_settings']);
|
||||||
|
$canViewTeam = !empty($helpdeskNav['can_view_team']);
|
||||||
|
|
||||||
$settingsActive = navActive('helpdesk/settings', true);
|
$settingsActive = navActive('helpdesk/settings', true);
|
||||||
|
$teamActive = navActive('helpdesk/team', true);
|
||||||
$customersActive = navActive('helpdesk', true);
|
$customersActive = navActive('helpdesk', true);
|
||||||
if ($settingsActive['isActive']) {
|
if ($settingsActive['isActive'] || $teamActive['isActive']) {
|
||||||
$customersActive = ['class' => '', 'aria' => '', 'isActive' => false];
|
$customersActive = ['class' => '', 'aria' => '', 'isActive' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +19,12 @@ $helpdeskNavItems = [
|
|||||||
'active' => $customersActive,
|
'active' => $customersActive,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => t('Team'),
|
||||||
|
'path' => 'helpdesk/team',
|
||||||
|
'active' => $teamActive,
|
||||||
|
'visible' => $canViewTeam,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => t('Settings'),
|
'label' => t('Settings'),
|
||||||
'path' => 'helpdesk/settings',
|
'path' => 'helpdesk/settings',
|
||||||
|
|||||||
@@ -1123,4 +1123,126 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Contract metrics ---
|
// --- 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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
/* Clickable rows — shared between search results and ticket list */
|
||||||
.app-clickable-row {
|
.app-clickable-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
120
modules/helpdesk/web/js/helpdesk-team.js
Normal file
120
modules/helpdesk/web/js/helpdesk-team.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -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:first-child,
|
||||||
.gridjs-pagination .gridjs-pages button:last-child {
|
.gridjs-pagination .gridjs-pages button:last-child {
|
||||||
|
|||||||
Reference in New Issue
Block a user