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>
This commit is contained in:
2026-04-06 10:29:59 +02:00
parent 97dbc7f57b
commit ea786f5341
27 changed files with 1150 additions and 109 deletions

View File

@@ -16,7 +16,6 @@ if (container) {
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');
@@ -32,11 +31,6 @@ if (container) {
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;
@@ -68,21 +62,21 @@ if (container) {
}
/**
* Build a single customer risk card — Stripe-style: score dominant, one-line facts.
* 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: Score (large, colored) + Name + Level
// Row 1: Name + Level on left, score pill on right
const header = h('div', 'helpdesk-risk-card-header');
const scoreEl = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score));
header.appendChild(scoreEl);
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 ' + levelClass(customer.risk_level), levelLabels[customer.risk_level] || ''));
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 ·
@@ -187,28 +181,12 @@ if (container) {
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;
@@ -224,7 +202,6 @@ if (container) {
if (!json.customers || json.customers.length === 0) { showState('empty'); return; }
lastData = json;
renderKpis(json.summary);
renderCards(json.customers);
showState('success');
@@ -247,10 +224,6 @@ if (container) {
});
}
if (searchInput) {
searchInput.addEventListener('input', () => filterCards(searchInput.value));
}
if (elRefresh) {
elRefresh.addEventListener('click', () => fetchData(true));
}

View File

@@ -68,6 +68,38 @@ const parseJsonSafely = async (response) => {
}
};
// Tenant override toggle — show/hide tenant fields + set save_target
const overrideToggle = document.getElementById('helpdesk-override-toggle');
const tenantFields = document.getElementById('helpdesk-tenant-fields');
const saveTargetInput = document.getElementById('helpdesk-settings-save-target');
const tenantAuthModeSelect = document.getElementById('tenant_auth_mode');
const tenantBasicFields = document.getElementById('helpdesk-tenant-basic-auth-fields');
const tenantOauth2Fields = document.getElementById('helpdesk-tenant-oauth2-fields');
if (overrideToggle && tenantFields) {
overrideToggle.addEventListener('change', () => {
tenantFields.hidden = !overrideToggle.checked;
});
}
// On form submit, set save_target based on active tab
if (settingsForm && saveTargetInput) {
settingsForm.addEventListener('submit', () => {
const tenantPanel = settingsForm.querySelector('[data-tab-panel="tenant"]:not([hidden])');
saveTargetInput.value = tenantPanel ? 'tenant' : 'global';
});
}
// Tenant auth mode toggle
if (tenantAuthModeSelect && tenantBasicFields && tenantOauth2Fields) {
tenantAuthModeSelect.addEventListener('change', () => {
const isOAuth2 = tenantAuthModeSelect.value === 'oauth2';
tenantBasicFields.hidden = isOAuth2;
tenantOauth2Fields.hidden = !isOAuth2;
});
}
if (testButton) {
testButton.addEventListener('click', async () => {
setButtonLoadingState(true);