forked from fa/breadcrumb-the-shire
feat(helpdesk): redesign Historical tab with per-agent performance widgets
Each agent gets a card with three insight columns: - Resolution time: Ø / Min / Max (formatted as hours or days) - Top 3 customers by resolved ticket count (ranked list) - Top 3 categories by resolved ticket count (ranked list) Backend collects resolved ticket details per agent: customer names, categories, and resolution hours. Aggregates top-3 and min/max/avg. Replaces the old compact single-row layout with rich widget cards matching the Current tab pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ $periodLabels = [
|
||||
data-label-description="<?php e(t('Description')); ?>"
|
||||
data-label-category="<?php e(t('Category')); ?>"
|
||||
data-label-last-activity="<?php e(t('Last activity')); ?>"
|
||||
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')); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
|
||||
@@ -300,39 +300,93 @@
|
||||
background: var(--app-warning, #f59e0b);
|
||||
}
|
||||
|
||||
/* Performance tab — compact rows */
|
||||
.helpdesk-team-perf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
padding: calc(var(--app-spacing) * 0.35) 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--app-muted-border-color) 40%, transparent);
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.helpdesk-team-perf-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.helpdesk-team-perf-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.helpdesk-team-perf-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--app-success, #198754) 12%, transparent);
|
||||
color: var(--app-success, #198754);
|
||||
}
|
||||
|
||||
.helpdesk-team-performance-panel > .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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user