Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-detail.js
fs aee9cb10f3 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>
2026-04-04 18:34:03 +02:00

1509 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Helpdesk debtor detail page — role-based dashboard rendering.
*
* - Support tab: health tiles, system recommendations, contracts/products widget, tickets grid
* - Sales tab: contact table (lazy-loaded)
* - Controlling tab: placeholder
* - Aside: cross-ticket communication feed
*/
import { initStandardListPage } from '/js/core/app-grid-factory.js';
import { readPageConfig } from '/js/core/app-page-config.js';
import {
renderCommunicationError,
renderCommunicationFeed,
renderCommunicationHint,
} from './helpdesk-communication.js';
import { renderEmptyState } from './helpdesk-empty-state.js';
const container = document.querySelector('.app-details-container[data-customer-no]');
if (container) {
init(container);
}
function init(container) {
const customerNo = container.dataset.customerNo || '';
const customerName = container.dataset.customerName || '';
const supportDashboardUrl = container.dataset.supportDashboardUrl || '';
const contactsUrl = container.dataset.contactsUrl || '';
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
const communicationUrl = container.dataset.communicationUrl || '';
const salesDashboardUrl = container.dataset.salesDashboardUrl || '';
const refreshFromUrl = (() => {
const value = new URLSearchParams(window.location.search).get('refresh');
if (!value) return false;
const normalized = String(value).trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
})();
if (!customerNo || !customerName) return;
const i18nEl = document.getElementById('helpdesk-i18n');
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
const t = (key) => i18n[key] || key;
let supportDashboardPromise = null;
let salesDashboardPromise = null;
let contactsPromise = null;
let ticketCategoriesPromise = null;
let debitorCommunicationPromise = null;
let supportTabRendered = false;
let salesTabRendered = false;
let ticketsGridInitialized = false;
let ticketsGridInitializing = false;
function buildBaseParams() {
const params = new URLSearchParams({ customerNo, customerName });
if (refreshFromUrl) {
params.set('refresh', '1');
}
return params;
}
function fetchSupportDashboard() {
if (!supportDashboardPromise) {
if (!supportDashboardUrl) {
supportDashboardPromise = Promise.resolve({ ok: false, error: 'No support dashboard endpoint configured' });
} else {
const params = buildBaseParams();
supportDashboardPromise = fetch(supportDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return supportDashboardPromise;
}
function fetchSalesDashboard() {
if (!salesDashboardPromise) {
if (!salesDashboardUrl) {
salesDashboardPromise = Promise.resolve({ ok: false, error: 'No sales dashboard endpoint configured' });
} else {
const params = buildBaseParams();
salesDashboardPromise = fetch(salesDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return salesDashboardPromise;
}
function fetchContacts() {
if (!contactsPromise) {
const params = buildBaseParams();
contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
return contactsPromise;
}
function fetchTicketCategories(config, appBase) {
if (!ticketCategoriesPromise) {
const categoryOptionsUrl = config.categoryOptionsUrl || '';
if (!categoryOptionsUrl) {
ticketCategoriesPromise = Promise.resolve({ ok: false, categories: [] });
return ticketCategoriesPromise;
}
const url = new URL(categoryOptionsUrl, appBase);
url.searchParams.set('customerNo', config.customerNo || customerNo);
url.searchParams.set('customerName', config.customerName || customerName);
if (refreshFromUrl) {
url.searchParams.set('refresh', '1');
}
ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, categories: [] }));
}
return ticketCategoriesPromise;
}
function fetchDebitorCommunication() {
if (!debitorCommunicationPromise) {
if (!communicationUrl) {
debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
} else {
const params = buildBaseParams();
debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return debitorCommunicationPromise;
}
function hydrateTicketCategoryFilter(config, categories) {
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
if (!(categoryFilter instanceof HTMLSelectElement)) return;
const uniqueCategories = Array.from(new Set(
(Array.isArray(categories) ? categories : [])
.map(item => String(item || '').trim())
.filter(Boolean),
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
const currentValue = String(categoryFilter.value || '');
categoryFilter.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = t('All');
categoryFilter.appendChild(allOption);
const categoryOptionMap = {};
for (const category of uniqueCategories) {
const option = document.createElement('option');
option.value = category;
option.textContent = category;
categoryFilter.appendChild(option);
categoryOptionMap[category] = category;
}
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) {
categoryFilter.value = currentValue;
} else {
categoryFilter.value = '';
}
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
config.filterChipMeta = {};
}
const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object')
? config.filterChipMeta.category
: {};
config.filterChipMeta.category = {
...currentCategoryMeta,
type: 'select',
default: '',
options: categoryOptionMap,
};
}
function esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function ticketUrl(ticketNo) {
return ticketBaseUrl + encodeURIComponent(ticketNo) + '?from=' + encodeURIComponent(customerNo);
}
function errorNotice(message) {
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
}
let contactsGridInitialized = false;
let contactsGridInitializing = false;
async function initContactsGrid() {
if (contactsGridInitialized || contactsGridInitializing) return;
contactsGridInitializing = true;
const contactsConfig = readPageConfig('helpdesk-contacts');
if (!contactsConfig) {
contactsGridInitializing = false;
return;
}
const labels = contactsConfig.labels || {};
const gridjsLib = window.gridjs;
if (!gridjsLib) {
contactsGridInitializing = false;
return;
}
const baseEl = document.querySelector('base');
const appBase = (baseEl && baseEl.href) ? baseEl.href : '/';
const gridDataUrl = new URL(contactsConfig.dataUrl || '', appBase);
gridDataUrl.searchParams.set('customerNo', contactsConfig.customerNo || customerNo);
gridDataUrl.searchParams.set('customerName', contactsConfig.customerName || customerName);
if (refreshFromUrl) {
gridDataUrl.searchParams.set('refresh', '1');
}
try {
// Fetch type options from first data response
const typeOptionsUrl = new URL(gridDataUrl.toString());
typeOptionsUrl.searchParams.set('limit', '1');
const typeResp = await fetch(typeOptionsUrl.toString()).then((r) => r.json()).catch(() => null);
const typeOptions = Array.isArray(typeResp?.type_options) ? typeResp.type_options : [];
hydrateContactTypeFilter(contactsConfig, typeOptions);
const gridOptions = {
gridjs: gridjsLib,
container: '#helpdesk-contacts-grid',
dataUrl: gridDataUrl.toString(),
appBase,
columns: [
{ name: labels.name || 'Name', sort: true },
{ name: labels.type || 'Type', sort: true },
{ name: labels.jobTitle || 'Job title', sort: true },
{ name: labels.email || 'E-Mail', sort: true },
{ name: labels.phone || 'Phone', sort: true },
{ name: labels.mobile || 'Mobile', sort: false },
],
sortColumns: ['Name', 'Type', 'Job_Title', 'E_Mail', 'Phone_No', null],
paginationLimit: 10,
language: contactsConfig.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
row.Name || '',
row.Type || '',
row.Job_Title || '',
row.E_Mail || '',
row.Phone_No || '',
row.Mobile_Phone_No || '',
]),
search: contactsConfig.gridSearch || null,
filterSchema: contactsConfig.filterSchema || [],
urlSync: false,
actions: { enabled: false },
};
// Scope filter UI to the contacts section to avoid collision with tickets filter
const contactsSection = document.getElementById('sales-contacts-section');
const contactsChipsEl = contactsSection
? contactsSection.querySelector('[data-active-filter-chips]')
: null;
const contactsLiveEl = contactsSection
? contactsSection.querySelector('[data-filter-live-region]')
: null;
initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: contactsConfig.filterChipMeta || {},
watchInputs: ['#helpdesk-contact-search'],
drawerRoot: contactsSection || undefined,
chips: contactsChipsEl ? { container: contactsChipsEl } : {},
live: contactsLiveEl ? { regionSelector: null } : {},
},
});
contactsGridInitialized = true;
} finally {
contactsGridInitializing = false;
}
}
function hydrateContactTypeFilter(contactsConfig, typeOptions) {
const select = document.getElementById('helpdesk-contact-type-filter');
if (!(select instanceof HTMLSelectElement) || !Array.isArray(typeOptions)) return;
const currentValue = String(select.value || '');
select.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = t('All');
select.appendChild(allOption);
const typeOptionMap = {};
for (const opt of typeOptions) {
const id = String(opt.id || '').trim();
const label = String(opt.description || opt.id || '').trim();
if (id === '') continue;
const option = document.createElement('option');
option.value = id;
option.textContent = label;
select.appendChild(option);
typeOptionMap[id] = label;
}
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(typeOptionMap, currentValue)) {
select.value = currentValue;
} else {
select.value = '';
}
if (!contactsConfig.filterChipMeta || typeof contactsConfig.filterChipMeta !== 'object') {
contactsConfig.filterChipMeta = {};
}
const currentTypeMeta = (contactsConfig.filterChipMeta.type && typeof contactsConfig.filterChipMeta.type === 'object')
? contactsConfig.filterChipMeta.type
: {};
contactsConfig.filterChipMeta.type = {
...currentTypeMeta,
type: 'select',
default: '',
options: typeOptionMap,
};
}
function formatTemplate(template, params) {
const tpl = String(template || '');
const map = (params && typeof params === 'object') ? params : {};
return tpl.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => {
const value = map[key];
if (value === null || value === undefined) {
return '';
}
return String(value);
});
}
function renderSystemRecommendations(recommendations) {
const entries = Array.isArray(recommendations) ? recommendations : [];
if (entries.length === 0) {
return renderEmptyState({
message: t('No system recommendations right now.'),
size: 'compact',
align: 'center',
icon: false,
});
}
const formatRecommendationMessage = (entry) => {
const messageKey = String(entry.message_key || '');
const template = t(messageKey);
const params = (entry.message_params && typeof entry.message_params === 'object')
? entry.message_params
: {};
if (template && template !== messageKey) {
return formatTemplate(template, params);
}
return messageKey || String(entry.rule_id || '—');
};
let html = '<table><thead><tr>';
html += `<th>${esc(t('Ticket No.'))}</th>`;
html += `<th>${esc(t('Action'))}</th>`;
html += `<th>${esc(t('Age (h)'))}</th>`;
html += '</tr></thead><tbody>';
for (const entry of entries) {
const ticketNo = String(entry.ticket_no || '');
const href = ticketNo ? ticketUrl(ticketNo) : '';
const ageHours = Number.isFinite(Number(entry.age_hours)) ? String(entry.age_hours) : '—';
const rowAttrs = href ? ` class="app-clickable-row" data-href="${esc(href)}" tabindex="0"` : '';
html += `<tr${rowAttrs}>`;
html += ticketNo && href
? `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`
: `<td>${esc(ticketNo || '—')}</td>`;
html += `<td>${esc(formatRecommendationMessage(entry))}</td>`;
html += `<td>${esc(ageHours)}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function renderSupportContracts(entries) {
const contracts = Array.isArray(entries) ? entries : [];
if (contracts.length === 0) {
return renderEmptyState({
message: t('No contracts found for this customer.'),
size: 'compact',
align: 'center',
icon: false,
});
}
const formatDate = (value) => {
const raw = String(value || '').trim();
if (!raw || raw.startsWith('0001-01-01')) return '—';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleDateString();
};
const amountFormatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
});
let html = '<table><thead><tr>';
html += `<th>${esc(t('Contract No.'))}</th>`;
html += `<th>${esc(t('Product type'))}</th>`;
html += `<th>${esc(t('State'))}</th>`;
html += `<th>${esc(t('Next invoicing'))}</th>`;
html += `<th>${esc(t('Amount'))}</th>`;
html += '</tr></thead><tbody>';
for (const contract of contracts.slice(0, 20)) {
const amount = Number(contract.total_payoff_amount ?? 0);
html += '<tr>';
html += `<td>${esc(contract.contract_no || '')}</td>`;
html += `<td>${esc(contract.product_type || '—')}</td>`;
html += `<td>${esc(contract.state || '—')}</td>`;
html += `<td>${esc(formatDate(contract.next_invoicing_date))}</td>`;
html += `<td>${esc(amountFormatter.format(Number.isFinite(amount) ? amount : 0))}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function contractTypeLabel(productType) {
return String(productType || '').trim() || '—';
}
const ACCENT_COUNT = 8;
const accentTypeMap = new Map();
function contractAccentClass(productType) {
const key = String(productType || '').trim().toUpperCase();
if (key === '') return 'accent-0';
if (!accentTypeMap.has(key)) {
accentTypeMap.set(key, accentTypeMap.size % ACCENT_COUNT);
}
return 'accent-' + accentTypeMap.get(key);
}
function contractIcon(productType) {
const type = String(productType || '').trim().toLowerCase();
if (type === 'wa') return 'bi-tools';
if (type === 'fb') return 'bi-cash-stack';
if (type === 'lz') return 'bi-key';
if (type === 'ho') return 'bi-cloud';
if (type === 'su') return 'bi-headset';
if (type === 'sw') return 'bi-code-square';
return 'bi-file-earmark-text';
}
// Shared formatters for contract rendering
const contractAmountFmt = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
});
const contractAmountFmtShort = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
function contractFormatDate(value) {
const raw = String(value || '').trim();
if (!raw || raw.startsWith('0001-01-01')) return '—';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleDateString();
}
function contractStateClass(state) {
const s = String(state || '').toLowerCase();
if (s === 'aktiv') return 'state-active';
if (s === 'vorbereitung') return 'state-prep';
return 'state-ended';
}
function contractStateVariant(state) {
const s = String(state || '').toLowerCase();
if (s === 'aktiv') return 'success';
if (s === 'vorbereitung') return 'warning';
return 'neutral';
}
/** Store all contracts for dialog access */
let salesContracts = [];
function renderContractCards(contracts) {
if (!Array.isArray(contracts) || contracts.length === 0) {
return renderEmptyState({
message: t('No contract details available.'),
size: 'compact',
align: 'center',
icon: false,
});
}
salesContracts = contracts;
let html = '<div class="helpdesk-contract-cards">';
for (let i = 0; i < contracts.length; i++) {
const contract = contracts[i];
const accent = contractAccentClass(contract.product_type);
const stateClass = contractStateClass(contract.state);
const monthlyAmt = Number(contract.monthly_amount ?? 0);
const lines = Array.isArray(contract.lines) ? contract.lines : [];
const typeLabel = contractTypeLabel(contract.product_type);
html += `<button type="button" class="helpdesk-contract-card ${accent} ${stateClass}" data-contract-index="${i}">`;
// Status badge — top-right
html += `<span class="helpdesk-contract-card-status">`;
html += `<span class="helpdesk-contract-card-dot"></span>${esc(contract.state || '—')}`;
html += `</span>`;
// Type label (uppercase, prominent)
html += `<span class="helpdesk-contract-card-type">${esc(typeLabel)}</span>`;
// Amount hero — or last invoicing date for inactive contracts
if (monthlyAmt > 0) {
html += `<span class="helpdesk-contract-card-amount">${esc(contractAmountFmtShort.format(monthlyAmt))}</span>`;
html += `<span class="helpdesk-contract-card-period">${esc(t('per month'))}</span>`;
} else if (stateClass === 'state-ended' && contract.next_invoicing_date) {
const dateStr = contractFormatDate(contract.next_invoicing_date);
if (dateStr !== '—') {
html += `<span class="helpdesk-contract-card-amount helpdesk-contract-card-amount-muted">${esc(dateStr)}</span>`;
html += `<span class="helpdesk-contract-card-period">${esc(t('Last invoicing'))}</span>`;
}
}
html += `</button>`;
}
html += '</div>';
return html;
}
function renderContractLinesTable(lines) {
if (!Array.isArray(lines) || lines.length === 0) {
return `<p class="muted">${esc(t('No contract details available.'))}</p>`;
}
let html = '<table><thead><tr>';
html += `<th>${esc(t('Description'))}</th>`;
html += `<th>${esc(t('Type'))}</th>`;
html += `<th>${esc(t('Quantity'))}</th>`;
html += `<th>${esc(t('Unit price'))}</th>`;
html += `<th>${esc(t('Line amount'))}</th>`;
html += `<th>${esc(t('Line state'))}</th>`;
html += '</tr></thead><tbody>';
for (const line of lines) {
const lineVariant = String(line.line_state || '').toLowerCase() === 'aktiv' ? 'success' : 'neutral';
html += '<tr>';
html += `<td>${esc(line.description || '—')}</td>`;
html += `<td>${line.type ? `<span class="badge">${esc(line.type)}</span>` : '—'}</td>`;
html += `<td>${esc(String(line.quantity ?? 0))}</td>`;
html += `<td>${esc(contractAmountFmt.format(Number(line.unit_price ?? 0)))}</td>`;
html += `<td>${esc(contractAmountFmt.format(Number(line.line_amount ?? 0)))}</td>`;
html += `<td><span class="badge" data-variant="${lineVariant}">${esc(line.line_state || '—')}</span></td>`;
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function openContractDialog(contract) {
// Remove existing dialog if present
const existing = document.getElementById('helpdesk-contract-dialog');
if (existing) existing.remove();
const lines = Array.isArray(contract.lines) ? contract.lines : [];
const variant = contractStateVariant(contract.state);
const typeLabel = contractTypeLabel(contract.product_type);
let dialogHtml = '<dialog id="helpdesk-contract-dialog">';
dialogHtml += '<article class="app-dialog-lg helpdesk-contract-dialog-article">';
// Header — follows core pattern: <h2> then <button class="close">
dialogHtml += '<header>';
dialogHtml += `<h2>${esc(contract.description || typeLabel)} <small>${esc(contract.contract_no || '')}</small></h2>`;
dialogHtml += '<button type="button" class="close" data-dialog-close aria-label="Close"></button>';
dialogHtml += '</header>';
// Contract meta
dialogHtml += '<div class="helpdesk-contract-dialog-meta">';
const metaItems = [];
metaItems.push(`<span><strong>${esc(t('Status'))}</strong><span class="badge" data-variant="${variant}">${esc(contract.state || '—')}</span></span>`);
metaItems.push(`<span><strong>${esc(t('Type'))}</strong>${esc(typeLabel)}</span>`);
if (contract.starting_date) {
metaItems.push(`<span><strong>${esc(t('Starting date'))}</strong>${esc(contractFormatDate(contract.starting_date))}</span>`);
}
if (contract.invoicing_period) {
metaItems.push(`<span><strong>${esc(t('Invoicing period'))}</strong>${esc(contract.invoicing_period)}</span>`);
}
if (contract.next_invoicing_date) {
metaItems.push(`<span><strong>${esc(t('Next invoicing'))}</strong>${esc(contractFormatDate(contract.next_invoicing_date))}</span>`);
}
if (Number(contract.monthly_amount ?? 0) > 0) {
metaItems.push(`<span><strong>${esc(t('Monthly volume'))}</strong>${esc(contractAmountFmt.format(contract.monthly_amount))}</span>`);
}
if (contract.salesperson_code) {
metaItems.push(`<span><strong>${esc(t('Salesperson'))}</strong>${esc(contract.salesperson_code)}</span>`);
}
if (Number(contract.support_hours ?? 0) > 0) {
metaItems.push(`<span><strong>${esc(t('Support hours'))}</strong>${esc(String(contract.support_hours))}</span>`);
}
dialogHtml += metaItems.join('');
dialogHtml += '</div>';
// Lines table
if (lines.length > 0) {
dialogHtml += `<h3 class="helpdesk-contract-dialog-section-title">${esc(t('positions'))} (${lines.length})</h3>`;
dialogHtml += '<div class="helpdesk-contract-dialog-lines">';
dialogHtml += renderContractLinesTable(lines);
dialogHtml += '</div>';
}
// Footer
dialogHtml += '<footer>';
dialogHtml += `<button type="button" class="secondary outline" data-dialog-close>${esc(t('Close'))}</button>`;
dialogHtml += '</footer>';
dialogHtml += '</article></dialog>';
document.body.insertAdjacentHTML('beforeend', dialogHtml);
const dialog = document.getElementById('helpdesk-contract-dialog');
dialog.addEventListener('click', (e) => {
if (e.target.closest('[data-dialog-close]')) {
dialog.close();
dialog.remove();
return;
}
// Close on backdrop click
if (e.target === dialog) {
dialog.close();
dialog.remove();
}
});
dialog.addEventListener('close', () => { dialog.remove(); });
dialog.showModal();
}
// Delegate click on contract cards
container.addEventListener('click', (e) => {
const card = e.target.closest('.helpdesk-contract-card[data-contract-index]');
if (!card) return;
const index = Number(card.dataset.contractIndex);
if (salesContracts[index]) {
openContractDialog(salesContracts[index]);
}
});
// KPI tile click → scroll to ticket list and set status filter
container.addEventListener('click', (e) => {
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter]');
if (!kpi) return;
const filterValue = kpi.dataset.kpiFilter || '';
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
if (statusSelect instanceof HTMLSelectElement) {
statusSelect.value = filterValue;
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
const ticketSection = document.querySelector('.helpdesk-support-ticket-list');
if (ticketSection) {
ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
function renderEscalatedTickets(entries, meta) {
const escalatedEntries = Array.isArray(entries) ? entries : [];
const escalationMeta = (meta && typeof meta === 'object') ? meta : {};
if (escalationMeta.available === false) {
return errorNotice(t('Could not load escalated tickets.'));
}
if (escalatedEntries.length === 0) {
return renderEmptyState({
message: t('No escalated tickets right now.'),
size: 'compact',
align: 'center',
icon: false,
});
}
const formatDateTime = (value) => {
const raw = String(value || '').trim();
if (!raw || raw.startsWith('0001-01-01')) return '—';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleString();
};
const formatDuration = (secondsValue) => {
const totalSeconds = Number(secondsValue);
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
return '—';
}
const seconds = Math.floor(totalSeconds);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
const dayLabel = days === 1 ? t('day') : t('days');
return hours > 0 ? `${days} ${dayLabel} ${hours} ${t('hours')}` : `${days} ${dayLabel}`;
}
if (hours > 0) {
return `${hours} ${t('hours')}`;
}
return `${Math.max(1, minutes)} min`;
};
let html = '<table><thead><tr>';
html += `<th>${esc(t('Ticket No.'))}</th>`;
html += `<th>${esc(t('Escalation code'))}</th>`;
html += `<th>${esc(t('SLA target'))}</th>`;
html += `<th>${esc(t('Last activity'))}</th>`;
html += `<th>${esc(t('Overdue by'))}</th>`;
html += '</tr></thead><tbody>';
for (const entry of escalatedEntries.slice(0, 25)) {
const ticketNo = String(entry.ticket_no || '');
const href = ticketNo ? ticketUrl(ticketNo) : '';
html += `<tr class="app-clickable-row" data-href="${esc(href)}" tabindex="0">`;
html += `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`;
html += `<td>${esc(String(entry.escalation_code || '—'))}</td>`;
html += `<td>${esc(formatDuration(entry.target_seconds))}</td>`;
html += `<td>${esc(formatDateTime(entry.last_activity_iso))}</td>`;
html += `<td>${esc(formatDuration(entry.overdue_seconds))}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function updateBadge(id, count) {
const badge = document.getElementById(id);
if (badge) {
badge.textContent = String(count);
}
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) {
el.textContent = String(value);
}
}
function setHtml(id, html) {
const el = document.getElementById(id);
if (el) {
el.innerHTML = html;
}
}
function fmtHours(h) {
const hours = Math.round(h);
if (hours < 24) return hours + 'h';
const days = Math.floor(hours / 24);
const rest = hours % 24;
return rest > 0 ? days + 'd ' + rest + 'h' : days + 'd';
}
function showLoading(id) {
const el = document.getElementById(id);
if (el) {
el.setAttribute('aria-busy', 'true');
el.hidden = false;
}
}
function hideLoading(id) {
const el = document.getElementById(id);
if (el) {
el.removeAttribute('aria-busy');
el.hidden = true;
}
}
function showContent(id) {
const el = document.getElementById(id);
if (el) {
el.hidden = false;
}
}
container.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
const row = e.target.closest('.app-clickable-row[data-href]');
if (row) {
const href = row.dataset.href;
if (href) {
window.location.href = href;
}
}
});
container.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = e.target.closest('.app-clickable-row[data-href]');
if (row) {
e.preventDefault();
const href = row.dataset.href;
if (href) {
window.location.href = href;
}
}
});
async function initTicketsGrid() {
if (ticketsGridInitialized || ticketsGridInitializing) return;
ticketsGridInitializing = true;
const config = readPageConfig('helpdesk-tickets');
if (!config) {
ticketsGridInitializing = false;
return;
}
const labels = config.labels || {};
const gridjs = window.gridjs;
if (!gridjs) {
ticketsGridInitializing = false;
return;
}
const baseEl = document.querySelector('base');
const appBase = (baseEl && baseEl.href) ? baseEl.href : '/';
const gridDataUrl = new URL(config.dataUrl || '', appBase);
gridDataUrl.searchParams.set('customerNo', config.customerNo || customerNo);
gridDataUrl.searchParams.set('customerName', config.customerName || customerName);
if (refreshFromUrl) {
gridDataUrl.searchParams.set('refresh', '1');
}
try {
const categoryResponse = await fetchTicketCategories(config, appBase);
hydrateTicketCategoryFilter(config, categoryResponse?.categories || []);
const gridOptions = {
gridjs,
container: '#helpdesk-tickets-grid',
dataUrl: gridDataUrl.toString(),
appBase,
columns: [
{
name: labels.ticketNo || 'Ticket No.',
sort: true,
formatter: (cell) => gridjs.html(`<a href="${cell.url}">${cell.no}</a>`),
},
{ name: labels.description || 'Description', sort: false },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') return gridjs.html('-');
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || '-'}</span>`);
},
},
{ name: labels.category || 'Category', sort: false },
{ name: labels.support || 'Support', sort: false },
{ name: labels.created || 'Created', sort: true },
{ name: labels.lastActivity || 'Last activity', sort: true },
{ name: 'ticket_url', hidden: true },
],
sortColumns: ['No', null, 'Ticket_State', null, null, 'Created_On', 'Last_Activity_Date', null],
paginationLimit: 10,
language: config.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
{ no: row.No || '', url: row.ticket_url || '#' },
row.Description || '',
{ variant: row.state_variant || 'neutral', label: row.Ticket_State || '' },
row.Category_1_Description || '',
row.Support_User_Name || '',
row.created_formatted || '',
row.last_activity_formatted || '',
row.ticket_url || '',
]),
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
actions: { enabled: false },
rowDblClick: {
getUrl: (rowData) => {
const url = rowData?.cells?.[7]?.data;
return url || '';
},
},
rowInteraction: { linkColumn: 0 },
};
initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-ticket-search'],
},
});
ticketsGridInitialized = true;
} finally {
ticketsGridInitializing = false;
}
}
async function renderSupportTab() {
if (supportTabRendered) return;
showLoading('support-loading');
const result = await fetchSupportDashboard();
hideLoading('support-loading');
showContent('support-content');
const recommendationsEl = document.getElementById('support-system-recommendations');
const contractsEl = document.getElementById('support-contracts-widget');
const escalationEl = document.getElementById('support-escalation-widget');
if (!result?.ok) {
setText('support-kpi-open-tickets', '—');
setText('support-kpi-created-30d', '—');
setText('support-kpi-closed-30d', '—');
setText('support-kpi-avg-age', '—');
if (recommendationsEl) {
recommendationsEl.innerHTML = errorNotice(t('Could not load tickets.'));
}
if (contractsEl) {
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
}
if (escalationEl) {
escalationEl.innerHTML = errorNotice(t('Could not load escalated tickets.'));
}
supportTabRendered = true;
return;
}
const health = (result.health && typeof result.health === 'object') ? result.health : {};
const openTickets = Number(health.open_tickets ?? 0);
const created30d = Number(health.created_30d ?? 0);
const createdPrev30d = Number(health.created_prev_30d ?? 0);
const closed30d = Number(health.closed_30d ?? 0);
const closedPrev30d = Number(health.closed_prev_30d ?? 0);
const avgAgeHours = health.avg_open_age_hours !== null && health.avg_open_age_hours !== undefined
? Number(health.avg_open_age_hours) : null;
const criticalTickets = Number(health.critical_tickets ?? 0);
// Helper: render trend indicator
const renderTrend = (current, previous, invertColor) => {
if (previous === 0 && current === 0) return '';
const delta = current - previous;
if (delta === 0) return `<span class="helpdesk-kpi-trend-neutral">— ${t('vs. prev. 30d')}</span>`;
const arrow = delta > 0 ? '↑' : '↓';
const abs = Math.abs(delta);
// For "resolved" more is good (invert), for "created/open" more is bad
const isPositive = invertColor ? delta > 0 : delta < 0;
const cls = isPositive ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
return `<span class="${cls}">${arrow} ${abs} ${t('vs. prev. 30d')}</span>`;
};
// 1. Open tickets + net flow trend
setText('support-kpi-open-tickets', openTickets);
const netFlow = created30d - closed30d;
const openTrendEl = document.getElementById('support-kpi-open-trend');
if (openTrendEl) {
if (netFlow > 0) {
openTrendEl.innerHTML = `<span class="helpdesk-kpi-trend-negative">↑ ${netFlow} ${t('net (30d)')}</span>`;
} else if (netFlow < 0) {
openTrendEl.innerHTML = `<span class="helpdesk-kpi-trend-positive">↓ ${Math.abs(netFlow)} ${t('net (30d)')}</span>`;
} else if (created30d > 0) {
openTrendEl.innerHTML = `<span class="helpdesk-kpi-trend-neutral">± 0 ${t('net (30d)')}</span>`;
}
}
// 2. Created last 30d + trend vs previous 30d
setText('support-kpi-created-30d', created30d);
const createdTrendEl = document.getElementById('support-kpi-created-trend');
if (createdTrendEl) {
createdTrendEl.innerHTML = renderTrend(created30d, createdPrev30d, false);
}
// 3. Closed last 30d + trend vs previous 30d
setText('support-kpi-closed-30d', closed30d);
const closedTrendEl = document.getElementById('support-kpi-closed-trend');
if (closedTrendEl) {
closedTrendEl.innerHTML = renderTrend(closed30d, closedPrev30d, true);
}
// 4. Avg age of open tickets
const formatAge = (hours) => {
if (hours === null) return '—';
if (hours < 24) return `${hours}h`;
const days = Math.round(hours / 24);
return `${days}d`;
};
setText('support-kpi-avg-age', formatAge(avgAgeHours));
const ageHintEl = document.getElementById('support-kpi-age-hint');
if (ageHintEl && criticalTickets > 0) {
ageHintEl.innerHTML = `<span class="helpdesk-kpi-trend-negative">${criticalTickets} ${t('critical')}</span>`;
}
// Recommendations widget
if (recommendationsEl) {
const recommendations = Array.isArray(result.recommendations)
? result.recommendations
: (Array.isArray(result.actions) ? result.actions : []);
if (recommendations.length > 0) {
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
} else {
recommendationsEl.innerHTML = renderEmptyState({
message: t('No system recommendations right now.'),
size: 'compact',
});
}
}
// Contracts widget
if (contractsEl) {
const contracts = Array.isArray(result.contracts) ? result.contracts : [];
const contractsMeta = (result.contracts_meta && typeof result.contracts_meta === 'object')
? result.contracts_meta
: {};
if (contracts.length === 0 && contractsMeta.available === false) {
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
} else if (contracts.length > 0) {
contractsEl.innerHTML = renderSupportContracts(contracts);
} else {
contractsEl.innerHTML = renderEmptyState({
message: t('No contracts found for this customer.'),
size: 'compact',
});
}
}
// Escalation widget
if (escalationEl) {
const escalationEntries = Array.isArray(result.escalation_entries) ? result.escalation_entries : [];
const escalationMeta = (result.escalation_meta && typeof result.escalation_meta === 'object')
? result.escalation_meta
: {};
if (escalationEntries.length > 0 || escalationMeta.available === false) {
escalationEl.innerHTML = renderEscalatedTickets(escalationEntries, escalationMeta);
} else {
escalationEl.innerHTML = renderEmptyState({
message: t('No escalated tickets right now.'),
size: 'compact',
});
}
}
supportTabRendered = true;
}
async function renderSalesTab() {
if (salesTabRendered) return;
showLoading('sales-loading');
const result = await fetchSalesDashboard();
hideLoading('sales-loading');
showContent('sales-content');
if (!result?.ok) {
setText('sales-kpi-monthly-volume', '—');
setText('sales-kpi-active-contracts', '—');
setText('sales-kpi-avg-value', '—');
setText('sales-kpi-next-invoicing', '—');
const detailEl = document.getElementById('sales-contracts-detail');
if (detailEl) detailEl.innerHTML = errorNotice(t('Could not load sales dashboard.'));
salesTabRendered = true;
return;
}
// Render KPIs
const kpis = (result.kpis && typeof result.kpis === 'object') ? result.kpis : {};
const amountFmt = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
const formatDate = (value) => {
const raw = String(value || '').trim();
if (!raw || raw.startsWith('0001-01-01')) return '—';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleDateString();
};
// Trend helper (same pattern as support dashboard)
const salesTrend = (current, previous, invertColor) => {
if (previous === 0 && current === 0) return '';
const delta = current - previous;
if (delta === 0) return `<span class="helpdesk-kpi-trend-neutral">— ${esc(t('vs. prev. 30d'))}</span>`;
const arrow = delta > 0 ? '↑' : '↓';
const abs = Math.abs(delta);
const isPositive = invertColor ? delta > 0 : delta < 0;
const cls = isPositive ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
return `<span class="${cls}">${arrow} ${abs} ${esc(t('vs. prev. 30d'))}</span>`;
};
const started30d = Number(kpis.started_30d ?? 0);
const startedPrev30d = Number(kpis.started_prev_30d ?? 0);
const ended30d = Number(kpis.ended_30d ?? 0);
const endedPrev30d = Number(kpis.ended_prev_30d ?? 0);
// 1. Monthly volume + net contract flow trend
setText('sales-kpi-monthly-volume', amountFmt.format(Number(kpis.monthly_volume ?? 0)));
const netContracts = started30d - ended30d;
if (started30d > 0 || ended30d > 0) {
const netCls = netContracts > 0 ? 'helpdesk-kpi-trend-positive' : (netContracts < 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral');
const netArrow = netContracts > 0 ? '↑' : (netContracts < 0 ? '↓' : '—');
const netAbs = Math.abs(netContracts);
setHtml('sales-kpi-monthly-volume-trend',
`<span class="${netCls}">${netArrow} ${netAbs} ${esc(t('net (30d)'))}</span>`);
} else {
setHtml('sales-kpi-monthly-volume-trend', '');
}
// 2. Active contracts + trend arrow (started vs previous period)
setText('sales-kpi-active-contracts', kpis.active_contracts ?? 0);
const totalContracts = Number(kpis.total_contracts ?? 0);
const contractsTrendHtml = salesTrend(started30d, startedPrev30d, false);
const totalHint = totalContracts > 0 ? `<span class="helpdesk-kpi-trend-neutral">${esc(t('of {total} total').replace('{total}', totalContracts))}</span>` : '';
setHtml('sales-kpi-active-contracts-trend', contractsTrendHtml
? contractsTrendHtml + (totalHint ? ' · ' + totalHint : '')
: totalHint);
// 3. Avg contract value + largest hint
setText('sales-kpi-avg-value', amountFmt.format(Number(kpis.avg_contract_value ?? 0)));
const largest = Number(kpis.largest_contract_volume ?? 0);
const activeCount = Number(kpis.active_contracts ?? 0);
const totalPositions = Number(kpis.total_positions ?? 0);
const avgParts = [];
if (activeCount > 1 && largest > 0) {
avgParts.push(`<span class="helpdesk-kpi-trend-neutral">${esc(t('largest'))}: ${esc(amountFmt.format(largest))}</span>`);
}
if (totalPositions > 0) {
avgParts.push(`<span class="helpdesk-kpi-trend-neutral">${esc(totalPositions + ' ' + t('positions'))}</span>`);
}
setHtml('sales-kpi-avg-value-trend', avgParts.join(' · '));
// 4. Next invoicing + countdown
setText('sales-kpi-next-invoicing', formatDate(kpis.next_invoicing_date));
const daysUntil = kpis.days_until_next_invoicing;
if (daysUntil !== null && daysUntil !== undefined) {
const cls = daysUntil <= 7 ? 'helpdesk-kpi-trend-negative' : (daysUntil <= 30 ? 'helpdesk-kpi-trend-neutral' : 'helpdesk-kpi-trend-positive');
setHtml('sales-kpi-next-invoicing-trend',
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
}
// Render contracts
const detailEl = document.getElementById('sales-contracts-detail');
if (detailEl) {
detailEl.innerHTML = `<h3 class="helpdesk-support-section-title">${esc(t('Contract overview'))}</h3>`
+ renderContractCards(result.contracts || []);
}
// Init contacts grid (server-side, same pattern as tickets)
initContactsGrid();
salesTabRendered = true;
}
async function renderDebitorCommunicationAside() {
const feedEl = document.getElementById('debitor-communication-feed');
const loadingEl = document.getElementById('debitor-communication-loading');
const contentEl = document.getElementById('debitor-communication-content');
if (!feedEl || !loadingEl || !contentEl) return;
showLoading('debitor-communication-loading');
const result = await fetchDebitorCommunication();
hideLoading('debitor-communication-loading');
showContent('debitor-communication-content');
if (!result.ok) {
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
return;
}
let html = '';
if ((result.meta?.soap_partial_failures || 0) > 0) {
html += renderCommunicationHint(t('Some ticket communications could not be loaded completely.'));
}
html += renderCommunicationFeed(result.entries || [], {
t,
emptyMessage: 'No communication found.',
showTicketNo: true,
ticketBaseUrl,
});
feedEl.innerHTML = html;
}
// --- Controlling tab ---
const controllingDashboardUrl = container.dataset.controllingDashboardUrl || '';
let controllingTabRendered = false;
let controllingCurrentPeriod = 90;
async function fetchControllingDashboard(periodDays) {
const params = buildBaseParams();
params.set('periodDays', String(periodDays));
const url = controllingDashboardUrl + '?' + params.toString();
try {
const res = await fetch(url, { credentials: 'same-origin' });
return await res.json();
} catch {
return null;
}
}
async function renderControllingTab(periodDays) {
showLoading('controlling-spinner');
const contentEl = document.getElementById('controlling-content');
const errorEl = document.getElementById('controlling-error');
const emptyEl = document.getElementById('controlling-empty');
if (contentEl) contentEl.hidden = true;
if (errorEl) errorEl.hidden = true;
if (emptyEl) emptyEl.hidden = true;
const result = await fetchControllingDashboard(periodDays);
hideLoading('controlling-spinner');
if (!result?.ok) {
if (errorEl) {
errorEl.innerHTML = errorNotice(t('Could not load controlling dashboard.'));
errorEl.hidden = false;
}
controllingTabRendered = true;
return;
}
const kpis = result.kpis || {};
const meta = result.meta || {};
if (meta.created_in_period === 0 && meta.created_in_prev === 0 && kpis.backlog_open === 0) {
if (emptyEl) {
emptyEl.innerHTML = renderEmptyState({
message: t('No controlling data available.'),
size: 'compact',
icon: false,
});
emptyEl.hidden = false;
}
controllingTabRendered = true;
return;
}
if (contentEl) contentEl.hidden = false;
// --- KPI 1: Ticket Volume ---
const volume = Number(kpis.volume ?? 0);
const volumePrev = Number(kpis.volume_prev ?? 0);
const volumeDelta = Number(kpis.volume_delta ?? 0);
setText('controlling-kpi-volume', String(volume));
if (volumeDelta !== 0 || volumePrev > 0) {
const arrow = volumeDelta > 0 ? '↑' : volumeDelta < 0 ? '↓' : '—';
const abs = Math.abs(volumeDelta);
// More tickets = more load = negative for controlling
const cls = volumeDelta > 0 ? 'helpdesk-kpi-trend-negative'
: volumeDelta < 0 ? 'helpdesk-kpi-trend-positive'
: 'helpdesk-kpi-trend-neutral';
setHtml('controlling-kpi-volume-trend',
`<span class="${cls}">${arrow} ${abs} ${esc(t('vs. prev. period'))}</span>`
);
}
// --- KPI 2: Close-Rate ---
const closeRate = kpis.close_rate;
const closeRatePrev = kpis.close_rate_prev;
setText('controlling-kpi-closerate', closeRate != null ? closeRate.toFixed(1) + '×' : '—');
if (closeRate != null && closeRatePrev != null) {
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive'
: 'helpdesk-kpi-trend-negative';
const prevLabel = closeRatePrev.toFixed(1) + '×';
setHtml('controlling-kpi-closerate-trend',
`<span class="${cls}">${esc(t('vs. prev. period'))}: ${prevLabel}</span>`
);
} else if (closeRate != null) {
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
const label = closeRate >= 1.0 ? t('Backlog shrinking') : t('Backlog growing');
setHtml('controlling-kpi-closerate-trend', `<span class="${cls}">${esc(label)}</span>`);
}
// --- KPI 3: Avg. Resolution ---
const resolution = kpis.resolution_hours;
const resolutionPrev = kpis.resolution_hours_prev;
setText('controlling-kpi-resolution', resolution != null ? fmtHours(resolution) : '—');
if (resolution != null && resolutionPrev != null) {
const delta = resolution - resolutionPrev;
if (delta === 0) {
setHtml('controlling-kpi-resolution-trend',
`<span class="helpdesk-kpi-trend-neutral">— ${esc(t('vs. prev. period'))}</span>`
);
} else {
const arrow = delta > 0 ? '↑' : '↓';
const absDelta = Math.abs(Math.round(delta));
// Lower resolution = better
const cls = delta < 0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
setHtml('controlling-kpi-resolution-trend',
`<span class="${cls}">${arrow} ${fmtHours(absDelta)} ${esc(t('vs. prev. period'))}</span>`
);
}
}
// --- KPI 4: Open Backlog ---
const backlog = Number(kpis.backlog_open ?? 0);
const unassigned = Number(kpis.backlog_unassigned ?? 0);
const critical = Number(kpis.backlog_critical ?? 0);
setText('controlling-kpi-backlog', String(backlog));
const parts = [];
if (unassigned > 0) parts.push(unassigned + ' ' + t('unassigned'));
if (critical > 0) parts.push(critical + ' ' + t('critical'));
if (parts.length > 0) {
const cls = critical > 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral';
setHtml('controlling-kpi-backlog-trend', `<span class="${cls}">${esc(parts.join(', '))}</span>`);
}
// Trend chart
renderTrendChart(result.trend || []);
// Efficiency
renderEfficiencyWidget(result.efficiency || {});
// Risk indicators
renderControllingRiskIndicators(result.risk_indicators || [], result.risk_summary || {});
controllingTabRendered = true;
}
function renderTrendChart(trend) {
const chartEl = document.getElementById('controlling-trend-chart');
if (!chartEl || !trend.length) return;
let max = 0;
for (const bucket of trend) {
if (bucket.created > max) max = bucket.created;
if (bucket.closed > max) max = bucket.closed;
}
if (max === 0) max = 1;
let rowsHtml = '';
for (const bucket of trend) {
const createdPct = Math.round((bucket.created / max) * 100);
const closedPct = Math.round((bucket.closed / max) * 100);
rowsHtml += `<div class="helpdesk-hbar-row">
<span class="helpdesk-hbar-label">${esc(bucket.label)}</span>
<div class="helpdesk-hbar-track">
<div class="helpdesk-hbar helpdesk-hbar-created" style="width:${createdPct}%">
<span class="helpdesk-hbar-value">${bucket.created}</span>
</div>
<div class="helpdesk-hbar helpdesk-hbar-closed" style="width:${closedPct}%">
<span class="helpdesk-hbar-value">${bucket.closed}</span>
</div>
</div>
</div>`;
}
chartEl.innerHTML = `
<div class="helpdesk-hbar-chart">${rowsHtml}</div>
<div class="helpdesk-hbar-legend">
<span class="helpdesk-hbar-legend-item">
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-created"></span>
${esc(t('Created'))}
</span>
<span class="helpdesk-hbar-legend-item">
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-closed"></span>
${esc(t('Closed'))}
</span>
</div>
`;
}
function renderEfficiencyWidget(efficiency) {
const el = document.getElementById('controlling-efficiency');
if (!el) return;
const rows = [
[t('Avg. resolution'), efficiency.resolution_hours != null ? fmtHours(efficiency.resolution_hours) : '—'],
[t('Oldest open'), efficiency.oldest_open_hours != null ? fmtHours(efficiency.oldest_open_hours) : '—'],
[t('Without assignee'), String(efficiency.unassigned_count ?? 0)],
];
el.innerHTML = '<table class="helpdesk-controlling-kv-table">' +
rows.map(([label, value]) => `<tr><td>${esc(label)}</td><td>${esc(value)}</td></tr>`).join('') +
'</table>';
}
function renderControllingRiskIndicators(indicators, summary) {
const el = document.getElementById('controlling-risk-indicators');
if (!el || !indicators.length) return;
const level = summary.level || 'low';
const levelLabel = t(level.charAt(0).toUpperCase() + level.slice(1));
const levelCls = level === 'high' ? 'helpdesk-kpi-trend-negative'
: level === 'medium' ? 'helpdesk-kpi-trend-neutral'
: 'helpdesk-kpi-trend-positive';
let html = `<div class="helpdesk-controlling-risk-summary">
<span class="${levelCls}">${esc(levelLabel)}</span>
<span class="helpdesk-controlling-risk-summary-count">${summary.triggered ?? 0}/${summary.total ?? 0} ${esc(t('flags'))}</span>
</div>`;
html += '<div class="helpdesk-controlling-risk-list">';
for (const ind of indicators) {
const dotCls = ind.triggered ? 'helpdesk-controlling-risk-dot-triggered' : 'helpdesk-controlling-risk-dot-ok';
html += `<div class="helpdesk-controlling-risk-row">
<span class="helpdesk-controlling-risk-dot ${dotCls}"></span>
<span class="helpdesk-controlling-risk-label">${esc(t(ind.label) || ind.label)}</span>
<span class="helpdesk-controlling-risk-value">${esc(ind.value)} / ${esc(ind.threshold)}</span>
</div>`;
}
html += '</div>';
el.innerHTML = html;
}
// Period selector (radio inputs)
const periodSelector = document.getElementById('controlling-period-selector');
if (periodSelector) {
periodSelector.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="controlling-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === controllingCurrentPeriod) return;
controllingCurrentPeriod = period;
controllingTabRendered = false;
renderControllingTab(period);
});
}
const tabPanels = container.querySelectorAll('[data-tab-panel]');
const observer = new MutationObserver(() => {
for (const panel of tabPanels) {
if (panel.hidden) continue;
const tabName = panel.dataset.tabPanel;
if (tabName === 'support') {
renderSupportTab();
initTicketsGrid();
} else if (tabName === 'sales') {
renderSalesTab();
} else if (tabName === 'controlling') {
if (!controllingTabRendered) {
renderControllingTab(controllingCurrentPeriod);
}
}
}
});
for (const panel of tabPanels) {
observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] });
}
const refreshButton = container.querySelector('button[name="support-refresh"]')
|| document.getElementById('support-refresh-button');
if (refreshButton) {
refreshButton.addEventListener('click', () => {
refreshButton.setAttribute('aria-busy', 'true');
refreshButton.setAttribute('disabled', 'disabled');
const url = new URL(window.location.href);
url.searchParams.set('refresh', '1');
window.location.assign(url.toString());
});
}
renderSupportTab();
initTicketsGrid();
renderDebitorCommunicationAside();
}