feat(helpdesk): clickable top customers/categories filter resolution times

Clicking a customer or category in the Historical tab recalculates
Ø/Min/Max resolution times for only that filter. Click again to reset.
All computation is client-side from resolved_details already in the
response — no extra API calls. Active filter highlighted with primary
color, resolution values turn primary when filtered.

Backend now includes resolved_ticket_details per agent (customer_name,
category, resolution_hours) for client-side filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 20:31:08 +02:00
parent fc98f2684a
commit 5c0364f687
3 changed files with 128 additions and 20 deletions

View File

@@ -1625,6 +1625,7 @@ class DebitorDetailService
'resolved_customers' => [],
'resolved_categories' => [],
'resolution_hours' => [],
'resolved_ticket_details' => [],
];
}
@@ -1642,9 +1643,17 @@ class DebitorDetailService
if ($category !== '') {
$agents[$normalizedUser]['resolved_categories'][$category] = ($agents[$normalizedUser]['resolved_categories'][$category] ?? 0) + 1;
}
$resolutionH = null;
if ($createdTs !== null && $activityTs > $createdTs) {
$agents[$normalizedUser]['resolution_hours'][] = intdiv($activityTs - $createdTs, 3600);
$resolutionH = intdiv($activityTs - $createdTs, 3600);
$agents[$normalizedUser]['resolution_hours'][] = $resolutionH;
}
$agents[$normalizedUser]['resolved_ticket_details'][] = [
'customer_name' => $customerName,
'category' => $category,
'resolution_hours' => $resolutionH,
];
}
} else {
$agents[$normalizedUser]['open_tickets']++;
@@ -1739,6 +1748,7 @@ class DebitorDetailService
'avg_resolution_hours' => $avgResolution,
'min_resolution_hours' => $minResolution,
'max_resolution_hours' => $maxResolution,
'resolved_details' => $agent['resolved_ticket_details'],
],
];
}

View File

@@ -446,6 +446,34 @@
font-size: var(--text-xs, 0.75rem);
}
/* Clickable ranked items */
.helpdesk-team-perf-ranked-clickable {
cursor: pointer;
border-radius: var(--app-border-radius, 0.25rem);
padding-inline: calc(var(--app-spacing) * 0.25);
margin-inline: calc(var(--app-spacing) * -0.25);
transition: background-color 0.15s ease;
}
.helpdesk-team-perf-ranked-clickable:hover {
background: color-mix(in srgb, var(--app-primary, #0d6efd) 8%, transparent);
}
.helpdesk-team-perf-ranked-clickable.active {
background: color-mix(in srgb, var(--app-primary, #0d6efd) 12%, transparent);
}
.helpdesk-team-perf-ranked-clickable.active .helpdesk-team-perf-ranked-name {
font-weight: 600;
color: var(--app-primary, #0d6efd);
}
/* Filtered state on resolution times */
.helpdesk-team-perf-stat-filtered .helpdesk-team-perf-ranked-count {
color: var(--app-primary, #0d6efd);
font-weight: 600;
}
/* Clickable rows — shared between search results and ticket list */
.app-clickable-row {
cursor: pointer;

View File

@@ -157,17 +157,51 @@ if (container) {
}
/**
* Build a small ranked list (top customers / top categories).
* Compute Ø/Min/Max from an array of hour values.
*/
function buildRankedList(items, label) {
function computeResolution(hours) {
const valid = hours.filter(h => h !== null && h !== undefined);
if (valid.length === 0) return { avg: null, min: null, max: null };
return {
avg: Math.round(valid.reduce((a, b) => a + b, 0) / valid.length),
min: Math.min(...valid),
max: Math.max(...valid),
};
}
/**
* Build a clickable ranked list (top customers / top categories).
* Clicking an item filters the resolution times and calls onFilter.
*/
function buildRankedList(items, label, filterKey, resolvedDetails, onFilter) {
if (!items || items.length === 0) return null;
const wrap = h('div', 'helpdesk-team-perf-stat');
wrap.appendChild(h('span', 'helpdesk-team-perf-stat-label', label));
const list = h('ol', 'helpdesk-team-perf-ranked');
let activeItem = null;
for (const item of items) {
const li = h('li');
const li = h('li', 'helpdesk-team-perf-ranked-clickable');
li.addEventListener('click', () => {
if (activeItem === li) {
activeItem.classList.remove('active');
activeItem = null;
onFilter(null);
} else {
if (activeItem) activeItem.classList.remove('active');
li.classList.add('active');
activeItem = li;
const filtered = resolvedDetails
.filter(d => d[filterKey] === item.name)
.map(d => d.resolution_hours);
onFilter(computeResolution(filtered));
}
});
li.appendChild(h('span', 'helpdesk-team-perf-ranked-name', item.name));
li.appendChild(h('span', 'helpdesk-team-perf-ranked-count', String(item.count)));
list.appendChild(li);
@@ -178,10 +212,12 @@ if (container) {
/**
* Build a performance widget for one agent — card with insights.
* Clicking a customer or category recalculates resolution times.
*/
function buildPerformanceWidget(m) {
const section = h('section', 'helpdesk-team-widget');
const perf = m.performance || {};
const resolvedDetails = perf.resolved_details || [];
// Header: avatar + name + resolved count
const title = h('h3', 'helpdesk-support-section-title');
@@ -195,31 +231,65 @@ if (container) {
// Stats grid
const grid = h('div', 'helpdesk-team-perf-grid');
// Resolution time as ranked list (same pattern as top customers/categories)
// Resolution time column — will be updated on filter
const timeStats = h('div', 'helpdesk-team-perf-stat');
timeStats.appendChild(h('span', 'helpdesk-team-perf-stat-label', container.dataset.labelResolutionTime || 'Resolution time'));
const timeList = h('ol', 'helpdesk-team-perf-ranked helpdesk-team-perf-ranked-plain');
const timeItems = [
['Ø', formatResolution(perf.avg_resolution_hours)],
['Min', formatResolution(perf.min_resolution_hours)],
['Max', formatResolution(perf.max_resolution_hours)],
];
for (const [label, value] of timeItems) {
const li = h('li');
li.appendChild(h('span', 'helpdesk-team-perf-ranked-name', label));
li.appendChild(h('span', 'helpdesk-team-perf-ranked-count', value));
timeList.appendChild(li);
}
const avgLi = h('li');
avgLi.appendChild(h('span', 'helpdesk-team-perf-ranked-name', 'Ø'));
const avgVal = h('span', 'helpdesk-team-perf-ranked-count', formatResolution(perf.avg_resolution_hours));
avgLi.appendChild(avgVal);
timeList.appendChild(avgLi);
const minLi = h('li');
minLi.appendChild(h('span', 'helpdesk-team-perf-ranked-name', 'Min'));
const minVal = h('span', 'helpdesk-team-perf-ranked-count', formatResolution(perf.min_resolution_hours));
minLi.appendChild(minVal);
timeList.appendChild(minLi);
const maxLi = h('li');
maxLi.appendChild(h('span', 'helpdesk-team-perf-ranked-name', 'Max'));
const maxVal = h('span', 'helpdesk-team-perf-ranked-count', formatResolution(perf.max_resolution_hours));
maxLi.appendChild(maxVal);
timeList.appendChild(maxLi);
timeStats.appendChild(timeList);
grid.appendChild(timeStats);
// Top customers
const customersEl = buildRankedList(perf.top_customers, container.dataset.labelTopCustomers || 'Top customers');
// Callback: update resolution times when a filter is applied or cleared
function updateResolution(filtered) {
if (filtered === null) {
avgVal.textContent = formatResolution(perf.avg_resolution_hours);
minVal.textContent = formatResolution(perf.min_resolution_hours);
maxVal.textContent = formatResolution(perf.max_resolution_hours);
timeStats.classList.remove('helpdesk-team-perf-stat-filtered');
} else {
avgVal.textContent = formatResolution(filtered.avg);
minVal.textContent = formatResolution(filtered.min);
maxVal.textContent = formatResolution(filtered.max);
timeStats.classList.add('helpdesk-team-perf-stat-filtered');
}
}
// Top customers (clickable)
const customersEl = buildRankedList(
perf.top_customers,
container.dataset.labelTopCustomers || 'Top customers',
'customer_name',
resolvedDetails,
updateResolution
);
if (customersEl) grid.appendChild(customersEl);
// Top categories
const categoriesEl = buildRankedList(perf.top_categories, container.dataset.labelTopCategories || 'Top categories');
// Top categories (clickable)
const categoriesEl = buildRankedList(
perf.top_categories,
container.dataset.labelTopCategories || 'Top categories',
'category',
resolvedDetails,
updateResolution
);
if (categoriesEl) grid.appendChild(categoriesEl);
section.appendChild(grid);