Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-detail.js

1509 lines
55 KiB
JavaScript
Raw Normal View History

/**
* 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();
}