diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 21ac81e..e70c8c8 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -126,134 +126,185 @@ } } - /* Team workload dashboard — agent cards */ - .helpdesk-team-card { - border: 1px solid var(--app-muted-border-color); + /* Team workload — queue section */ + .helpdesk-team-queue { + border: 1px dashed 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); + padding: calc(var(--app-spacing) * 0.6) calc(var(--app-spacing) * 0.75); + margin-bottom: calc(var(--app-spacing) * 0.75); + background: color-mix(in srgb, var(--app-muted-border-color) 10%, transparent); } - .helpdesk-team-card-queue { - border-style: dashed; - background: color-mix(in srgb, var(--app-muted-border-color) 15%, transparent); + .helpdesk-team-queue-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: calc(var(--app-spacing) * 0.4); } - .helpdesk-team-card-header { + .helpdesk-team-queue-title { + font-weight: 600; + font-size: var(--text-sm, 0.875rem); + color: var(--app-muted-color); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .helpdesk-team-queue-count { + font-size: var(--text-sm, 0.875rem); + color: var(--app-muted-color); + } + + /* Team workload — agent card with avatar */ + .helpdesk-team-agent { + display: flex; + gap: calc(var(--app-spacing) * 0.65); + padding: calc(var(--app-spacing) * 0.6) 0; + border-bottom: 1px solid color-mix(in srgb, var(--app-muted-border-color) 50%, transparent); + } + + .helpdesk-team-agent:last-child { + border-bottom: none; + } + + .helpdesk-team-avatar { + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.02em; + flex-shrink: 0; + background: hsl(var(--agent-hue, 210) 55% 92%); + color: hsl(var(--agent-hue, 210) 50% 35%); + } + + .helpdesk-team-avatar-sm { + width: 1.75rem; + height: 1.75rem; + font-size: 0.65rem; + } + + .helpdesk-team-avatar-muted { + background: var(--app-muted-border-color); + color: var(--app-muted-color); + } + + .helpdesk-team-agent-content { + flex: 1; + min-width: 0; + } + + .helpdesk-team-agent-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); + margin-bottom: calc(var(--app-spacing) * 0.25); } - .helpdesk-team-card-name { + .helpdesk-team-agent-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); - } - - .helpdesk-team-card-count { + .helpdesk-team-agent-count { font-variant-numeric: tabular-nums; font-weight: 600; - font-size: var(--text-sm, 0.875rem); - padding: 0.1em 0.55em; + font-size: 0.75rem; + padding: 0.1em 0.5em; border-radius: 999px; background: color-mix(in srgb, var(--app-primary, #0d6efd) 12%, transparent); color: var(--app-primary, #0d6efd); } - .helpdesk-team-card-count-warning { + .helpdesk-team-agent-count-warning { background: color-mix(in srgb, var(--app-warning, #f59e0b) 15%, transparent); color: var(--app-warning, #f59e0b); } - .helpdesk-team-card-count-success { + .helpdesk-team-agent-count-success { background: color-mix(in srgb, var(--app-success, #198754) 12%, transparent); color: var(--app-success, #198754); } - .helpdesk-team-card-bar-track { - height: 4px; + /* Load bar */ + .helpdesk-team-agent-bar-track { + height: 3px; border-radius: 999px; - background: color-mix(in srgb, var(--app-muted-border-color) 60%, transparent); - margin-bottom: calc(var(--app-spacing) * 0.4); + background: color-mix(in srgb, var(--app-muted-border-color) 50%, transparent); + margin-bottom: calc(var(--app-spacing) * 0.35); } - .helpdesk-team-card-bar-fill { + .helpdesk-team-agent-bar-fill { height: 100%; border-radius: 999px; - background: var(--app-primary, #0d6efd); + background: hsl(var(--agent-hue, 210) 55% 55%); 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 { + .helpdesk-team-agent-bar-success { background: var(--app-success, #198754); } - .helpdesk-team-card-tickets { - list-style: none; - margin: 0; - padding: 0; - } - - .helpdesk-team-card-ticket { + /* Ticket pills */ + .helpdesk-team-pills { 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); + flex-wrap: wrap; + gap: calc(var(--app-spacing) * 0.25); } - .helpdesk-team-card-ticket:first-child { - border-top: none; - } - - .helpdesk-team-card-ticket-no { + .helpdesk-team-pill { + display: inline-flex; + align-items: center; + gap: 0.3em; + font-size: 0.75rem; font-variant-numeric: tabular-nums; - font-weight: 500; - color: var(--app-muted-color); - flex-shrink: 0; + padding: 0.15em 0.5em; + border-radius: var(--app-radius, 0.375rem); + background: color-mix(in srgb, var(--app-muted-border-color) 35%, transparent); + color: var(--app-color); + cursor: default; + line-height: 1.5; } - .helpdesk-team-card-ticket-desc { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + .helpdesk-team-pill-critical { + background: color-mix(in srgb, var(--app-warning, #f59e0b) 12%, transparent); } - .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); + .helpdesk-team-pill-no { font-weight: 600; } - .helpdesk-team-card-ticket-critical .helpdesk-team-card-ticket-no { + .helpdesk-team-pill-sep { + opacity: 0.35; + } + + .helpdesk-team-pill-customer { + opacity: 0.6; + } + + .helpdesk-team-pill-age { + opacity: 0.6; + } + + .helpdesk-team-pill-critical .helpdesk-team-pill-no, + .helpdesk-team-pill-critical .helpdesk-team-pill-age { color: var(--app-warning, #f59e0b); + opacity: 1; + font-weight: 600; } - .helpdesk-team-card-compact { - padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.75); + /* Compact variant for performance tab */ + .helpdesk-team-agent-compact { + padding: calc(var(--app-spacing) * 0.4) 0; } - .helpdesk-team-card-compact .helpdesk-team-card-bar-track { + .helpdesk-team-agent-compact .helpdesk-team-agent-bar-track { margin-bottom: 0; } diff --git a/modules/helpdesk/web/js/helpdesk-team.js b/modules/helpdesk/web/js/helpdesk-team.js index cf2478d..0e9b0a1 100644 --- a/modules/helpdesk/web/js/helpdesk-team.js +++ b/modules/helpdesk/web/js/helpdesk-team.js @@ -1,13 +1,12 @@ /** - * Team workload dashboard — visual workload map. - * Current tab: agent cards with load bars and ticket details. - * Performance tab: resolved-count cards per agent. + * Team workload dashboard — visual workload map with avatar cards. + * Current tab: queue section + agent cards with ticket pills. + * Performance tab: compact agent cards with resolved counts. */ 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'; @@ -30,92 +29,149 @@ if (container) { if (elLoading) elLoading.setAttribute('aria-busy', state === 'loading' ? 'true' : 'false'); } - function el(tag, className, text) { + function h(tag, cls, text) { const e = document.createElement(tag); - if (className) e.className = className; + if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; } - function formatAge(hours) { - if (hours < 24) return hours + 'h'; - const days = Math.floor(hours / 24); - return days + 'd'; + /** + * Generate two-letter initials from a display name. + */ + function initials(name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.substring(0, 2).toUpperCase(); } /** - * Build a single agent card for the Current tab. + * Deterministic hue from string — consistent color per agent. + */ + function nameHue(name) { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return Math.abs(hash) % 360; + } + + function formatAge(hours) { + if (hours < 24) return hours + 'h'; + return Math.floor(hours / 24) + 'd'; + } + + /** + * Build a ticket pill: "T-1234 · K-10610 · 12h" + */ + function buildTicketPill(t) { + const pill = h('span', 'helpdesk-team-pill'); + if (t.critical) pill.classList.add('helpdesk-team-pill-critical'); + + pill.appendChild(h('span', 'helpdesk-team-pill-no', t.no)); + if (t.customer) { + pill.appendChild(h('span', 'helpdesk-team-pill-sep', '·')); + pill.appendChild(h('span', 'helpdesk-team-pill-customer', t.customer)); + } + pill.appendChild(h('span', 'helpdesk-team-pill-sep', '·')); + pill.appendChild(h('span', 'helpdesk-team-pill-age', formatAge(t.age_hours))); + + if (t.description) pill.title = t.description; + return pill; + } + + /** + * Build the queue section (unassigned tickets). + */ + function buildQueueSection(member) { + const section = h('div', 'helpdesk-team-queue'); + + const header = h('div', 'helpdesk-team-queue-header'); + header.appendChild(h('span', 'helpdesk-team-queue-title', labelQueue)); + header.appendChild(h('span', 'helpdesk-team-queue-count', String(member.open_tickets) + ' ' + labelTickets)); + section.appendChild(header); + + const pills = h('div', 'helpdesk-team-pills'); + for (const t of member.open_ticket_details || []) { + pills.appendChild(buildTicketPill(t)); + } + section.appendChild(pills); + + return section; + } + + /** + * Build an agent card with avatar, load bar, and ticket pills. */ 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 hue = nameHue(m.display_name); - const card = el('article', 'helpdesk-team-card'); - if (isQueue) card.classList.add('helpdesk-team-card-queue'); + const card = h('article', 'helpdesk-team-agent'); - // 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); + // Avatar + const avatar = h('div', 'helpdesk-team-avatar', initials(m.display_name)); + avatar.style.setProperty('--agent-hue', hue); + card.appendChild(avatar); + + // Content area + const content = h('div', 'helpdesk-team-agent-content'); + + // Name row + const nameRow = h('div', 'helpdesk-team-agent-header'); + nameRow.appendChild(h('span', 'helpdesk-team-agent-name', m.display_name)); + const countBadge = h('span', 'helpdesk-team-agent-count', String(m.open_tickets)); + if (m.critical_tickets > 0) countBadge.classList.add('helpdesk-team-agent-count-warning'); + nameRow.appendChild(countBadge); + content.appendChild(nameRow); // 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'); + const barTrack = h('div', 'helpdesk-team-agent-bar-track'); + const barFill = h('div', 'helpdesk-team-agent-bar-fill'); barFill.style.width = pct + '%'; + barFill.style.setProperty('--agent-hue', hue); barTrack.appendChild(barFill); - card.appendChild(barTrack); + content.appendChild(barTrack); - // Ticket list + // Ticket pills 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'); - - 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); + const pills = h('div', 'helpdesk-team-pills'); + for (const t of tickets) pills.appendChild(buildTicketPill(t)); + content.appendChild(pills); } + card.appendChild(content); return card; } /** - * Build a single agent card for the Performance tab. + * Build a compact performance card with avatar and resolved bar. */ 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 hue = nameHue(m.display_name || 'queue'); - const card = el('article', 'helpdesk-team-card helpdesk-team-card-compact'); - if (isQueue) card.classList.add('helpdesk-team-card-queue'); + const card = h('article', 'helpdesk-team-agent helpdesk-team-agent-compact'); - 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 avatar = h('div', 'helpdesk-team-avatar helpdesk-team-avatar-sm', m.support_user === '' ? '?' : initials(m.display_name)); + avatar.style.setProperty('--agent-hue', m.support_user === '' ? 0 : hue); + if (m.support_user === '') avatar.classList.add('helpdesk-team-avatar-muted'); + card.appendChild(avatar); - const barTrack = el('div', 'helpdesk-team-card-bar-track'); - const barFill = el('div', 'helpdesk-team-card-bar-fill helpdesk-team-card-bar-success'); + const content = h('div', 'helpdesk-team-agent-content'); + const nameRow = h('div', 'helpdesk-team-agent-header'); + nameRow.appendChild(h('span', 'helpdesk-team-agent-name', m.support_user === '' ? labelQueue : m.display_name)); + nameRow.appendChild(h('span', 'helpdesk-team-agent-count helpdesk-team-agent-count-success', String(m.resolved_in_period))); + content.appendChild(nameRow); + + const barTrack = h('div', 'helpdesk-team-agent-bar-track'); + const barFill = h('div', 'helpdesk-team-agent-bar-fill helpdesk-team-agent-bar-success'); barFill.style.width = pct + '%'; barTrack.appendChild(barFill); - card.appendChild(barTrack); + content.appendChild(barTrack); + card.appendChild(content); return card; } @@ -123,14 +179,11 @@ if (container) { 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)); + const queue = members.find(m => m.support_user === '' && m.open_tickets > 0); + const agents = members.filter(m => m.support_user !== '' && (m.open_tickets > 0 || m.critical_tickets > 0)); + const maxOpen = Math.max(1, ...agents.map(m => m.open_tickets)); - // Queue (unassigned) first if present - const queue = visible.find(m => m.support_user === ''); - const agents = visible.filter(m => m.support_user !== ''); - - if (queue) elCurrentCards.appendChild(buildAgentCard(queue, maxOpen)); + if (queue) elCurrentCards.appendChild(buildQueueSection(queue)); for (const m of agents) elCurrentCards.appendChild(buildAgentCard(m, maxOpen)); } @@ -160,15 +213,8 @@ if (container) { const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' }); const json = await res.json(); - if (!json.ok) { - showState('error'); - return; - } - - if (!json.members || json.members.length === 0) { - showState('empty'); - return; - } + if (!json.ok) { showState('error'); return; } + if (!json.members || json.members.length === 0) { showState('empty'); return; } renderCurrentTab(json.members); renderPerformanceTab(json.members);