From 397f9fe6596ea3dbc9db1fe9f2a49192ee859bf3 Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 5 Apr 2026 19:09:15 +0200 Subject: [PATCH] feat(helpdesk): redesign team dashboard with agent cards and ticket details Replace KPI tiles + table with visual agent cards showing workload at a glance. Each card has the agent name, ticket count badge (warning-colored when critical), a proportional load bar, and the actual open tickets listed underneath (number, description, age). Critical tickets are highlighted with warning color. Queue (unassigned) shown as dashed card at top. Performance tab uses compact cards with success-colored bars. Backend now returns open_ticket_details per member (sorted: critical first, then by age descending). Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/helpdesk/i18n/default_de.json | 3 +- modules/helpdesk/i18n/default_en.json | 3 +- .../Helpdesk/Service/DebitorDetailService.php | 25 ++- .../pages/helpdesk/team/index(default).phtml | 71 +------ modules/helpdesk/web/css/helpdesk.css | 201 ++++++++++-------- modules/helpdesk/web/js/helpdesk-team.js | 197 ++++++++--------- 6 files changed, 243 insertions(+), 257 deletions(-) diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 49a885a..5425d3b 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -296,5 +296,6 @@ "Could not load team dashboard.": "Team-Dashboard konnte nicht geladen werden.", "No team data available.": "Keine Team-Daten verfügbar.", "Current": "Aktuell", - "Performance": "Leistung" + "Performance": "Leistung", + "Queue": "Warteschlange" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 48010d6..a523e60 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -296,5 +296,6 @@ "Could not load team dashboard.": "Could not load team dashboard.", "No team data available.": "No team data available.", "Current": "Current", - "Performance": "Performance" + "Performance": "Performance", + "Queue": "Queue" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index 0acc4fa..da406e0 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -1593,7 +1593,7 @@ class DebitorDetailService $periodStart = $nowTs - ($periodDays * 86400); $criticalThresholdHours = 48; - /** @var array $agents */ + /** @var array}> $agents */ $agents = []; $globalOpen = 0; $globalUnassigned = 0; @@ -1619,6 +1619,7 @@ class DebitorDetailService 'critical_tickets' => 0, 'age_hours_sum' => 0, 'resolved_in_period' => 0, + 'open_ticket_details' => [], ]; } @@ -1635,17 +1636,28 @@ class DebitorDetailService $globalUnassigned++; } + $ageHours = 0; + $isCritical = false; if ($activityTs !== null) { $ageHours = intdiv(max(0, $nowTs - $activityTs), 3600); $agents[$normalizedUser]['age_hours_sum'] += $ageHours; if ($ageHours > $criticalThresholdHours) { $agents[$normalizedUser]['critical_tickets']++; + $isCritical = true; } $globalAgeSum += $ageHours; $globalOpenCount++; } + + $agents[$normalizedUser]['open_ticket_details'][] = [ + 'no' => trim((string) ($ticket['No'] ?? '')), + 'description' => trim((string) ($ticket['Description'] ?? '')), + 'age_hours' => $ageHours, + 'critical' => $isCritical, + 'customer' => trim((string) ($ticket['Customer_No'] ?? '')), + ]; } } @@ -1655,6 +1667,16 @@ class DebitorDetailService ? (int) round($agent['age_hours_sum'] / $agent['open_tickets']) : 0; + // Sort ticket details: critical first, then by age descending + $details = $agent['open_ticket_details']; + usort($details, static function (array $a, array $b): int { + if ($a['critical'] !== $b['critical']) { + return $b['critical'] <=> $a['critical']; + } + + return $b['age_hours'] <=> $a['age_hours']; + }); + $members[] = [ 'support_user' => $agent['support_user'], 'display_name' => $agent['display_name'], @@ -1662,6 +1684,7 @@ class DebitorDetailService 'critical_tickets' => $agent['critical_tickets'], 'avg_age_hours' => $avgAge, 'resolved_in_period' => $agent['resolved_in_period'], + 'open_ticket_details' => $details, ]; } diff --git a/modules/helpdesk/pages/helpdesk/team/index(default).phtml b/modules/helpdesk/pages/helpdesk/team/index(default).phtml index 9bd35e2..f0a2dae 100644 --- a/modules/helpdesk/pages/helpdesk/team/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/team/index(default).phtml @@ -1,9 +1,9 @@ " data-label-not-assigned="" + data-label-queue="" + data-label-tickets="" data-label-error="" data-label-empty="" > @@ -41,21 +43,8 @@ $periodLabels = [
-
- -
-
-
-
- -
- -
-
-
-
-
-
+ +
@@ -75,33 +64,7 @@ $periodLabels = [
-
-
-

-

-
-
-

-

-
-
-

-

-
-
- - - - - - - - - - - - -
48h open)')); ?>
+
@@ -112,23 +75,7 @@ $periodLabels = [
-
-
-

-

-
-
- - - - - - - - - - -
+
diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 2e98630..21ac81e 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -126,114 +126,141 @@ } } - /* Team workload dashboard */ - .helpdesk-team-table { - width: 100%; - margin-top: calc(var(--app-spacing) * 0.75); - border-collapse: separate; - border-spacing: 0; + /* Team workload dashboard — agent cards */ + .helpdesk-team-card { + border: 1px solid var(--app-muted-border-color); + border-radius: var(--app-radius, 0.375rem); + padding: calc(var(--app-spacing) * 0.65) calc(var(--app-spacing) * 0.75); + margin-bottom: calc(var(--app-spacing) * 0.5); } - .helpdesk-team-table th { - text-align: left; - padding: calc(var(--app-spacing) * 0.3) calc(var(--app-spacing) * 0.5); + .helpdesk-team-card-queue { + border-style: dashed; + background: color-mix(in srgb, var(--app-muted-border-color) 15%, transparent); + } + + .helpdesk-team-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--app-spacing) * 0.5); + margin-bottom: calc(var(--app-spacing) * 0.35); + } + + .helpdesk-team-card-name { + font-weight: 600; font-size: var(--text-sm, 0.875rem); + } + + .helpdesk-team-card-queue .helpdesk-team-card-name { font-weight: 500; color: var(--app-muted-color); - border-bottom: 1px solid var(--app-muted-border-color); } - .helpdesk-team-table td { - text-align: left; - padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.5); - border-bottom: 1px solid color-mix(in srgb, var(--app-muted-border-color) 50%, transparent); + .helpdesk-team-card-count { + font-variant-numeric: tabular-nums; + font-weight: 600; + font-size: var(--text-sm, 0.875rem); + padding: 0.1em 0.55em; + border-radius: 999px; + background: color-mix(in srgb, var(--app-primary, #0d6efd) 12%, transparent); + color: var(--app-primary, #0d6efd); } - .helpdesk-team-table th:not(:first-child), - .helpdesk-team-table td:not(:first-child) { - text-align: right; + .helpdesk-team-card-count-warning { + background: color-mix(in srgb, var(--app-warning, #f59e0b) 15%, transparent); + color: var(--app-warning, #f59e0b); } - .helpdesk-team-table tbody tr:last-child td { - border-bottom: none; + .helpdesk-team-card-count-success { + background: color-mix(in srgb, var(--app-success, #198754) 12%, transparent); + color: var(--app-success, #198754); } - .helpdesk-team-agent-name { + .helpdesk-team-card-bar-track { + height: 4px; + border-radius: 999px; + background: color-mix(in srgb, var(--app-muted-border-color) 60%, transparent); + margin-bottom: calc(var(--app-spacing) * 0.4); + } + + .helpdesk-team-card-bar-fill { + height: 100%; + border-radius: 999px; + background: var(--app-primary, #0d6efd); + transition: width 0.3s ease; + min-width: 2px; + } + + .helpdesk-team-card-bar-muted { + background: var(--app-muted-color); + opacity: 0.4; + } + + .helpdesk-team-card-bar-success { + background: var(--app-success, #198754); + } + + .helpdesk-team-card-tickets { + list-style: none; + margin: 0; + padding: 0; + } + + .helpdesk-team-card-ticket { + display: flex; + align-items: baseline; + gap: calc(var(--app-spacing) * 0.4); + padding: calc(var(--app-spacing) * 0.2) 0; + font-size: var(--text-sm, 0.875rem); + border-top: 1px solid color-mix(in srgb, var(--app-muted-border-color) 40%, transparent); + } + + .helpdesk-team-card-ticket:first-child { + border-top: none; + } + + .helpdesk-team-card-ticket-no { + font-variant-numeric: tabular-nums; font-weight: 500; - } - - .helpdesk-team-row-unassigned { - font-style: italic; - } - - .helpdesk-team-row-unassigned .helpdesk-team-agent-name { - font-weight: 400; color: var(--app-muted-color); + flex-shrink: 0; + } + + .helpdesk-team-card-ticket-desc { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .helpdesk-team-card-ticket-age { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: var(--app-muted-color); + } + + .helpdesk-team-card-ticket-critical .helpdesk-team-card-ticket-age { + color: var(--app-warning, #f59e0b); + font-weight: 600; + } + + .helpdesk-team-card-ticket-critical .helpdesk-team-card-ticket-no { + color: var(--app-warning, #f59e0b); + } + + .helpdesk-team-card-compact { + padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.75); + } + + .helpdesk-team-card-compact .helpdesk-team-card-bar-track { + margin-bottom: 0; } .helpdesk-team-performance-panel > .helpdesk-segment-control { margin-bottom: calc(var(--app-spacing) * 0.75); } - /* Muted zero values — de-emphasize non-information */ - .helpdesk-team-muted { - color: var(--app-muted-color); - opacity: 0.5; - } - - /* Inline proportional bar — Stripe-style visual comparison */ - .helpdesk-team-bar-cell { - display: inline-flex; - align-items: center; - gap: calc(var(--app-spacing) * 0.35); - min-width: 5rem; - justify-content: flex-end; - } - - .helpdesk-team-bar { - height: 0.5rem; - border-radius: 999px; - flex-shrink: 0; - min-width: 2px; - width: 0; - transition: width 0.3s ease; - } - - .helpdesk-team-bar-primary { - background: var(--app-primary, #0d6efd); - } - - .helpdesk-team-bar-success { - background: var(--app-success, #198754); - } - - .helpdesk-team-bar-muted { - background: var(--app-muted-color); - opacity: 0.35; - } - - .helpdesk-team-bar-value { - font-variant-numeric: tabular-nums; - min-width: 2ch; - text-align: right; - } - - /* Critical badge — draws attention to non-zero values */ - .helpdesk-team-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.5em; - padding: 0.1em 0.45em; - font-size: var(--text-sm, 0.875rem); - font-weight: 600; - font-variant-numeric: tabular-nums; - line-height: 1.3; - border-radius: 999px; - background: color-mix(in srgb, var(--app-warning, #f59e0b) 15%, transparent); - color: var(--app-warning, #f59e0b); - } - /* 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 fb92e3c..cf2478d 100644 --- a/modules/helpdesk/web/js/helpdesk-team.js +++ b/modules/helpdesk/web/js/helpdesk-team.js @@ -1,20 +1,22 @@ /** - * Team workload dashboard — fetches and renders team metrics. - * Tab "Current": snapshot of open tickets per agent. - * Tab "Performance": resolved tickets per agent in a selectable period. + * Team workload dashboard — visual workload map. + * Current tab: agent cards with load bars and ticket details. + * Performance tab: resolved-count cards per agent. */ const container = document.querySelector('.app-details-container[data-team-workload-url]'); if (container) { const dataUrl = container.dataset.teamWorkloadUrl; const labelNotAssigned = container.dataset.labelNotAssigned || '—'; + const labelQueue = container.dataset.labelQueue || 'Queue'; + const labelTickets = container.dataset.labelTickets || 'Tickets'; const elLoading = document.getElementById('team-loading'); const elError = document.getElementById('team-error'); const elEmpty = document.getElementById('team-empty'); const elContent = document.getElementById('team-content'); - const elCurrentBody = document.getElementById('team-table-current'); - const elPerformanceBody = document.getElementById('team-table-performance'); + const elCurrentCards = document.getElementById('team-current-cards'); + const elPerformanceCards = document.getElementById('team-performance-cards'); const elRefresh = container.querySelector('button[name="team-refresh"]'); const periodSelector = document.getElementById('team-period-selector'); @@ -28,110 +30,113 @@ if (container) { if (elLoading) elLoading.setAttribute('aria-busy', state === 'loading' ? 'true' : 'false'); } - function setText(id, value) { - const el = document.getElementById(id); - if (el) el.textContent = value; + function el(tag, className, text) { + const e = document.createElement(tag); + if (className) e.className = className; + if (text !== undefined) e.textContent = text; + return e; } - function agentName(m) { - return m.support_user === '' ? labelNotAssigned : m.display_name; + function formatAge(hours) { + if (hours < 24) return hours + 'h'; + const days = Math.floor(hours / 24); + return days + 'd'; } /** - * Create a table cell with an inline proportional bar behind the value. - * Stripe-style: the bar gives instant visual comparison across rows. + * Build a single agent card for the Current tab. */ - function barCell(value, max, variant) { - const td = document.createElement('td'); - const pct = max > 0 ? Math.round((value / max) * 100) : 0; + function buildAgentCard(m, maxOpen) { + const isQueue = m.support_user === ''; + const name = isQueue ? labelQueue : m.display_name; + const pct = maxOpen > 0 ? Math.round((m.open_tickets / maxOpen) * 100) : 0; - const wrap = document.createElement('span'); - wrap.className = 'helpdesk-team-bar-cell'; + const card = el('article', 'helpdesk-team-card'); + if (isQueue) card.classList.add('helpdesk-team-card-queue'); - const bar = document.createElement('span'); - bar.className = 'helpdesk-team-bar'; - if (variant) bar.classList.add('helpdesk-team-bar-' + variant); - bar.style.width = pct + '%'; + // Header row: name + count badge + const header = el('div', 'helpdesk-team-card-header'); + const nameEl = el('span', 'helpdesk-team-card-name', name); + const countEl = el('span', 'helpdesk-team-card-count', String(m.open_tickets)); + if (m.critical_tickets > 0) countEl.classList.add('helpdesk-team-card-count-warning'); + header.appendChild(nameEl); + header.appendChild(countEl); + card.appendChild(header); - const label = document.createElement('span'); - label.className = 'helpdesk-team-bar-value'; - label.textContent = String(value); - if (value === 0) label.classList.add('helpdesk-team-muted'); + // Load bar + const barTrack = el('div', 'helpdesk-team-card-bar-track'); + const barFill = el('div', 'helpdesk-team-card-bar-fill'); + if (isQueue) barFill.classList.add('helpdesk-team-card-bar-muted'); + barFill.style.width = pct + '%'; + barTrack.appendChild(barFill); + card.appendChild(barTrack); - wrap.appendChild(bar); - wrap.appendChild(label); - td.appendChild(wrap); - return td; - } + // Ticket list + const tickets = m.open_ticket_details || []; + if (tickets.length > 0) { + const list = el('ul', 'helpdesk-team-card-tickets'); + for (const t of tickets) { + const li = el('li', 'helpdesk-team-card-ticket'); + if (t.critical) li.classList.add('helpdesk-team-card-ticket-critical'); - /** - * Create a badge cell — small pill for critical counts. - * 0 = muted text, >0 = warning badge. - */ - function badgeCell(value) { - const td = document.createElement('td'); - if (value === 0) { - td.textContent = '0'; - td.classList.add('helpdesk-team-muted'); - } else { - const badge = document.createElement('span'); - badge.className = 'helpdesk-team-badge'; - badge.textContent = String(value); - td.appendChild(badge); + const no = el('span', 'helpdesk-team-card-ticket-no', t.no); + const desc = el('span', 'helpdesk-team-card-ticket-desc', t.description || '—'); + const age = el('span', 'helpdesk-team-card-ticket-age', formatAge(t.age_hours)); + + li.appendChild(no); + li.appendChild(desc); + li.appendChild(age); + list.appendChild(li); + } + card.appendChild(list); } - return td; + + return card; } /** - * Create a plain text cell, muted when value is 0. + * Build a single agent card for the Performance tab. */ - function valueCell(value, unit) { - const td = document.createElement('td'); - td.textContent = unit ? value + unit : String(value); - if (value === 0) td.classList.add('helpdesk-team-muted'); - return td; + function buildPerformanceCard(m, maxResolved) { + const isQueue = m.support_user === ''; + const name = isQueue ? labelQueue : m.display_name; + const pct = maxResolved > 0 ? Math.round((m.resolved_in_period / maxResolved) * 100) : 0; + + const card = el('article', 'helpdesk-team-card helpdesk-team-card-compact'); + if (isQueue) card.classList.add('helpdesk-team-card-queue'); + + const header = el('div', 'helpdesk-team-card-header'); + header.appendChild(el('span', 'helpdesk-team-card-name', name)); + header.appendChild(el('span', 'helpdesk-team-card-count helpdesk-team-card-count-success', String(m.resolved_in_period))); + card.appendChild(header); + + const barTrack = el('div', 'helpdesk-team-card-bar-track'); + const barFill = el('div', 'helpdesk-team-card-bar-fill helpdesk-team-card-bar-success'); + barFill.style.width = pct + '%'; + barTrack.appendChild(barFill); + card.appendChild(barTrack); + + return card; } - function renderCurrentTab(kpis, members) { - setText('team-kpi-open', kpis.open_tickets ?? 0); - setText('team-kpi-unassigned', kpis.unassigned ?? 0); - setText('team-kpi-avg-age', kpis.avg_age_hours ?? 0); - - if (!elCurrentBody) return; - elCurrentBody.replaceChildren(); + function renderCurrentTab(members) { + if (!elCurrentCards) return; + elCurrentCards.replaceChildren(); const visible = members.filter(m => m.open_tickets > 0 || m.critical_tickets > 0); const maxOpen = Math.max(1, ...visible.map(m => m.open_tickets)); - for (const m of visible) { - const tr = document.createElement('tr'); - const isUnassigned = m.support_user === ''; + // Queue (unassigned) first if present + const queue = visible.find(m => m.support_user === ''); + const agents = visible.filter(m => m.support_user !== ''); - // Agent name - const nameCell = document.createElement('td'); - nameCell.className = 'helpdesk-team-agent-name'; - nameCell.textContent = agentName(m); - tr.appendChild(nameCell); - - // Open tickets with proportional bar - tr.appendChild(barCell(m.open_tickets, maxOpen, isUnassigned ? 'muted' : 'primary')); - - // Critical with badge - tr.appendChild(badgeCell(m.critical_tickets)); - - // Avg age - tr.appendChild(valueCell(m.avg_age_hours, 'h')); - - if (isUnassigned) tr.classList.add('helpdesk-team-row-unassigned'); - elCurrentBody.appendChild(tr); - } + if (queue) elCurrentCards.appendChild(buildAgentCard(queue, maxOpen)); + for (const m of agents) elCurrentCards.appendChild(buildAgentCard(m, maxOpen)); } - function renderPerformanceTab(kpis, members) { - setText('team-kpi-resolved', kpis.resolved_in_period ?? 0); - - if (!elPerformanceBody) return; - elPerformanceBody.replaceChildren(); + function renderPerformanceTab(members) { + if (!elPerformanceCards) return; + elPerformanceCards.replaceChildren(); const sorted = [...members] .filter(m => m.resolved_in_period > 0) @@ -142,26 +147,7 @@ if (container) { }); const maxResolved = Math.max(1, ...sorted.map(m => m.resolved_in_period)); - - for (const m of sorted) { - const tr = document.createElement('tr'); - const isUnassigned = m.support_user === ''; - - const nameCell = document.createElement('td'); - nameCell.className = 'helpdesk-team-agent-name'; - nameCell.textContent = agentName(m); - tr.appendChild(nameCell); - - tr.appendChild(barCell(m.resolved_in_period, maxResolved, isUnassigned ? 'muted' : 'success')); - - if (isUnassigned) tr.classList.add('helpdesk-team-row-unassigned'); - elPerformanceBody.appendChild(tr); - } - } - - function renderAll(data) { - renderCurrentTab(data.kpis, data.members); - renderPerformanceTab(data.kpis, data.members); + for (const m of sorted) elPerformanceCards.appendChild(buildPerformanceCard(m, maxResolved)); } async function fetchData(refresh = false) { @@ -184,7 +170,8 @@ if (container) { return; } - renderAll(json); + renderCurrentTab(json.members); + renderPerformanceTab(json.members); showState('success'); } catch { showState('error');