diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 7ff2d72..83e51e4 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -296,6 +296,8 @@ "Not assigned": "Nicht zugewiesen", "Could not load team dashboard.": "Team-Dashboard konnte nicht geladen werden.", "No team data available.": "Keine Team-Daten verfügbar.", + "Top customers": "Top-Kunden", + "Top categories": "Top-Kategorien", "Current": "Aktuell", "Performance": "Leistung", "Historical": "Historisch", diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index cdac0a6..0046435 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -296,6 +296,8 @@ "Not assigned": "Not assigned", "Could not load team dashboard.": "Could not load team dashboard.", "No team data available.": "No team data available.", + "Top customers": "Top customers", + "Top categories": "Top categories", "Current": "Current", "Performance": "Performance", "Historical": "Historical", diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index 17b3195..1c45902 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -1620,13 +1620,29 @@ class DebitorDetailService 'age_hours_sum' => 0, 'resolved_in_period' => 0, 'open_ticket_details' => [], + 'resolved_customers' => [], + 'resolved_categories' => [], + 'resolution_hours' => [], ]; } if ($isClosed) { + $createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? '')); if ($activityTs !== null && $activityTs >= $periodStart) { $agents[$normalizedUser]['resolved_in_period']++; $globalResolved++; + + $customerName = trim((string) ($ticket['Company_Contact_Name'] ?? (string) ($ticket['Cust_Name'] ?? ''))); + $category = trim((string) ($ticket['Category_1_Description'] ?? (string) ($ticket['Category_1_Code'] ?? ''))); + if ($customerName !== '') { + $agents[$normalizedUser]['resolved_customers'][$customerName] = ($agents[$normalizedUser]['resolved_customers'][$customerName] ?? 0) + 1; + } + if ($category !== '') { + $agents[$normalizedUser]['resolved_categories'][$category] = ($agents[$normalizedUser]['resolved_categories'][$category] ?? 0) + 1; + } + if ($createdTs !== null && $activityTs > $createdTs) { + $agents[$normalizedUser]['resolution_hours'][] = intdiv($activityTs - $createdTs, 3600); + } } } else { $agents[$normalizedUser]['open_tickets']++; @@ -1685,6 +1701,20 @@ class DebitorDetailService return $b['age_hours'] <=> $a['age_hours']; }); + // Performance insights from resolved tickets + $topCustomers = $agent['resolved_customers']; + arsort($topCustomers); + $topCustomers = array_slice($topCustomers, 0, 3, true); + + $topCategories = $agent['resolved_categories']; + arsort($topCategories); + $topCategories = array_slice($topCategories, 0, 3, true); + + $resHours = $agent['resolution_hours']; + $avgResolution = $resHours !== [] ? (int) round(array_sum($resHours) / count($resHours)) : null; + $minResolution = $resHours !== [] ? min($resHours) : null; + $maxResolution = $resHours !== [] ? max($resHours) : null; + $members[] = [ 'support_user' => $agent['support_user'], 'display_name' => $agent['display_name'], @@ -1693,6 +1723,21 @@ class DebitorDetailService 'avg_age_hours' => $avgAge, 'resolved_in_period' => $agent['resolved_in_period'], 'open_ticket_details' => $details, + 'performance' => [ + 'top_customers' => array_map( + static fn (string $name, int $count): array => ['name' => $name, 'count' => $count], + array_keys($topCustomers), + array_values($topCustomers) + ), + 'top_categories' => array_map( + static fn (string $name, int $count): array => ['name' => $name, 'count' => $count], + array_keys($topCategories), + array_values($topCategories) + ), + 'avg_resolution_hours' => $avgResolution, + 'min_resolution_hours' => $minResolution, + 'max_resolution_hours' => $maxResolution, + ], ]; } diff --git a/modules/helpdesk/pages/helpdesk/team/index(default).phtml b/modules/helpdesk/pages/helpdesk/team/index(default).phtml index ea821a9..7a4f2bb 100644 --- a/modules/helpdesk/pages/helpdesk/team/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/team/index(default).phtml @@ -29,6 +29,9 @@ $periodLabels = [ data-label-description="" data-label-category="" data-label-last-activity="" + data-label-resolution-time="" + data-label-top-customers="" + data-label-top-categories="" >
.helpdesk-segment-control { margin-bottom: calc(var(--app-spacing) * 0.75); } + /* Performance insights grid inside agent widget */ + .helpdesk-team-perf-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: calc(var(--app-spacing) * 0.75); + padding: calc(var(--app-spacing) * 0.65) calc(var(--app-spacing) * 0.75); + } + + @media (max-width: 760px) { + .helpdesk-team-perf-grid { + grid-template-columns: minmax(0, 1fr); + } + } + + .helpdesk-team-perf-stat-label { + display: block; + font-size: var(--text-xs, 0.75rem); + font-weight: 600; + color: var(--app-muted-color); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: calc(var(--app-spacing) * 0.3); + } + + .helpdesk-team-perf-times { + display: flex; + gap: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-team-perf-time { + display: flex; + flex-direction: column; + } + + .helpdesk-team-perf-time-value { + font-size: clamp(1rem, 0.9rem + 0.3vw, 1.2rem); + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.2; + } + + .helpdesk-team-perf-time-label { + font-size: var(--text-xs, 0.75rem); + color: var(--app-muted-color); + } + + .helpdesk-team-perf-ranked { + list-style: none; + margin: 0; + padding: 0; + counter-reset: rank; + } + + .helpdesk-team-perf-ranked li { + display: flex; + align-items: baseline; + gap: calc(var(--app-spacing) * 0.3); + padding: calc(var(--app-spacing) * 0.15) 0; + font-size: var(--text-sm, 0.875rem); + counter-increment: rank; + } + + .helpdesk-team-perf-ranked li::before { + content: counter(rank) "."; + color: var(--app-muted-color); + font-size: var(--text-xs, 0.75rem); + font-variant-numeric: tabular-nums; + min-width: 1.5ch; + } + + .helpdesk-team-perf-ranked-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .helpdesk-team-perf-ranked-count { + font-variant-numeric: tabular-nums; + color: var(--app-muted-color); + font-size: var(--text-xs, 0.75rem); + } + /* 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 efbf839..9b39c57 100644 --- a/modules/helpdesk/web/js/helpdesk-team.js +++ b/modules/helpdesk/web/js/helpdesk-team.js @@ -160,19 +160,86 @@ if (container) { return section; } + function formatResolution(hours) { + if (hours === null || hours === undefined) return '—'; + if (hours < 1) return '< 1h'; + if (hours < 24) return hours + 'h'; + const d = Math.floor(hours / 24); + const remainder = hours % 24; + return remainder > 0 ? d + 'd ' + remainder + 'h' : d + 'd'; + } + /** - * Build a compact row for the performance tab. + * Build a small ranked list (top customers / top categories). */ - function buildPerformanceRow(m) { - const isQueue = m.support_user === ''; - const section = h('div', 'helpdesk-team-perf-row'); + function buildRankedList(items, label) { + if (!items || items.length === 0) return null; - if (!isQueue) { - section.appendChild(h('span', 'helpdesk-team-avatar-inline', initials(m.display_name))); + 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'); + for (const item of items) { + const li = h('li'); + 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); } - section.appendChild(h('span', 'helpdesk-team-perf-name', isQueue ? labelQueue : m.display_name)); - section.appendChild(h('span', 'helpdesk-team-perf-count', String(m.resolved_in_period))); + wrap.appendChild(list); + return wrap; + } + /** + * Build a performance widget for one agent — card with insights. + */ + function buildPerformanceWidget(m) { + const section = h('section', 'helpdesk-team-widget'); + const perf = m.performance || {}; + + // Header: avatar + name + resolved count + const title = h('h3', 'helpdesk-support-section-title'); + if (m.support_user !== '') { + title.appendChild(h('span', 'helpdesk-team-avatar-inline', initials(m.display_name))); + } + title.appendChild(document.createTextNode(m.support_user === '' ? labelQueue : m.display_name)); + title.appendChild(h('span', 'helpdesk-team-title-count', String(m.resolved_in_period))); + section.appendChild(title); + + // Stats grid + const grid = h('div', 'helpdesk-team-perf-grid'); + + // Resolution time KPIs + const timeStats = h('div', 'helpdesk-team-perf-stat'); + timeStats.appendChild(h('span', 'helpdesk-team-perf-stat-label', container.dataset.labelResolutionTime || 'Resolution time')); + + const times = h('div', 'helpdesk-team-perf-times'); + const avgEl = h('div', 'helpdesk-team-perf-time'); + avgEl.appendChild(h('span', 'helpdesk-team-perf-time-value', formatResolution(perf.avg_resolution_hours))); + avgEl.appendChild(h('span', 'helpdesk-team-perf-time-label', 'Ø')); + times.appendChild(avgEl); + + const minEl = h('div', 'helpdesk-team-perf-time'); + minEl.appendChild(h('span', 'helpdesk-team-perf-time-value', formatResolution(perf.min_resolution_hours))); + minEl.appendChild(h('span', 'helpdesk-team-perf-time-label', 'Min')); + times.appendChild(minEl); + + const maxEl = h('div', 'helpdesk-team-perf-time'); + maxEl.appendChild(h('span', 'helpdesk-team-perf-time-value', formatResolution(perf.max_resolution_hours))); + maxEl.appendChild(h('span', 'helpdesk-team-perf-time-label', 'Max')); + times.appendChild(maxEl); + + timeStats.appendChild(times); + grid.appendChild(timeStats); + + // Top customers + const customersEl = buildRankedList(perf.top_customers, container.dataset.labelTopCustomers || 'Top customers'); + if (customersEl) grid.appendChild(customersEl); + + // Top categories + const categoriesEl = buildRankedList(perf.top_categories, container.dataset.labelTopCategories || 'Top categories'); + if (categoriesEl) grid.appendChild(categoriesEl); + + section.appendChild(grid); return section; } @@ -210,7 +277,7 @@ if (container) { return b.resolved_in_period - a.resolved_in_period; }); - for (const m of sorted) elPerformanceCards.appendChild(buildPerformanceRow(m)); + for (const m of sorted) elPerformanceCards.appendChild(buildPerformanceWidget(m)); } async function fetchData(refresh = false) {