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

1626 lines
61 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 { initListSection } from '/js/core/app-list-page-module.js';
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.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 EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-detail"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-detail"]');
};
export function initHelpdeskDetail(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
return init(container);
}
function init(container) {
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
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 fileDownloadUrl = container.dataset.fileDownloadUrl || '';
const salesDashboardUrl = container.dataset.salesDashboardUrl || '';
const meetingsUrl = container.dataset.meetingsUrl || '';
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 EMPTY_API;
}
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 meetingsPromise = 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 = getJson(`${supportDashboardUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.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 = getJson(`${salesDashboardUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return salesDashboardPromise;
}
function fetchMeetings() {
if (!meetingsPromise) {
if (!meetingsUrl) {
meetingsPromise = Promise.resolve({ ok: false, error: 'No meetings endpoint configured' });
} else {
const params = new URLSearchParams({ customerNo });
if (refreshFromUrl) params.set('refresh', '1');
meetingsPromise = getJson(`${meetingsUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return meetingsPromise;
}
function fetchContacts() {
if (!contactsPromise) {
const params = buildBaseParams();
contactsPromise = getJson(`${contactsUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.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 = getJson(url.toString(), {
signal: requestController.signal,
})
.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 = getJson(`${communicationUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return debitorCommunicationPromise;
}
/**
* Hydrate a dynamic select filter with values from the API response.
* Works for category, support user, and contact filters.
*/
function hydrateSelectFilter(config, selectorId, filterKey, values) {
const selectEl = document.querySelector(selectorId);
if (!(selectEl instanceof HTMLSelectElement)) return;
const uniqueValues = Array.from(new Set(
(Array.isArray(values) ? values : [])
.map(item => String(item || '').trim())
.filter(Boolean),
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
const currentValue = String(selectEl.value || '');
selectEl.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = t('All');
selectEl.appendChild(allOption);
const optionMap = {};
for (const value of uniqueValues) {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
selectEl.appendChild(option);
optionMap[value] = value;
}
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(optionMap, currentValue)) {
selectEl.value = currentValue;
} else {
selectEl.value = '';
}
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
config.filterChipMeta = {};
}
const currentMeta = (config.filterChipMeta[filterKey] && typeof config.filterChipMeta[filterKey] === 'object')
? config.filterChipMeta[filterKey]
: {};
config.filterChipMeta[filterKey] = {
...currentMeta,
type: 'select',
default: '',
options: optionMap,
};
}
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 getJson(typeOptionsUrl.toString(), {
signal: requestController.signal,
}).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;
initListSection({
moduleId: 'helpdesk-detail-contacts',
initErrorMessage: 'Helpdesk contacts grid init failed',
initOptions: {
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);
});
}
const severityMap = {
escalation_overdue: { cls: 'critical', icon: '🔴' },
high_risk_aging: { cls: 'high', icon: '🟠' },
unassigned_open: { cls: 'medium', icon: '🟡' },
stale_open: { cls: 'low', icon: '🔵' },
customer_backlog: { cls: 'low', icon: '🔵' },
};
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 = '<div class="helpdesk-rec-list">';
for (const entry of entries) {
const ticketNo = String(entry.ticket_no || '');
const href = ticketNo ? ticketUrl(ticketNo) : '';
const ruleId = String(entry.rule_id || '');
const severity = severityMap[ruleId] || { cls: 'low', icon: '🔵' };
const ageHours = Number.isFinite(Number(entry.age_hours)) ? Number(entry.age_hours) : null;
const ageLabel = ageHours !== null ? fmtHours(ageHours) : '';
const message = formatRecommendationMessage(entry);
const tag = href ? 'a' : 'div';
const hrefAttr = href ? ` href="${esc(href)}"` : '';
html += `<${tag}${hrefAttr} class="helpdesk-rec-card helpdesk-rec-${severity.cls}" tabindex="0">`;
html += `<div class="helpdesk-rec-card-header">`;
html += `<span class="helpdesk-rec-card-ticket">${esc(ticketNo || '—')}</span>`;
if (ageLabel) html += `<span class="helpdesk-rec-card-age">${esc(ageLabel)}</span>`;
html += `</div>`;
html += `<div class="helpdesk-rec-card-message">${esc(message)}</div>`;
html += `</${tag}>`;
}
html += '</div>';
return html;
}
function contractTypeLabel(productType) {
return String(productType || '').trim() || '—';
}
// Shared formatter for contract rendering (dialog, timeline)
const contractAmountFmt = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
});
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 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 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');
on(dialog, '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();
}
});
on(dialog, 'close', () => { dialog.remove(); });
dialog.showModal();
}
// Delegate click on contract cards and timeline rows
on(container, 'click', (e) => {
const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]');
if (!target) return;
const index = Number(target.dataset.contractIndex);
if (salesContracts[index]) {
openContractDialog(salesContracts[index]);
}
});
// KPI tile click → scroll to ticket list and apply filter or sort
on(container, 'click', (e) => {
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]');
if (!kpi) return;
// Apply status filter if present
if (kpi.dataset.kpiFilter !== undefined) {
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 }));
}
}
// Apply sort if present (format: "column:dir")
if (kpi.dataset.kpiSort) {
const [col, dir] = kpi.dataset.kpiSort.split(':');
const url = new URL(window.location.href);
url.searchParams.set('order', col);
url.searchParams.set('dir', dir || 'asc');
// Also set open filter for "avg age" to show only open tickets
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
if (statusSelect instanceof HTMLSelectElement) {
statusSelect.value = 'open';
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
window.history.replaceState(null, '', url.toString());
}
const ticketSection = document.querySelector('.helpdesk-support-ticket-list');
if (ticketSection) {
ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
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.hidden = false;
}
}
function hideLoading(id) {
const el = document.getElementById(id);
if (el) {
el.hidden = true;
}
}
function showContent(id) {
const el = document.getElementById(id);
if (el) {
el.hidden = false;
}
}
on(container, '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;
}
}
});
on(container, '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;
}
return;
}
// Timeline row keyboard activation
const tlRow = e.target.closest('.helpdesk-tl-row-clickable[data-contract-index]');
if (tlRow) {
e.preventDefault();
const index = Number(tlRow.dataset.contractIndex);
if (salesContracts[index]) {
openContractDialog(salesContracts[index]);
}
}
});
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 filterOptionsResponse = await fetchTicketCategories(config, appBase);
hydrateSelectFilter(config, '#helpdesk-ticket-category-filter', 'category', filterOptionsResponse?.categories || []);
hydrateSelectFilter(config, '#helpdesk-ticket-support-filter', 'support', filterOptionsResponse?.support_users || []);
hydrateSelectFilter(config, '#helpdesk-ticket-contact-filter', 'contact', filterOptionsResponse?.contacts || []);
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 },
};
initListSection({
moduleId: 'helpdesk-detail-tickets',
initErrorMessage: 'Helpdesk tickets grid init failed',
initOptions: {
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');
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.'));
}
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 + escalations merged — only show section when there are entries
const recommendationsWrapper = document.getElementById('support-recommendations-wrapper');
if (recommendationsEl) {
const recommendations = Array.isArray(result.recommendations)
? result.recommendations
: (Array.isArray(result.actions) ? result.actions : []);
if (recommendations.length > 0) {
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
if (recommendationsWrapper) recommendationsWrapper.hidden = false;
} else if (recommendationsWrapper) {
recommendationsWrapper.hidden = true;
}
}
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>`);
}
// Contract timeline (swimlane view — click on bar opens contract dialog)
renderContractTimeline(result.contracts || []);
// Init contacts grid (server-side, same pattern as tickets)
initContactsGrid();
// Meetings timeline (async, doesn't block sales tab)
renderMeetingsSection();
salesTabRendered = true;
}
// All values inserted via esc() which escapes HTML entities — safe innerHTML usage (same pattern as renderContractTimeline, renderSystemRecommendations)
async function renderMeetingsSection() {
const loadingEl = document.getElementById('meetings-loading');
const contentEl = document.getElementById('meetings-content');
const emptyEl = document.getElementById('meetings-empty');
const errorEl = document.getElementById('meetings-error');
if (!loadingEl || !contentEl) return;
loadingEl.hidden = false;
contentEl.hidden = true;
if (emptyEl) emptyEl.hidden = true;
if (errorEl) errorEl.hidden = true;
const result = await fetchMeetings();
loadingEl.hidden = true;
if (!result?.ok) {
if (errorEl) {
errorEl.innerHTML = errorNotice(t('Could not load meetings.')); // raw-html-ok: errorNotice returns escaped HTML
errorEl.hidden = false;
}
return;
}
const kpis = result.kpis || {};
const timeline = result.timeline || {};
const upcoming = timeline.upcoming || {};
const past = timeline.past || {};
const hasEntries = Object.keys(upcoming).length > 0 || Object.keys(past).length > 0;
if (!hasEntries) {
if (emptyEl) {
emptyEl.innerHTML = renderEmptyState(t('No meetings found for this customer.')); // raw-html-ok: renderEmptyState returns escaped HTML
emptyEl.hidden = false;
}
return;
}
// Render timeline — all dynamic values passed through esc() which HTML-entity-escapes
const timelineEl = document.getElementById('meetings-timeline');
if (!timelineEl) { contentEl.hidden = false; return; }
let html = '';
if (Object.keys(upcoming).length > 0) {
html += `<div class="helpdesk-meetings-group-header">${esc(t('Upcoming meetings'))}</div>`;
html += renderMeetingGroups(upcoming);
}
if (Object.keys(past).length > 0) {
html += `<div class="helpdesk-meetings-group-header helpdesk-meetings-group-header-past">${esc(t('Past meetings'))}</div>`;
html += renderMeetingGroups(past);
}
timelineEl.innerHTML = html; // raw-html-ok: all values escaped via esc()
contentEl.hidden = false;
}
function renderMeetingGroups(groups) {
const monthFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'long' });
const dateFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
let html = '';
for (const [monthKey, entries] of Object.entries(groups)) {
const monthDate = new Date(monthKey + '-01');
const monthLabel = Number.isNaN(monthDate.getTime()) ? monthKey : monthFmt.format(monthDate);
html += `<div class="helpdesk-meetings-month"><span class="helpdesk-meetings-month-label">${esc(monthLabel)}</span>`;
for (const entry of entries) {
const d = new Date(entry.appointment_date);
const dateStr = Number.isNaN(d.getTime()) ? entry.appointment_date : dateFmt.format(d);
const statusCls = entry.is_released ? 'helpdesk-meetings-card-released' : 'helpdesk-meetings-card-pending';
const badgeCls = entry.is_released ? 'helpdesk-meetings-badge-released' : 'helpdesk-meetings-badge-pending';
const badgeLabel = entry.is_released ? t('Released') : t('Pending');
html += `<article class="helpdesk-meetings-card ${statusCls}">
<p class="helpdesk-meetings-card-comment">${esc(entry.comment || '—')}</p>
<p class="helpdesk-meetings-card-secondary">
<span class="helpdesk-meetings-card-context">
<time datetime="${esc(entry.appointment_date)}">${esc(dateStr)}</time>
<span>·</span>
<span>${esc(entry.salesperson_code)}</span>
</span>
<span class="helpdesk-meetings-badge ${badgeCls}" aria-label="${esc(badgeLabel)}">${esc(badgeLabel)}</span>
</p>
</article>`;
}
html += '</div>';
}
return html;
}
function renderContractTimeline(contracts) {
const chartEl = document.getElementById('sales-trend-chart');
if (!chartEl || !contracts.length) return;
// Store contracts for dialog access
salesContracts = contracts;
const amountFmt = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
const nowDate = new Date();
// Parse dates and compute timeline range
const items = [];
let globalMin = Infinity;
let globalMax = -Infinity;
for (let i = 0; i < contracts.length; i++) {
const c = contracts[i];
const startRaw = String(c.starting_date || '').trim();
if (!startRaw || startRaw.startsWith('0001')) continue;
const startTs = new Date(startRaw).getTime();
if (Number.isNaN(startTs)) continue;
const stateLower = String(c.state || '').toLowerCase();
const isActive = stateLower === 'aktiv';
let endTs;
if (isActive) {
endTs = nowDate.getTime();
} else {
const endRaw = String(c.next_invoicing_date || '').trim();
endTs = (endRaw && !endRaw.startsWith('0001')) ? new Date(endRaw).getTime() : startTs;
if (Number.isNaN(endTs)) endTs = startTs;
}
if (startTs < globalMin) globalMin = startTs;
if (endTs > globalMax) globalMax = endTs;
if (isActive && nowDate.getTime() > globalMax) globalMax = nowDate.getTime();
items.push({
contract_index: i,
contract_no: String(c.contract_no || ''),
product_type: String(c.product_type || '').trim(),
monthly_amount: Number(c.monthly_amount ?? 0),
is_active: isActive,
start_ts: startTs,
end_ts: endTs,
});
}
if (items.length === 0) return;
// Sort: active first, then by start date
items.sort((a, b) => {
if (a.is_active !== b.is_active) return a.is_active ? -1 : 1;
return a.start_ts - b.start_ts;
});
// Extend range to full years so all year ticks are always visible
const firstYear = new Date(globalMin).getFullYear();
const lastYear = new Date(globalMax).getFullYear();
const yearStartMin = new Date(firstYear, 0, 1).getTime();
const yearStartMax = new Date(lastYear, 0, 1).getTime();
const rangeMin = Math.min(globalMin, yearStartMin);
const rangeMax = Math.max(globalMax, yearStartMax);
const range = rangeMax - rangeMin || 1;
const padding = range * 0.02;
const timeMin = rangeMin - padding;
const timeMax = rangeMax + padding;
const timeRange = timeMax - timeMin;
// Build year tick marks + vertical guide lines
const minYear = new Date(globalMin).getFullYear();
const maxYear = new Date(globalMax).getFullYear();
let ticksHtml = '';
let guidesHtml = '';
for (let y = minYear; y <= maxYear; y++) {
const yearTs = new Date(y, 0, 1).getTime();
const pct = ((yearTs - timeMin) / timeRange) * 100;
if (pct >= 0 && pct <= 100) {
ticksHtml += `<div class="helpdesk-tl-tick" style="left:${pct.toFixed(2)}%">
<span class="helpdesk-tl-tick-label">${y}</span>
</div>`;
guidesHtml += `<div class="helpdesk-tl-guide" style="left:${pct.toFixed(2)}%"></div>`;
}
}
// Build swimlane rows
let rowsHtml = '';
for (const item of items) {
const leftPct = ((item.start_ts - timeMin) / timeRange) * 100;
const widthPct = ((item.end_ts - item.start_ts) / timeRange) * 100;
const barCls = item.is_active ? 'helpdesk-tl-bar-active' : 'helpdesk-tl-bar-ended';
const typeLabel = item.product_type ? item.product_type.toUpperCase() : '';
const amtLabel = item.monthly_amount > 0 ? amountFmt.format(item.monthly_amount) : '';
const startDate = new Date(item.start_ts).toLocaleDateString();
const endLabel = item.is_active ? t('active') : new Date(item.end_ts).toLocaleDateString();
const tooltip = `${item.contract_no} · ${startDate} ${endLabel}` + (amtLabel ? ` · ${amtLabel}/M` : '');
rowsHtml += `<div class="helpdesk-tl-row helpdesk-tl-row-clickable" data-contract-index="${item.contract_index}" tabindex="0">
<div class="helpdesk-tl-label">
<span class="helpdesk-tl-type">${esc(typeLabel || item.contract_no)}</span>
<span class="helpdesk-tl-amount">${esc(amtLabel ? amtLabel + '/M' : '')}</span>
</div>
<div class="helpdesk-tl-track">
<div class="helpdesk-tl-bar ${barCls}" style="left:${leftPct.toFixed(2)}%;width:${Math.max(widthPct, 0.5).toFixed(2)}%"
data-tooltip="${esc(tooltip)}" data-tooltip-pos="top">${item.is_active ? '<span class="helpdesk-tl-bar-arrow"></span>' : ''}</div>
</div>
</div>`;
}
chartEl.innerHTML = `
<div class="helpdesk-tl">
<div class="helpdesk-tl-body">
<div class="helpdesk-tl-guides">${guidesHtml}</div>
<div class="helpdesk-tl-rows">${rowsHtml}</div>
</div>
<div class="helpdesk-tl-axis">${ticksHtml}</div>
</div>
<div class="helpdesk-tl-legend">
<span class="helpdesk-tl-legend-item">
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-active"></span>
${esc(t('active'))}
</span>
<span class="helpdesk-tl-legend-item">
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-ended"></span>
${esc(t('ended'))}
</span>
</div>
`;
}
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,
fileDownloadUrl,
});
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 {
return await getJson(url, { signal: requestController.signal });
} 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 || []);
// 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;
// Find max value for Y-axis scaling
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;
// Compute nice grid lines (2-3 lines)
const gridStep = max <= 5 ? 1 : max <= 15 ? 5 : max <= 50 ? 10 : Math.ceil(max / 4 / 10) * 10;
let gridHtml = '';
for (let v = gridStep; v <= max; v += gridStep) {
const pct = ((v / max) * 100).toFixed(1);
gridHtml += `<div class="helpdesk-vchart-grid-line" style="bottom:${pct}%">
<span class="helpdesk-vchart-grid-label">${v}</span>
</div>`;
}
// Bar groups
let barsHtml = '';
for (const bucket of trend) {
const createdPct = ((bucket.created / max) * 100).toFixed(1);
const closedPct = ((bucket.closed / max) * 100).toFixed(1);
barsHtml += `<div class="helpdesk-vchart-group">
<div class="helpdesk-vchart-bars">
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-created" style="height:${createdPct}%" data-tooltip="${bucket.created}" data-tooltip-pos="top"></div>
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-closed" style="height:${closedPct}%" data-tooltip="${bucket.closed}" data-tooltip-pos="top"></div>
</div>
<span class="helpdesk-vchart-label">${esc(bucket.label)}</span>
</div>`;
}
chartEl.innerHTML = `
<div class="helpdesk-vchart-area">
${gridHtml}
<div class="helpdesk-vchart-groups">${barsHtml}</div>
</div>
<div class="helpdesk-vchart-legend">
<span class="helpdesk-vchart-legend-item">
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-created"></span>
${esc(t('Created'))}
</span>
<span class="helpdesk-vchart-legend-item">
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-closed"></span>
${esc(t('Closed'))}
</span>
</div>
`;
}
function renderControllingRiskIndicators(indicators, summary) {
const el = document.getElementById('controlling-risk-indicators');
if (!el || !indicators.length) return;
// Count triggered from actual indicator data (single source of truth)
const total = indicators.length;
const triggered = indicators.filter(ind => !!ind.triggered).length;
const summaryLabel = triggered === 0
? t('All checks passed')
: triggered + ' ' + t('of') + ' ' + total + ' ' + t('checks flagged');
const summaryCls = triggered === 0 ? 'helpdesk-risk-summary-ok'
: triggered <= 2 ? 'helpdesk-risk-summary-warn'
: 'helpdesk-risk-summary-critical';
let html = `<div class="helpdesk-risk-header">
<span class="helpdesk-risk-header-icon ${summaryCls}">${triggered === 0 ? '✓' : '⚠'}</span>
<span class="helpdesk-risk-header-label">${esc(summaryLabel)}</span>
</div>`;
html += '<div class="helpdesk-risk-checks">';
for (const ind of indicators) {
const icon = ind.triggered ? '⚠' : '✓';
const iconCls = ind.triggered ? 'helpdesk-risk-icon-warn' : 'helpdesk-risk-icon-ok';
const barCls = ind.triggered ? 'helpdesk-risk-bar-over' : 'helpdesk-risk-bar-ok';
// Threshold bar: normalised to whichever is larger (value or threshold)
// so the bar always fits inside the track and the limit marker stays visible.
const rawVal = ind.raw_value ?? 0;
const thresholdVal = ind.threshold_value ?? 1;
const scale = Math.max(rawVal, thresholdVal, 1); // prevent division by 0
const fillPct = Math.min(100, (rawVal / scale) * 100);
const limitPct = Math.min(100, (thresholdVal / scale) * 100);
// Format display value with fmtHours for hour-based units
const displayValue = ind.unit === 'h' && typeof rawVal === 'number' && rawVal > 0
? fmtHours(rawVal)
: esc(ind.value);
const limitLabel = ind.unit === 'h' ? t('Limit') + ' ' + fmtHours(thresholdVal) : t('Limit') + ' ' + thresholdVal + ind.unit;
const descText = ind.description ? t(ind.description) : '';
html += `<div class="helpdesk-risk-check">
<div class="helpdesk-risk-check-header">
<span class="helpdesk-risk-icon ${iconCls}">${icon}</span>
<span class="helpdesk-risk-check-label">${esc(t(ind.label) || ind.label)}</span>
</div>
<div class="helpdesk-risk-check-value">${displayValue}</div>
${descText ? `<div class="helpdesk-risk-check-desc">${esc(descText)}</div>` : ''}
<div class="helpdesk-risk-bar-wrapper">
<div class="helpdesk-risk-bar-track">
<div class="helpdesk-risk-bar-fill ${barCls}" style="width:${fillPct}%"></div>
</div>
<div class="helpdesk-risk-bar-limit" style="left:${limitPct}%"></div>
</div>
<div class="helpdesk-risk-bar-legend">${esc(limitLabel)}</div>
</div>`;
}
html += '</div>';
el.innerHTML = html;
}
// Period selector (radio inputs)
const periodSelector = document.getElementById('controlling-period-selector');
on(periodSelector, '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;
void 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');
on(refreshButton, '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();
void initTicketsGrid();
void renderDebitorCommunicationAside();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
observer.disconnect();
const dialog = document.getElementById('helpdesk-contract-dialog');
if (dialog) {
dialog.remove();
}
},
};
}