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

@@ -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;

View 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();
}