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

1626 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Helpdesk debtor detail page — role-based dashboard rendering.
*
* - Support tab: health tiles, system recommendations, contracts/products widget, tickets grid
* - Sales tab: contact table (lazy-loaded)
* - Controlling tab: placeholder
* - Aside: cross-ticket communication feed
*/
import { 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();
}
},
};
}