feat(helpdesk): separate queue from agents in team dashboard with wait time

Queue detection: any member with 'warteschlange' in support_user name
(or empty) is treated as queue, not as a regular agent.

Current tab: queue cards shown at top with a wait time summary
(Ø/Min/Max from open ticket ages). Agents listed below.

Historical tab: queue cards shown before agents, same performance
widget pattern.

No backend changes — purely client-side separation using existing data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 20:56:31 +02:00
parent 1c6bcd6ec3
commit 81dc642d34
5 changed files with 65 additions and 13 deletions

View File

@@ -301,5 +301,6 @@
"Current": "Aktuell",
"Performance": "Leistung",
"Historical": "Historisch",
"Queue": "Warteschlange"
"Queue": "Warteschlange",
"Wait time": "Wartezeit"
}

View File

@@ -301,5 +301,6 @@
"Current": "Current",
"Performance": "Performance",
"Historical": "Historical",
"Queue": "Queue"
"Queue": "Queue",
"Wait time": "Wait time"
}

View File

@@ -32,6 +32,7 @@ $periodLabels = [
data-label-resolution-time="<?php e(t('Resolution time')); ?>"
data-label-top-customers="<?php e(t('Top customers')); ?>"
data-label-top-categories="<?php e(t('Top categories')); ?>"
data-label-wait-time="<?php e(t('Wait time')); ?>"
>
<section>
<?php

View File

@@ -220,6 +220,15 @@
border-radius: 0;
}
/* Team workload — queue wait time summary */
.helpdesk-team-queue-wait {
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.75);
margin-bottom: calc(var(--app-spacing) * 0.5);
border: 1px dashed var(--app-muted-border-color);
border-radius: var(--app-radius, 0.375rem);
background: color-mix(in srgb, var(--app-muted-border-color) 10%, transparent);
}
/* Team workload — inline avatar for section titles */
.helpdesk-team-avatar-inline {
display: inline-flex;

View File

@@ -10,6 +10,7 @@ if (container) {
const labelQueue = container.dataset.labelQueue || 'Queue';
const labelTickets = container.dataset.labelTickets || 'Tickets';
const labelCritical = container.dataset.labelCritical || 'Critical (>48h open)';
const labelWaitTime = container.dataset.labelWaitTime || 'Wait time';
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
const debitorBaseUrl = container.dataset.debitorBaseUrl || '';
@@ -38,6 +39,13 @@ if (container) {
return e;
}
/**
* Detect queue entries — Support_User_Name contains 'warteschlange'.
*/
function isQueueMember(m) {
return m.support_user === '' || m.support_user.includes('warteschlange');
}
function initials(name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
@@ -298,14 +306,48 @@ if (container) {
return section;
}
/**
* Build a queue summary with wait time Ø/Min/Max from open ticket ages.
*/
function buildQueueWaitSummary(queueMembers) {
const allAges = queueMembers.flatMap(m => (m.open_ticket_details || []).map(t => t.age_hours));
if (allAges.length === 0) return null;
const stats = computeResolution(allAges);
const wrap = h('div', 'helpdesk-team-queue-wait');
wrap.appendChild(h('span', 'helpdesk-team-perf-stat-label', labelWaitTime));
const list = h('ol', 'helpdesk-team-perf-ranked helpdesk-team-perf-ranked-plain');
const items = [
['Ø', formatResolution(stats.avg)],
['Min', formatResolution(stats.min)],
['Max', formatResolution(stats.max)],
];
for (const [label, value] of items) {
const li = h('li');
li.appendChild(h('span', 'helpdesk-team-perf-ranked-name', label));
li.appendChild(h('span', 'helpdesk-team-perf-ranked-count', value));
list.appendChild(li);
}
wrap.appendChild(list);
return wrap;
}
function renderCurrentTab(members) {
if (!elCurrentCards) return;
elCurrentCards.replaceChildren();
const queue = members.find(m => m.support_user === '' && m.open_tickets > 0);
const agents = members.filter(m => m.support_user !== '' && (m.open_tickets > 0 || m.critical_tickets > 0));
const queueMembers = members.filter(m => isQueueMember(m) && m.open_tickets > 0);
const agents = members.filter(m => !isQueueMember(m) && (m.open_tickets > 0 || m.critical_tickets > 0));
if (queue) elCurrentCards.appendChild(buildAgentWidget(queue, true));
// Queue section(s) at top
for (const q of queueMembers) elCurrentCards.appendChild(buildAgentWidget(q, true));
// Wait time summary across all queues
const waitSummary = buildQueueWaitSummary(queueMembers);
if (waitSummary) elCurrentCards.appendChild(waitSummary);
// Agent cards
for (const m of agents) elCurrentCards.appendChild(buildAgentWidget(m, false));
// Legend
@@ -324,15 +366,13 @@ if (container) {
if (!elPerformanceCards) return;
elPerformanceCards.replaceChildren();
const sorted = [...members]
.filter(m => m.resolved_in_period > 0)
.sort((a, b) => {
if (a.support_user === '') return 1;
if (b.support_user === '') return -1;
return b.resolved_in_period - a.resolved_in_period;
});
const queueResolved = members.filter(m => isQueueMember(m) && m.resolved_in_period > 0);
const agentsResolved = members
.filter(m => !isQueueMember(m) && m.resolved_in_period > 0)
.sort((a, b) => b.resolved_in_period - a.resolved_in_period);
for (const m of sorted) elPerformanceCards.appendChild(buildPerformanceWidget(m));
for (const q of queueResolved) elPerformanceCards.appendChild(buildPerformanceWidget(q));
for (const m of agentsResolved) elPerformanceCards.appendChild(buildPerformanceWidget(m));
}
async function fetchData(refresh = false) {