/** * Risk Radar — customer portfolio risk view. */ const container = document.querySelector('.app-details-container[data-risk-radar-url]'); if (container) { const dataUrl = container.dataset.riskRadarUrl; const debitorBaseUrl = container.dataset.debitorBaseUrl || ''; const d = (key, fallback) => container.dataset[key] || fallback; const elLoading = document.getElementById('radar-loading'); const elError = document.getElementById('radar-error'); const elEmpty = document.getElementById('radar-empty'); const elTruncated = document.getElementById('radar-truncated'); const elContent = document.getElementById('radar-content'); const elCards = document.getElementById('radar-cards'); const elRefresh = container.querySelector('button[name="radar-refresh"]'); const periodSelector = document.getElementById('radar-period-selector'); const dialog = document.getElementById('radar-detail-dialog'); const dialogTitle = document.getElementById('radar-detail-title'); const dialogBody = document.getElementById('radar-detail-body'); const dialogClose = dialog ? dialog.querySelector('.close') : null; let currentPeriod = 90; let lastData = null; 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 levelClass(level) { return 'helpdesk-risk-level-' + level; } function formatAge(hours) { if (hours < 24) return hours + 'h'; return Math.floor(hours / 24) + 'd'; } /** * Dimension label lookup from data attributes. */ function dimLabel(id) { const map = { 'open_pressure': d('labelOpenPressure', 'Open pressure'), 'trend': d('labelTrend', 'Trend'), 'sla_overdue': d('labelSla', 'SLA overdue'), 'inactivity': d('labelInactivity', 'Inactivity'), }; return map[id] || id; } /** * Build a single customer risk card — accent border + score pill. */ function buildCard(customer) { const card = h('article', 'helpdesk-risk-card ' + levelClass(customer.risk_level)); card.setAttribute('aria-label', customer.customer_name || customer.customer_no); // Row 1: Name + Level on left, score pill on right const header = h('div', 'helpdesk-risk-card-header'); const info = h('div', 'helpdesk-risk-card-info'); info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no)); const levelLabels = { high: d('labelHigh', 'High risk'), medium: d('labelMedium', 'Medium risk'), low: d('labelLow', 'Low risk') }; info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || '')); header.appendChild(info); const scoreEl = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score)); header.appendChild(scoreEl); card.appendChild(header); // Row 2: One-line key facts separated by · const kpis = customer.kpis || {}; const parts = []; if (kpis.open > 0) parts.push(kpis.open + ' ' + d('labelOpen', 'open')); if (kpis.critical > 0) parts.push(kpis.critical + ' ' + d('labelCritical', 'critical')); if (kpis.sla_overdue > 0) parts.push(kpis.sla_overdue + ' SLA'); if (kpis.net_flow > 0) parts.push('↑' + kpis.net_flow); else if (kpis.net_flow < 0) parts.push('↓' + Math.abs(kpis.net_flow)); if (parts.length > 0) { card.appendChild(h('p', 'helpdesk-risk-card-facts', parts.join(' · '))); } // Click/keyboard to open detail card.tabIndex = 0; card.addEventListener('click', () => openDetail(customer)); card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(customer); } }); return card; } /** * Open detail dialog for a customer. */ function openDetail(customer) { if (!dialog || !dialogTitle || !dialogBody) return; dialogTitle.textContent = (customer.customer_name || customer.customer_no) + ' — ' + customer.risk_score + '/100'; dialogBody.replaceChildren(); // Dimensions as points breakdown: "18 / 35" const dims = customer.dimensions || []; const dimTable = h('table', 'helpdesk-risk-detail-tickets'); const dimHead = h('thead'); const dimHeadRow = h('tr'); dimHeadRow.appendChild(h('th', '', 'Dimension')); dimHeadRow.appendChild(h('th', '', d('labelScore', 'Score'))); dimHead.appendChild(dimHeadRow); dimTable.appendChild(dimHead); const dimBody = h('tbody'); for (const dim of dims) { const tr = h('tr'); tr.appendChild(h('td', '', dimLabel(dim.id))); const points = Math.round(dim.weighted_points || 0); tr.appendChild(h('td', '', points + ' / ' + dim.weight)); dimBody.appendChild(tr); } const totalTr = h('tr'); const totalLabel = h('td', ''); totalLabel.style.fontWeight = '700'; totalLabel.textContent = 'Total'; totalTr.appendChild(totalLabel); const totalVal = h('td', ''); totalVal.style.fontWeight = '700'; totalVal.textContent = customer.risk_score + ' / 100'; totalTr.appendChild(totalVal); dimBody.appendChild(totalTr); dimTable.appendChild(dimBody); dialogBody.appendChild(dimTable); // Open tickets list const tickets = customer.open_tickets || []; if (tickets.length > 0) { dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', d('labelTickets', 'Tickets') + ' (' + tickets.length + ')')); const table = h('table', 'helpdesk-risk-detail-tickets'); const thead = h('thead'); const headRow = h('tr'); headRow.appendChild(h('th', '', d('labelTicket', 'Ticket'))); headRow.appendChild(h('th', '', d('labelSla', 'SLA'))); headRow.appendChild(h('th', '', d('labelStatus', 'Status'))); headRow.appendChild(h('th', '', d('labelAge', 'Age'))); thead.appendChild(headRow); table.appendChild(thead); const tbody = h('tbody'); for (const t of tickets) { const tr = h('tr', t.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : ''); tr.appendChild(h('td', '', t.no)); tr.appendChild(h('td', '', t.escalation_code || '—')); tr.appendChild(h('td', '', t.state)); tr.appendChild(h('td', '', formatAge(t.age_hours))); tbody.appendChild(tr); } table.appendChild(tbody); dialogBody.appendChild(table); } // Link to debitor if (debitorBaseUrl && customer.customer_no) { const link = h('a', 'helpdesk-risk-detail-link', customer.customer_name || customer.customer_no); link.href = debitorBaseUrl + encodeURIComponent(customer.customer_no); link.target = '_blank'; link.rel = 'noopener noreferrer'; dialogBody.appendChild(link); } dialog.showModal(); } // Dialog close handlers if (dialogClose) dialogClose.addEventListener('click', () => dialog.close()); if (dialog) dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }); function renderCards(customers) { if (!elCards) return; elCards.replaceChildren(); for (const c of customers) elCards.appendChild(buildCard(c)); } async function fetchData(refresh = false) { showState('loading'); if (elTruncated) elTruncated.hidden = true; 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.customers || json.customers.length === 0) { showState('empty'); return; } lastData = json; renderCards(json.customers); showState('success'); if (json.meta && json.meta.truncated && elTruncated) { elTruncated.hidden = false; } } catch { showState('error'); } } if (periodSelector) { periodSelector.addEventListener('change', (e) => { const radio = e.target.closest('input[name="radar-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(); }