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) <noreply@anthropic.com>
198 lines
6.9 KiB
JavaScript
198 lines
6.9 KiB
JavaScript
/**
|
|
* 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 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 = 90;
|
|
|
|
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';
|
|
if (elLoading) elLoading.setAttribute('aria-busy', state === 'loading' ? 'true' : 'false');
|
|
}
|
|
|
|
function el(tag, className, text) {
|
|
const e = document.createElement(tag);
|
|
if (className) e.className = className;
|
|
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';
|
|
}
|
|
|
|
/**
|
|
* Build a single agent card for the Current tab.
|
|
*/
|
|
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 card = el('article', 'helpdesk-team-card');
|
|
if (isQueue) card.classList.add('helpdesk-team-card-queue');
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// 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');
|
|
|
|
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 card;
|
|
}
|
|
|
|
/**
|
|
* Build a single agent card for the Performance tab.
|
|
*/
|
|
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(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));
|
|
|
|
// 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));
|
|
for (const m of agents) elCurrentCards.appendChild(buildAgentCard(m, maxOpen));
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
const maxResolved = Math.max(1, ...sorted.map(m => m.resolved_in_period));
|
|
for (const m of sorted) elPerformanceCards.appendChild(buildPerformanceCard(m, maxResolved));
|
|
}
|
|
|
|
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();
|
|
}
|