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');