/** * 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 searchInput = document.getElementById('radar-search'); 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('.app-dialog-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 setText(id, value) { const el = document.getElementById(id); if (el) el.textContent = value; } 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'), 'resolution': d('labelResolution', 'Resolution time'), 'inactivity': d('labelInactivity', 'Inactivity'), }; return map[id] || id; } /** * Build a single customer risk card. */ function buildCard(customer) { const card = h('article', 'helpdesk-risk-card ' + levelClass(customer.risk_level)); card.setAttribute('aria-label', customer.customer_name || customer.customer_no); // Header: name + score badge const header = h('div', 'helpdesk-risk-card-header'); const nameEl = h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no); const scoreBadge = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score)); scoreBadge.setAttribute('aria-label', d('labelScore', 'Risk score') + ': ' + customer.risk_score); header.appendChild(nameEl); header.appendChild(scoreBadge); card.appendChild(header); // Core metrics row const metrics = h('div', 'helpdesk-risk-card-metrics'); const kpis = customer.kpis || {}; if (kpis.sla_overdue > 0) { const m = h('span', 'helpdesk-risk-card-metric helpdesk-risk-card-metric-warn'); m.textContent = kpis.sla_overdue + ' ' + d('labelOverdue', 'overdue'); metrics.appendChild(m); } if (kpis.critical > 0) { const m = h('span', 'helpdesk-risk-card-metric helpdesk-risk-card-metric-warn'); m.textContent = kpis.critical + ' ' + d('labelCritical', 'critical'); metrics.appendChild(m); } const openM = h('span', 'helpdesk-risk-card-metric'); openM.textContent = kpis.open + ' ' + d('labelOpen', 'open'); metrics.appendChild(openM); if (kpis.net_flow !== 0) { const flowM = h('span', 'helpdesk-risk-card-metric'); flowM.textContent = (kpis.net_flow > 0 ? '+' : '') + kpis.net_flow + ' ' + d('labelNet', 'net'); if (kpis.net_flow > 0) flowM.classList.add('helpdesk-risk-card-metric-warn'); metrics.appendChild(flowM); } card.appendChild(metrics); // Top drivers const drivers = customer.drivers || []; if (drivers.length > 0) { const driverSection = h('div', 'helpdesk-risk-card-drivers'); for (const dr of drivers) { if (dr.dimension_score === null || dr.weighted_points <= 0) continue; const row = h('div', 'helpdesk-risk-card-driver'); row.appendChild(h('span', 'helpdesk-risk-card-driver-label', dimLabel(dr.id))); const bar = h('div', 'helpdesk-risk-card-driver-bar'); const fill = h('div', 'helpdesk-risk-card-driver-fill'); fill.style.width = Math.min(100, dr.dimension_score) + '%'; bar.appendChild(fill); row.appendChild(bar); driverSection.appendChild(row); } card.appendChild(driverSection); } // Click/keyboard to open detail card.tabIndex = 0; card.setAttribute('role', 'button'); 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(); // All 5 dimensions const dims = customer.dimensions || []; const dimSection = h('div', 'helpdesk-risk-detail-dimensions'); for (const dim of dims) { const row = h('div', 'helpdesk-risk-detail-dim'); row.appendChild(h('span', 'helpdesk-risk-detail-dim-label', dimLabel(dim.id))); const bar = h('div', 'helpdesk-risk-card-driver-bar'); const fill = h('div', 'helpdesk-risk-card-driver-fill'); fill.style.width = (dim.dimension_score !== null ? Math.min(100, dim.dimension_score) : 0) + '%'; bar.appendChild(fill); row.appendChild(bar); const val = h('span', 'helpdesk-risk-detail-dim-value'); val.textContent = dim.dimension_score !== null ? dim.dimension_score + '/100' : '—'; row.appendChild(val); dimSection.appendChild(row); } dialogBody.appendChild(dimSection); // 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 renderKpis(summary) { setText('radar-kpi-total', summary.total ?? 0); setText('radar-kpi-high', summary.high ?? 0); setText('radar-kpi-medium', summary.medium ?? 0); setText('radar-kpi-low', summary.low ?? 0); } function renderCards(customers) { if (!elCards) return; elCards.replaceChildren(); for (const c of customers) elCards.appendChild(buildCard(c)); } function filterCards(query) { if (!lastData) return; const q = query.toLowerCase().trim(); const filtered = q === '' ? lastData.customers : lastData.customers.filter(c => (c.customer_name || '').toLowerCase().includes(q) || c.customer_no.toLowerCase().includes(q)); renderCards(filtered); } 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; renderKpis(json.summary); 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 (searchInput) { searchInput.addEventListener('input', () => filterCards(searchInput.value)); } if (elRefresh) { elRefresh.addEventListener('click', () => fetchData(true)); } fetchData(); }