From 0927dea7bbfa66c10c1e86ab0db87f3d1750a8d8 Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 5 Apr 2026 18:56:25 +0200 Subject: [PATCH] feat(helpdesk): add Stripe-style inline bars, badges, and muted zeros to team tables Current tab: proportional bars behind open ticket counts for instant visual comparison, warning badges for critical counts (>0), muted styling for zero values. Performance tab: success-colored bars behind resolved counts. Unassigned row gets muted bar variant. Adds tabular-nums for aligned numbers, smooth bar transitions, and de-emphasized table borders for a cleaner visual hierarchy. Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/helpdesk/web/css/helpdesk.css | 88 ++++++++++++++++++++- modules/helpdesk/web/js/helpdesk-team.js | 99 +++++++++++++++++++----- 2 files changed, 166 insertions(+), 21 deletions(-) diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index b69e56b..329d809 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -130,12 +130,23 @@ .helpdesk-team-table { width: 100%; margin-top: calc(var(--app-spacing) * 0.75); + border-collapse: separate; + border-spacing: 0; + } + + .helpdesk-team-table th { + text-align: left; + padding: calc(var(--app-spacing) * 0.3) calc(var(--app-spacing) * 0.5); + font-size: var(--text-sm, 0.875rem); + font-weight: 500; + color: var(--app-muted-color); + border-bottom: 1px solid var(--app-muted-border-color); } - .helpdesk-team-table th, .helpdesk-team-table td { text-align: left; - padding: calc(var(--app-spacing) * 0.35) calc(var(--app-spacing) * 0.5); + 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-table th:not(:first-child), @@ -143,11 +154,82 @@ text-align: right; } + .helpdesk-team-table tbody tr:last-child td { + border-bottom: none; + } + + .helpdesk-team-agent-name { + font-weight: 500; + } + .helpdesk-team-row-unassigned { - opacity: 0.7; font-style: italic; } + .helpdesk-team-row-unassigned .helpdesk-team-agent-name { + font-weight: 400; + color: var(--app-muted-color); + } + + /* 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 3627608..df15e4f 100644 --- a/modules/helpdesk/web/js/helpdesk-team.js +++ b/modules/helpdesk/web/js/helpdesk-team.js @@ -19,7 +19,6 @@ if (container) { const periodSelector = document.getElementById('team-period-selector'); let currentPeriod = 90; - let lastData = null; function showState(state) { if (elLoading) elLoading.hidden = state !== 'loading'; @@ -34,14 +33,63 @@ if (container) { if (el) el.textContent = value; } - function createCell(text) { + function agentName(m) { + return m.support_user === '' ? labelNotAssigned : m.display_name; + } + + /** + * Create a table cell with an inline proportional bar behind the value. + * Stripe-style: the bar gives instant visual comparison across rows. + */ + function barCell(value, max, variant) { const td = document.createElement('td'); - td.textContent = text; + const pct = max > 0 ? Math.round((value / max) * 100) : 0; + + const wrap = document.createElement('span'); + wrap.className = 'helpdesk-team-bar-cell'; + + const bar = document.createElement('span'); + bar.className = 'helpdesk-team-bar'; + if (variant) bar.classList.add('helpdesk-team-bar-' + variant); + bar.style.width = pct + '%'; + + const label = document.createElement('span'); + label.className = 'helpdesk-team-bar-value'; + label.textContent = String(value); + if (value === 0) label.classList.add('helpdesk-team-muted'); + + wrap.appendChild(bar); + wrap.appendChild(label); + td.appendChild(wrap); return td; } - function agentName(m) { - return m.support_user === '' ? labelNotAssigned : m.display_name; + /** + * 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); + } + return td; + } + + /** + * Create a plain text cell, muted when value is 0. + */ + 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 renderCurrentTab(kpis, members) { @@ -52,17 +100,28 @@ if (container) { if (!elCurrentBody) return; elCurrentBody.replaceChildren(); + const maxOpen = Math.max(1, ...members.map(m => m.open_tickets)); + for (const m of members) { const tr = document.createElement('tr'); - tr.appendChild(createCell(agentName(m))); - tr.appendChild(createCell(String(m.open_tickets))); - tr.appendChild(createCell(String(m.critical_tickets))); - tr.appendChild(createCell(String(m.avg_age_hours))); + const isUnassigned = m.support_user === ''; - if (m.support_user === '') { - tr.classList.add('helpdesk-team-row-unassigned'); - } + // 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); } } @@ -79,21 +138,25 @@ if (container) { return b.resolved_in_period - a.resolved_in_period; }); + const maxResolved = Math.max(1, ...sorted.map(m => m.resolved_in_period)); + for (const m of sorted) { const tr = document.createElement('tr'); - tr.appendChild(createCell(agentName(m))); - tr.appendChild(createCell(String(m.resolved_in_period))); + const isUnassigned = m.support_user === ''; - if (m.support_user === '') { - tr.classList.add('helpdesk-team-row-unassigned'); - } + 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) { - lastData = data; renderCurrentTab(data.kpis, data.members); renderPerformanceTab(data.kpis, data.members); }