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:
@@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user