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>
233 lines
8.6 KiB
JavaScript
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();
|
|
}
|