Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-detail.js
fs d6835d3b7b fix(helpdesk): remove contracts KPI section from support dashboard
The Contracts & products KPI tiles (active contracts, monthly volume,
support hours, next invoicing) distract from the ticket-focused support
view. Contract data remains available in the dedicated Sales tab.

Removes: section HTML, skeleton placeholder, JS render function + call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:01:50 +02:00

1459 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Helpdesk debtor detail page — role-based dashboard rendering.
*
* - Support tab: health tiles, system recommendations, contracts/products widget, tickets grid
* - Sales tab: contact table (lazy-loaded)
* - Controlling tab: placeholder
* - Aside: cross-ticket communication feed
*/
import { initStandardListPage } from '/js/core/app-grid-factory.js';
import { readPageConfig } from '/js/core/app-page-config.js';
import {
renderCommunicationError,
renderCommunicationFeed,
renderCommunicationHint,
} from './helpdesk-communication.js';
import { renderEmptyState } from './helpdesk-empty-state.js';
const container = document.querySelector('.app-details-container[data-customer-no]');
if (container) {
init(container);
}
function init(container) {
const customerNo = container.dataset.customerNo || '';
const customerName = container.dataset.customerName || '';
const supportDashboardUrl = container.dataset.supportDashboardUrl || '';
const contactsUrl = container.dataset.contactsUrl || '';
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
const communicationUrl = container.dataset.communicationUrl || '';
const salesDashboardUrl = container.dataset.salesDashboardUrl || '';
const refreshFromUrl = (() => {
const value = new URLSearchParams(window.location.search).get('refresh');
if (!value) return false;
const normalized = String(value).trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
})();
if (!customerNo || !customerName) return;
const i18nEl = document.getElementById('helpdesk-i18n');
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
const t = (key) => i18n[key] || key;
let supportDashboardPromise = null;
let salesDashboardPromise = null;
let contactsPromise = null;
let ticketCategoriesPromise = null;
let debitorCommunicationPromise = null;
let supportTabRendered = false;
let salesTabRendered = false;
let ticketsGridInitialized = false;
let ticketsGridInitializing = false;
function buildBaseParams() {
const params = new URLSearchParams({ customerNo, customerName });
if (refreshFromUrl) {
params.set('refresh', '1');
}
return params;
}
function fetchSupportDashboard() {
if (!supportDashboardPromise) {
if (!supportDashboardUrl) {
supportDashboardPromise = Promise.resolve({ ok: false, error: 'No support dashboard endpoint configured' });
} else {
const params = buildBaseParams();
supportDashboardPromise = fetch(supportDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return supportDashboardPromise;
}
function fetchSalesDashboard() {
if (!salesDashboardPromise) {
if (!salesDashboardUrl) {
salesDashboardPromise = Promise.resolve({ ok: false, error: 'No sales dashboard endpoint configured' });
} else {
const params = buildBaseParams();
salesDashboardPromise = fetch(salesDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return salesDashboardPromise;
}
function fetchContacts() {
if (!contactsPromise) {
const params = buildBaseParams();
contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
return contactsPromise;
}
function fetchTicketCategories(config, appBase) {
if (!ticketCategoriesPromise) {
const categoryOptionsUrl = config.categoryOptionsUrl || '';
if (!categoryOptionsUrl) {
ticketCategoriesPromise = Promise.resolve({ ok: false, categories: [] });
return ticketCategoriesPromise;
}
const url = new URL(categoryOptionsUrl, appBase);
url.searchParams.set('customerNo', config.customerNo || customerNo);
url.searchParams.set('customerName', config.customerName || customerName);
if (refreshFromUrl) {
url.searchParams.set('refresh', '1');
}
ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, categories: [] }));
}
return ticketCategoriesPromise;
}
function fetchDebitorCommunication() {
if (!debitorCommunicationPromise) {
if (!communicationUrl) {
debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
} else {
const params = buildBaseParams();
debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return debitorCommunicationPromise;
}
/**
* 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 fetch(typeOptionsUrl.toString()).then((r) => r.json()).catch(() => null);
const typeOptions = Array.isArray(typeResp?.type_options) ? typeResp.type_options : [];
hydrateContactTypeFilter(contactsConfig, typeOptions);
const gridOptions = {
gridjs: gridjsLib,
container: '#helpdesk-contacts-grid',
dataUrl: gridDataUrl.toString(),
appBase,
columns: [
{ name: labels.name || 'Name', sort: true },
{ name: labels.type || 'Type', sort: true },
{ name: labels.jobTitle || 'Job title', sort: true },
{ name: labels.email || 'E-Mail', sort: true },
{ name: labels.phone || 'Phone', sort: true },
{ name: labels.mobile || 'Mobile', sort: false },
],
sortColumns: ['Name', 'Type', 'Job_Title', 'E_Mail', 'Phone_No', null],
paginationLimit: 10,
language: contactsConfig.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
row.Name || '',
row.Type || '',
row.Job_Title || '',
row.E_Mail || '',
row.Phone_No || '',
row.Mobile_Phone_No || '',
]),
search: contactsConfig.gridSearch || null,
filterSchema: contactsConfig.filterSchema || [],
urlSync: false,
actions: { enabled: false },
};
// Scope filter UI to the contacts section to avoid collision with tickets filter
const contactsSection = document.getElementById('sales-contacts-section');
const contactsChipsEl = contactsSection
? contactsSection.querySelector('[data-active-filter-chips]')
: null;
const contactsLiveEl = contactsSection
? contactsSection.querySelector('[data-filter-live-region]')
: null;
initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: contactsConfig.filterChipMeta || {},
watchInputs: ['#helpdesk-contact-search'],
drawerRoot: contactsSection || undefined,
chips: contactsChipsEl ? { container: contactsChipsEl } : {},
live: contactsLiveEl ? { regionSelector: null } : {},
},
});
contactsGridInitialized = true;
} finally {
contactsGridInitializing = false;
}
}
function hydrateContactTypeFilter(contactsConfig, typeOptions) {
const select = document.getElementById('helpdesk-contact-type-filter');
if (!(select instanceof HTMLSelectElement) || !Array.isArray(typeOptions)) return;
const currentValue = String(select.value || '');
select.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = t('All');
select.appendChild(allOption);
const typeOptionMap = {};
for (const opt of typeOptions) {
const id = String(opt.id || '').trim();
const label = String(opt.description || opt.id || '').trim();
if (id === '') continue;
const option = document.createElement('option');
option.value = id;
option.textContent = label;
select.appendChild(option);
typeOptionMap[id] = label;
}
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(typeOptionMap, currentValue)) {
select.value = currentValue;
} else {
select.value = '';
}
if (!contactsConfig.filterChipMeta || typeof contactsConfig.filterChipMeta !== 'object') {
contactsConfig.filterChipMeta = {};
}
const currentTypeMeta = (contactsConfig.filterChipMeta.type && typeof contactsConfig.filterChipMeta.type === 'object')
? contactsConfig.filterChipMeta.type
: {};
contactsConfig.filterChipMeta.type = {
...currentTypeMeta,
type: 'select',
default: '',
options: typeOptionMap,
};
}
function formatTemplate(template, params) {
const tpl = String(template || '');
const map = (params && typeof params === 'object') ? params : {};
return tpl.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => {
const value = map[key];
if (value === null || value === undefined) {
return '';
}
return String(value);
});
}
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');
dialog.addEventListener('click', (e) => {
if (e.target.closest('[data-dialog-close]')) {
dialog.close();
dialog.remove();
return;
}
// Close on backdrop click
if (e.target === dialog) {
dialog.close();
dialog.remove();
}
});
dialog.addEventListener('close', () => { dialog.remove(); });
dialog.showModal();
}
// Delegate click on contract cards and timeline rows
container.addEventListener('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
container.addEventListener('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;
}
}
container.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
const row = e.target.closest('.app-clickable-row[data-href]');
if (row) {
const href = row.dataset.href;
if (href) {
window.location.href = href;
}
}
});
container.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = e.target.closest('.app-clickable-row[data-href]');
if (row) {
e.preventDefault();
const href = row.dataset.href;
if (href) {
window.location.href = href;
}
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 },
};
initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-ticket-search'],
},
});
ticketsGridInitialized = true;
} finally {
ticketsGridInitializing = false;
}
}
async function renderSupportTab() {
if (supportTabRendered) return;
showLoading('support-loading');
const result = await fetchSupportDashboard();
hideLoading('support-loading');
showContent('support-content');
const recommendationsEl = document.getElementById('support-system-recommendations');
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();
salesTabRendered = true;
}
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,
});
feedEl.innerHTML = html;
}
// --- Controlling tab ---
const controllingDashboardUrl = container.dataset.controllingDashboardUrl || '';
let controllingTabRendered = false;
let controllingCurrentPeriod = 90;
async function fetchControllingDashboard(periodDays) {
const params = buildBaseParams();
params.set('periodDays', String(periodDays));
const url = controllingDashboardUrl + '?' + params.toString();
try {
const res = await fetch(url, { credentials: 'same-origin' });
return await res.json();
} catch {
return null;
}
}
async function renderControllingTab(periodDays) {
showLoading('controlling-spinner');
const contentEl = document.getElementById('controlling-content');
const errorEl = document.getElementById('controlling-error');
const emptyEl = document.getElementById('controlling-empty');
if (contentEl) contentEl.hidden = true;
if (errorEl) errorEl.hidden = true;
if (emptyEl) emptyEl.hidden = true;
const result = await fetchControllingDashboard(periodDays);
hideLoading('controlling-spinner');
if (!result?.ok) {
if (errorEl) {
errorEl.innerHTML = errorNotice(t('Could not load controlling dashboard.'));
errorEl.hidden = false;
}
controllingTabRendered = true;
return;
}
const kpis = result.kpis || {};
const meta = result.meta || {};
if (meta.created_in_period === 0 && meta.created_in_prev === 0 && kpis.backlog_open === 0) {
if (emptyEl) {
emptyEl.innerHTML = renderEmptyState({
message: t('No controlling data available.'),
size: 'compact',
icon: false,
});
emptyEl.hidden = false;
}
controllingTabRendered = true;
return;
}
if (contentEl) contentEl.hidden = false;
// --- KPI 1: Ticket Volume ---
const volume = Number(kpis.volume ?? 0);
const volumePrev = Number(kpis.volume_prev ?? 0);
const volumeDelta = Number(kpis.volume_delta ?? 0);
setText('controlling-kpi-volume', String(volume));
if (volumeDelta !== 0 || volumePrev > 0) {
const arrow = volumeDelta > 0 ? '↑' : volumeDelta < 0 ? '↓' : '—';
const abs = Math.abs(volumeDelta);
// More tickets = more load = negative for controlling
const cls = volumeDelta > 0 ? 'helpdesk-kpi-trend-negative'
: volumeDelta < 0 ? 'helpdesk-kpi-trend-positive'
: 'helpdesk-kpi-trend-neutral';
setHtml('controlling-kpi-volume-trend',
`<span class="${cls}">${arrow} ${abs} ${esc(t('vs. prev. period'))}</span>`
);
}
// --- KPI 2: Close-Rate ---
const closeRate = kpis.close_rate;
const closeRatePrev = kpis.close_rate_prev;
setText('controlling-kpi-closerate', closeRate != null ? closeRate.toFixed(1) + '×' : '—');
if (closeRate != null && closeRatePrev != null) {
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive'
: 'helpdesk-kpi-trend-negative';
const prevLabel = closeRatePrev.toFixed(1) + '×';
setHtml('controlling-kpi-closerate-trend',
`<span class="${cls}">${esc(t('vs. prev. period'))}: ${prevLabel}</span>`
);
} else if (closeRate != null) {
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
const label = closeRate >= 1.0 ? t('Backlog shrinking') : t('Backlog growing');
setHtml('controlling-kpi-closerate-trend', `<span class="${cls}">${esc(label)}</span>`);
}
// --- KPI 3: Avg. Resolution ---
const resolution = kpis.resolution_hours;
const resolutionPrev = kpis.resolution_hours_prev;
setText('controlling-kpi-resolution', resolution != null ? fmtHours(resolution) : '—');
if (resolution != null && resolutionPrev != null) {
const delta = resolution - resolutionPrev;
if (delta === 0) {
setHtml('controlling-kpi-resolution-trend',
`<span class="helpdesk-kpi-trend-neutral">— ${esc(t('vs. prev. period'))}</span>`
);
} else {
const arrow = delta > 0 ? '↑' : '↓';
const absDelta = Math.abs(Math.round(delta));
// Lower resolution = better
const cls = delta < 0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
setHtml('controlling-kpi-resolution-trend',
`<span class="${cls}">${arrow} ${fmtHours(absDelta)} ${esc(t('vs. prev. period'))}</span>`
);
}
}
// --- KPI 4: Open Backlog ---
const backlog = Number(kpis.backlog_open ?? 0);
const unassigned = Number(kpis.backlog_unassigned ?? 0);
const critical = Number(kpis.backlog_critical ?? 0);
setText('controlling-kpi-backlog', String(backlog));
const parts = [];
if (unassigned > 0) parts.push(unassigned + ' ' + t('unassigned'));
if (critical > 0) parts.push(critical + ' ' + t('critical'));
if (parts.length > 0) {
const cls = critical > 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral';
setHtml('controlling-kpi-backlog-trend', `<span class="${cls}">${esc(parts.join(', '))}</span>`);
}
// Trend chart
renderTrendChart(result.trend || []);
// 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');
if (periodSelector) {
periodSelector.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="controlling-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === controllingCurrentPeriod) return;
controllingCurrentPeriod = period;
controllingTabRendered = false;
renderControllingTab(period);
});
}
const tabPanels = container.querySelectorAll('[data-tab-panel]');
const observer = new MutationObserver(() => {
for (const panel of tabPanels) {
if (panel.hidden) continue;
const tabName = panel.dataset.tabPanel;
if (tabName === 'support') {
renderSupportTab();
initTicketsGrid();
} else if (tabName === 'sales') {
renderSalesTab();
} else if (tabName === 'controlling') {
if (!controllingTabRendered) {
renderControllingTab(controllingCurrentPeriod);
}
}
}
});
for (const panel of tabPanels) {
observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] });
}
const refreshButton = container.querySelector('button[name="support-refresh"]')
|| document.getElementById('support-refresh-button');
if (refreshButton) {
refreshButton.addEventListener('click', () => {
refreshButton.setAttribute('aria-busy', 'true');
refreshButton.setAttribute('disabled', 'disabled');
const url = new URL(window.location.href);
url.searchParams.set('refresh', '1');
window.location.assign(url.toString());
});
}
renderSupportTab();
initTicketsGrid();
renderDebitorCommunicationAside();
}