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

@@ -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,
]),
]);

View 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')],
];

View 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>