/** * Team workload dashboard. * Current tab: per-agent widget with section title + ticket table. * Performance tab: compact agent rows with resolved counts. */ const container = document.querySelector('.app-details-container[data-team-workload-url]'); if (container) { const dataUrl = container.dataset.teamWorkloadUrl; const labelQueue = container.dataset.labelQueue || 'Queue'; const labelTickets = container.dataset.labelTickets || 'Tickets'; const labelCritical = container.dataset.labelCritical || 'Critical (>48h open)'; const labelTrendHint = container.dataset.labelTrendHint || 'Trend based on last {days} days'; const ticketBaseUrl = container.dataset.ticketBaseUrl || ''; const debitorBaseUrl = container.dataset.debitorBaseUrl || ''; const labelTrendUp = container.dataset.labelTrendUp || 'Backlog growing'; const labelTrendDown = container.dataset.labelTrendDown || 'Backlog shrinking'; 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 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'); let currentPeriod = 30; function showState(state) { if (elLoading) elLoading.hidden = state !== 'loading'; if (elError) elError.hidden = state !== 'error'; if (elEmpty) elEmpty.hidden = state !== 'empty'; if (elContent) elContent.hidden = state !== 'success'; } function h(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; } 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(); } function formatDateTime(isoString) { if (!isoString) return '—'; const d = new Date(isoString); if (isNaN(d.getTime())) return '—'; return d.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' }); } /** * Build a ticket table row: No (link) | Customer (link) | Description | Last activity */ function buildTicketRow(t) { const tr = h('tr', t.critical ? 'helpdesk-team-ticket-critical' : ''); // Ticket number as link const noCell = h('td', 'helpdesk-team-ticket-no'); if (ticketBaseUrl && t.no) { const a = h('a', '', t.no); a.href = ticketBaseUrl + encodeURIComponent(t.no); noCell.appendChild(a); } else { noCell.textContent = t.no || '—'; } tr.appendChild(noCell); // Customer name (link to debitor when customer_no available) const customerCell = h('td', 'helpdesk-team-ticket-customer'); const customerDisplay = t.customer_name || t.customer_no || '—'; if (debitorBaseUrl && t.customer_no) { const a = h('a', '', customerDisplay); a.href = debitorBaseUrl + encodeURIComponent(t.customer_no); customerCell.appendChild(a); } else { customerCell.textContent = customerDisplay; } tr.appendChild(customerCell); // Description (ellipsis) tr.appendChild(h('td', 'helpdesk-team-ticket-desc', t.description || '—')); // Category tr.appendChild(h('td', '', t.category || '—')); // Last activity date tr.appendChild(h('td', 'helpdesk-team-ticket-date', formatDateTime(t.last_activity))); return tr; } /** * Build a section widget for one agent — mirrors debitor dashboard section pattern. */ function buildAgentWidget(m, isQueue) { const section = h('section', 'helpdesk-team-widget'); // Section title: "MM Name (count)" with line — same as helpdesk-support-section-title const title = h('h3', 'helpdesk-support-section-title'); const name = isQueue ? labelQueue : m.display_name; const count = String(m.open_tickets); if (!isQueue) { const avatar = h('span', 'helpdesk-team-avatar-inline', initials(m.display_name)); title.appendChild(avatar); } title.appendChild(document.createTextNode(name)); title.appendChild(h('span', 'helpdesk-team-title-count', count)); // Trend delta: ↑3 (growing) or ↓2 (shrinking) const delta = m.trend_delta ?? 0; if (delta !== 0) { const arrow = delta > 0 ? '↑' : '↓'; const cls = delta > 0 ? 'helpdesk-team-trend-up' : 'helpdesk-team-trend-down'; const tooltip = delta > 0 ? labelTrendUp : labelTrendDown; const trendEl = h('span', cls, arrow + Math.abs(delta)); trendEl.setAttribute('data-tooltip', tooltip); trendEl.setAttribute('data-tooltip-pos', 'top'); trendEl.tabIndex = 0; title.appendChild(trendEl); } section.appendChild(title); // Ticket table const tickets = m.open_ticket_details || []; if (tickets.length > 0) { const table = h('table', 'helpdesk-team-ticket-table'); // Colgroup for consistent column widths across all agent cards const colgroup = document.createElement('colgroup'); colgroup.appendChild(h('col', 'helpdesk-team-col-ticket')); colgroup.appendChild(h('col', 'helpdesk-team-col-customer')); colgroup.appendChild(h('col', 'helpdesk-team-col-description')); colgroup.appendChild(h('col', 'helpdesk-team-col-category')); colgroup.appendChild(h('col', 'helpdesk-team-col-activity')); table.appendChild(colgroup); const thead = h('thead'); const headRow = h('tr'); headRow.appendChild(h('th', '', container.dataset.labelTicket || 'Ticket')); headRow.appendChild(h('th', '', container.dataset.labelCustomer || 'Customer')); headRow.appendChild(h('th', '', container.dataset.labelDescription || 'Description')); headRow.appendChild(h('th', '', container.dataset.labelCategory || 'Category')); headRow.appendChild(h('th', '', container.dataset.labelLastActivity || 'Last activity')); thead.appendChild(headRow); table.appendChild(thead); const tbody = h('tbody'); for (const t of tickets) tbody.appendChild(buildTicketRow(t)); table.appendChild(tbody); const scrollWrap = h('div', 'helpdesk-team-table-scroll'); scrollWrap.appendChild(table); section.appendChild(scrollWrap); } 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'; } /** * Compute Ø/Min/Max from an array of hour values. */ function computeResolution(hours) { const valid = hours.filter(h => h !== null && h !== undefined); if (valid.length === 0) return { avg: null, min: null, max: null }; return { avg: Math.round(valid.reduce((a, b) => a + b, 0) / valid.length), min: Math.min(...valid), max: Math.max(...valid), }; } /** * Build a clickable ranked list (top customers / top categories). * Clicking an item filters the resolution times and calls onFilter. */ function buildRankedList(items, label, filterKey, resolvedDetails, onFilter) { if (!items || items.length === 0) return null; 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'); let activeItem = null; for (const item of items) { const li = h('li', 'helpdesk-team-perf-ranked-clickable'); li.addEventListener('click', () => { if (activeItem === li) { activeItem.classList.remove('active'); activeItem = null; onFilter(null); } else { if (activeItem) activeItem.classList.remove('active'); li.classList.add('active'); activeItem = li; const filtered = resolvedDetails .filter(d => d[filterKey] === item.name) .map(d => d.resolution_hours); onFilter(computeResolution(filtered)); } }); 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); } wrap.appendChild(list); return wrap; } /** * Build a performance widget for one agent — card with insights. * Clicking a customer or category recalculates resolution times. */ function buildPerformanceWidget(m) { const section = h('section', 'helpdesk-team-widget'); const perf = m.performance || {}; const resolvedDetails = perf.resolved_details || []; // 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 column — will be updated on filter const timeStats = h('div', 'helpdesk-team-perf-stat'); timeStats.appendChild(h('span', 'helpdesk-team-perf-stat-label', container.dataset.labelResolutionTime || 'Resolution time')); const timeList = h('ol', 'helpdesk-team-perf-ranked helpdesk-team-perf-ranked-plain'); const avgLi = h('li'); avgLi.appendChild(h('span', 'helpdesk-team-perf-ranked-name', 'Ø')); const avgVal = h('span', 'helpdesk-team-perf-ranked-count', formatResolution(perf.avg_resolution_hours)); avgLi.appendChild(avgVal); timeList.appendChild(avgLi); const minLi = h('li'); minLi.appendChild(h('span', 'helpdesk-team-perf-ranked-name', 'Min')); const minVal = h('span', 'helpdesk-team-perf-ranked-count', formatResolution(perf.min_resolution_hours)); minLi.appendChild(minVal); timeList.appendChild(minLi); const maxLi = h('li'); maxLi.appendChild(h('span', 'helpdesk-team-perf-ranked-name', 'Max')); const maxVal = h('span', 'helpdesk-team-perf-ranked-count', formatResolution(perf.max_resolution_hours)); maxLi.appendChild(maxVal); timeList.appendChild(maxLi); timeStats.appendChild(timeList); // Callback: update resolution times when a filter is applied or cleared function updateResolution(filtered) { if (filtered === null) { avgVal.textContent = formatResolution(perf.avg_resolution_hours); minVal.textContent = formatResolution(perf.min_resolution_hours); maxVal.textContent = formatResolution(perf.max_resolution_hours); timeStats.classList.remove('helpdesk-team-perf-stat-filtered'); } else { avgVal.textContent = formatResolution(filtered.avg); minVal.textContent = formatResolution(filtered.min); maxVal.textContent = formatResolution(filtered.max); timeStats.classList.add('helpdesk-team-perf-stat-filtered'); } } // Top customers (clickable) const customersEl = buildRankedList( perf.top_customers, container.dataset.labelTopCustomers || 'Top customers', 'customer_name', resolvedDetails, updateResolution ); if (customersEl) grid.appendChild(customersEl); // Top categories (clickable) const categoriesEl = buildRankedList( perf.top_categories, container.dataset.labelTopCategories || 'Top categories', 'category', resolvedDetails, updateResolution ); if (categoriesEl) grid.appendChild(categoriesEl); // Resolution time last — updates when customer/category is clicked grid.appendChild(timeStats); section.appendChild(grid); return section; } function renderCurrentTab(members) { if (!elCurrentCards) return; elCurrentCards.replaceChildren(); const visible = members.filter(m => m.open_tickets > 0 || m.critical_tickets > 0); for (const m of visible) elCurrentCards.appendChild(buildAgentWidget(m, m.support_user === '')); // Legend const hasCritical = members.some(m => m.critical_tickets > 0); const hasTrend = visible.some(m => (m.trend_delta ?? 0) !== 0); if (hasCritical || hasTrend) { const legend = h('div', 'helpdesk-team-legend'); if (hasCritical) { const item = h('span', 'helpdesk-team-legend-item'); item.appendChild(h('span', 'helpdesk-team-legend-dot helpdesk-team-legend-dot-warning')); item.appendChild(h('span', '', labelCritical)); legend.appendChild(item); } if (hasTrend) { const hint = labelTrendHint.replace('{days}', String(currentPeriod)); legend.appendChild(h('span', 'helpdesk-team-legend-item', '↑↓ ' + hint)); } elCurrentCards.appendChild(legend); } } function renderPerformanceTab(members) { if (!elPerformanceCards) return; elPerformanceCards.replaceChildren(); const sorted = [...members] .filter(m => m.resolved_in_period > 0) .sort((a, b) => { if (a.support_user === '') return 1; if (b.support_user === '') return -1; return b.resolved_in_period - a.resolved_in_period; }); for (const m of sorted) elPerformanceCards.appendChild(buildPerformanceWidget(m)); } async function fetchData(refresh = false) { showState('loading'); const params = new URLSearchParams({ periodDays: String(currentPeriod) }); if (refresh) params.set('refresh', '1'); try { 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; } renderCurrentTab(json.members); renderPerformanceTab(json.members); showState('success'); } catch { showState('error'); } } if (periodSelector) { periodSelector.addEventListener('change', (e) => { const radio = e.target.closest('input[name="team-period"]'); if (!radio) return; const period = parseInt(radio.value, 10); if (!period || period === currentPeriod) return; currentPeriod = period; fetchData(); }); } if (elRefresh) { elRefresh.addEventListener('click', () => fetchData(true)); } fetchData(); }