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:
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>
|
||||
Reference in New Issue
Block a user