diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index d919d0f..c058d29 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -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'], ], ]; } diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 6d4b6de..e8ebd79 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -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; diff --git a/modules/helpdesk/web/js/helpdesk-team.js b/modules/helpdesk/web/js/helpdesk-team.js index 466c3f5..a9f3d24 100644 --- a/modules/helpdesk/web/js/helpdesk-team.js +++ b/modules/helpdesk/web/js/helpdesk-team.js @@ -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);