Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-risk-radar.js
fs ea786f5341 feat(helpdesk): add multi-tenant BC connection settings with risk radar refinements
Introduce per-tenant override for helpdesk BC connection config.
New table helpdesk_tenant_settings stores tenant-specific credentials
with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService
resolves global vs tenant config per request. Settings UI extended with
tenant override tab, toggle, and dual editing mode.

All runtime consumers (OData, SOAP, OAuth) read through the new
resolver. Token cache flushed on any config change. Default behavior
unchanged — tenants use global config until override explicitly enabled.

Also includes risk radar UI refinements: Stripe-style card redesign
with accent borders and score pills, removal of redundant KPI tiles
and search, and filtering of zero-score entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:29:59 +02:00

233 lines
8.6 KiB
JavaScript

/**
* 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'),
'resolution': d('labelResolution', 'Resolution time'),
'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();
}