feat(helpdesk): add dashboards, communication feed, settings UI and fix routing

Add support/sales/controlling dashboards with KPIs, trend charts and
risk indicators. Add debitor communication timeline, contact filters,
system recommendations engine, and configurable controlling risk rules.

Rename search→index to fix query-string preservation on back navigation.
Remove fake escalation rate metric, add KPI info tooltips, and switch
trend chart colors to red (created) / green (closed) for clarity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 18:34:03 +02:00
parent e897cc2c56
commit aee9cb10f3
43 changed files with 7451 additions and 635 deletions

View File

@@ -1,3 +1,5 @@
import { renderEmptyState } from './helpdesk-empty-state.js';
function esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
@@ -72,9 +74,12 @@ export function renderCommunicationFeed(entries, options = {}) {
const list = Array.isArray(entries) ? entries : [];
if (list.length === 0) {
return `<div class="app-empty-state app-empty-state-compact">
<p class="app-empty-state-message">${esc(t(emptyMessage))}</p>
</div>`;
return renderEmptyState({
message: t(emptyMessage),
size: 'compact',
align: 'center',
icon: false,
});
}
let html = '<ol class="helpdesk-comm-feed" role="list">';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
function esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML;
}
const EMPTY_ICON_SVG = `
<svg class="app-empty-state-icon" xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline>
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
</svg>
`;
export function renderEmptyState(options = {}) {
const message = String(options.message || '').trim();
if (!message) {
return '';
}
const hint = String(options.hint || '').trim();
const sizeRaw = String(options.size || 'compact').trim().toLowerCase();
const size = ['default', 'compact', 'small'].includes(sizeRaw) ? sizeRaw : 'compact';
const alignRaw = String(options.align || 'center').trim().toLowerCase();
const align = ['center', 'left'].includes(alignRaw) ? alignRaw : 'center';
const icon = options.icon === true;
return `<div class="app-empty-state" data-size="${esc(size)}" data-align="${esc(align)}">
${icon ? EMPTY_ICON_SVG : ''}
<p class="app-empty-state-message">${esc(message)}</p>
${hint ? `<small class="app-empty-state-hint">${esc(hint)}</small>` : ''}
</div>`;
}

View File

@@ -3,6 +3,16 @@
*/
import { showAsyncFlash } from '/js/components/app-async-flash.js';
const settingsForm = document.getElementById('helpdesk-settings-form');
const getMessage = (key, fallback) => {
if (!settingsForm || !settingsForm.dataset) {
return fallback;
}
return settingsForm.dataset[key] || fallback;
};
// Toggle OAuth2 vs Basic Auth field visibility
const authModeSelect = document.getElementById('auth_mode');
const basicFields = document.getElementById('helpdesk-basic-auth-fields');
@@ -12,9 +22,10 @@ const toggleAuthFields = () => {
if (!authModeSelect || !basicFields || !oauth2Fields) {
return;
}
const isOAuth2 = authModeSelect.value === 'oauth2';
basicFields.style.display = isOAuth2 ? 'none' : '';
oauth2Fields.style.display = isOAuth2 ? '' : 'none';
basicFields.toggleAttribute('hidden', isOAuth2);
oauth2Fields.toggleAttribute('hidden', !isOAuth2);
};
if (authModeSelect) {
@@ -24,11 +35,43 @@ if (authModeSelect) {
// Connection test button
const testButton = document.getElementById('helpdesk-test-connection-button');
const testResult = document.getElementById('helpdesk-connection-test-result');
const setTestResult = (variant, message) => {
if (!testResult) {
return;
}
testResult.textContent = message;
testResult.dataset.variant = variant;
testResult.hidden = !message;
};
const setButtonLoadingState = (isLoading) => {
if (!testButton) {
return;
}
const defaultLabel = testButton.dataset.labelDefault || getMessage('testDefaultLabel', 'Test connection');
const loadingLabel = testButton.dataset.labelLoading || getMessage('testLoadingLabel', 'Testing...');
testButton.disabled = isLoading;
testButton.toggleAttribute('aria-busy', isLoading);
testButton.textContent = isLoading ? loadingLabel : defaultLabel;
};
const parseJsonSafely = async (response) => {
try {
return await response.json();
} catch {
return null;
}
};
if (testButton) {
testButton.addEventListener('click', async () => {
testButton.disabled = true;
testButton.setAttribute('aria-busy', 'true');
setButtonLoadingState(true);
setTestResult('info', getMessage('testLoadingLabel', 'Testing...'));
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
@@ -49,22 +92,35 @@ if (testButton) {
},
);
const data = await response.json();
const data = await parseJsonSafely(response);
const successMessage = getMessage('testSuccessMessage', 'Connection successful');
const defaultErrorMessage = getMessage('testErrorMessage', 'Connection failed');
if (data.ok) {
showAsyncFlash('success', data.message || 'Connection successful');
if (response.ok && data?.ok) {
const message = data.message || successMessage;
setTestResult('success', message);
showAsyncFlash('success', message);
} else {
let msg = data.error || 'Connection failed';
if (data.debug?.http_code) {
msg += ' (HTTP ' + data.debug.http_code + ')';
let message = defaultErrorMessage;
if (data?.error) {
message = data.error;
} else if (!response.ok) {
message = `${defaultErrorMessage} (HTTP ${response.status})`;
}
showAsyncFlash('error', msg);
if (data?.debug?.http_code) {
message += ` (HTTP ${data.debug.http_code})`;
}
setTestResult('warning', message);
showAsyncFlash('error', message);
}
} catch {
showAsyncFlash('error', 'Connection failed');
const message = getMessage('testErrorMessage', 'Connection failed');
setTestResult('warning', message);
showAsyncFlash('error', message);
} finally {
testButton.disabled = false;
testButton.removeAttribute('aria-busy');
setButtonLoadingState(false);
}
});
}

View File

@@ -9,6 +9,7 @@ import {
renderCommunicationFeed,
renderCommunicationHint,
} from './helpdesk-communication.js';
import { renderEmptyState } from './helpdesk-empty-state.js';
const container = document.querySelector('.app-details-container[data-ticket-no]');
if (container) {
@@ -95,9 +96,12 @@ function init(container) {
function renderTimeline(entries) {
if (entries.length === 0) {
return `<div class="app-empty-state app-empty-state-compact">
<p class="app-empty-state-message">${esc(t('No activity found.'))}</p>
</div>`;
return renderEmptyState({
message: t('No activity found.'),
size: 'compact',
align: 'center',
icon: false,
});
}
let html = '<div class="helpdesk-timeline">';