2026-04-02 17:48:27 +02:00
|
|
|
|
/**
|
2026-04-04 18:34:03 +02:00
|
|
|
|
* Helpdesk debtor detail page — role-based dashboard rendering.
|
2026-04-02 17:48:27 +02:00
|
|
|
|
*
|
2026-04-04 18:34:03 +02:00
|
|
|
|
* - 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
|
2026-04-02 17:48:27 +02:00
|
|
|
|
*/
|
|
|
|
|
|
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
|
|
|
|
|
import { readPageConfig } from '/js/core/app-page-config.js';
|
2026-04-03 16:45:41 +02:00
|
|
|
|
import {
|
|
|
|
|
|
renderCommunicationError,
|
|
|
|
|
|
renderCommunicationFeed,
|
|
|
|
|
|
renderCommunicationHint,
|
|
|
|
|
|
} from './helpdesk-communication.js';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
import { renderEmptyState } from './helpdesk-empty-state.js';
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
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 || '';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
const supportDashboardUrl = container.dataset.supportDashboardUrl || '';
|
2026-04-02 17:48:27 +02:00
|
|
|
|
const contactsUrl = container.dataset.contactsUrl || '';
|
|
|
|
|
|
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
|
2026-04-03 16:45:41 +02:00
|
|
|
|
const communicationUrl = container.dataset.communicationUrl || '';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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';
|
|
|
|
|
|
})();
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
if (!customerNo || !customerName) return;
|
|
|
|
|
|
|
|
|
|
|
|
const i18nEl = document.getElementById('helpdesk-i18n');
|
|
|
|
|
|
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
|
|
|
|
|
|
const t = (key) => i18n[key] || key;
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
let supportDashboardPromise = null;
|
|
|
|
|
|
let salesDashboardPromise = null;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
let contactsPromise = null;
|
|
|
|
|
|
let ticketCategoriesPromise = null;
|
2026-04-03 16:45:41 +02:00
|
|
|
|
let debitorCommunicationPromise = null;
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
|
|
let supportTabRendered = false;
|
|
|
|
|
|
let salesTabRendered = false;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
let ticketsGridInitialized = false;
|
|
|
|
|
|
let ticketsGridInitializing = false;
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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' }));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
function fetchContacts() {
|
|
|
|
|
|
if (!contactsPromise) {
|
2026-04-04 18:34:03 +02:00
|
|
|
|
const params = buildBaseParams();
|
2026-04-02 17:48:27 +02:00
|
|
|
|
contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.catch(() => ({ ok: false, error: 'Network error' }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
return contactsPromise;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (refreshFromUrl) {
|
|
|
|
|
|
url.searchParams.set('refresh', '1');
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' })
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.catch(() => ({ ok: false, categories: [] }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ticketCategoriesPromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 16:45:41 +02:00
|
|
|
|
function fetchDebitorCommunication() {
|
|
|
|
|
|
if (!debitorCommunicationPromise) {
|
|
|
|
|
|
if (!communicationUrl) {
|
|
|
|
|
|
debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
|
|
|
|
|
|
} else {
|
2026-04-04 18:34:03 +02:00
|
|
|
|
const params = buildBaseParams();
|
2026-04-03 16:45:41 +02:00
|
|
|
|
debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.catch(() => ({ ok: false, error: 'Network error' }));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return debitorCommunicationPromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
|
function hydrateTicketCategoryFilter(config, categories) {
|
|
|
|
|
|
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
|
|
|
|
|
|
if (!(categoryFilter instanceof HTMLSelectElement)) return;
|
|
|
|
|
|
|
|
|
|
|
|
const uniqueCategories = Array.from(new Set(
|
|
|
|
|
|
(Array.isArray(categories) ? categories : [])
|
|
|
|
|
|
.map(item => String(item || '').trim())
|
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
|
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
|
|
|
|
|
|
|
|
|
|
|
const currentValue = String(categoryFilter.value || '');
|
|
|
|
|
|
categoryFilter.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
const allOption = document.createElement('option');
|
|
|
|
|
|
allOption.value = '';
|
|
|
|
|
|
allOption.textContent = t('All');
|
|
|
|
|
|
categoryFilter.appendChild(allOption);
|
|
|
|
|
|
|
|
|
|
|
|
const categoryOptionMap = {};
|
|
|
|
|
|
for (const category of uniqueCategories) {
|
|
|
|
|
|
const option = document.createElement('option');
|
|
|
|
|
|
option.value = category;
|
|
|
|
|
|
option.textContent = category;
|
|
|
|
|
|
categoryFilter.appendChild(option);
|
|
|
|
|
|
categoryOptionMap[category] = category;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) {
|
|
|
|
|
|
categoryFilter.value = currentValue;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
categoryFilter.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
|
|
|
|
|
|
config.filterChipMeta = {};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object')
|
|
|
|
|
|
? config.filterChipMeta.category
|
|
|
|
|
|
: {};
|
|
|
|
|
|
|
|
|
|
|
|
config.filterChipMeta.category = {
|
|
|
|
|
|
...currentCategoryMeta,
|
|
|
|
|
|
type: 'select',
|
|
|
|
|
|
default: '',
|
|
|
|
|
|
options: categoryOptionMap,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function esc(str) {
|
|
|
|
|
|
const d = document.createElement('div');
|
|
|
|
|
|
d.textContent = str;
|
|
|
|
|
|
return d.innerHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
|
try {
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// 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;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(typeOptionMap, currentValue)) {
|
|
|
|
|
|
select.value = currentValue;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
select.value = '';
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (!contactsConfig.filterChipMeta || typeof contactsConfig.filterChipMeta !== 'object') {
|
|
|
|
|
|
contactsConfig.filterChipMeta = {};
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
const currentTypeMeta = (contactsConfig.filterChipMeta.type && typeof contactsConfig.filterChipMeta.type === 'object')
|
|
|
|
|
|
? contactsConfig.filterChipMeta.type
|
|
|
|
|
|
: {};
|
|
|
|
|
|
|
|
|
|
|
|
contactsConfig.filterChipMeta.type = {
|
|
|
|
|
|
...currentTypeMeta,
|
|
|
|
|
|
type: 'select',
|
|
|
|
|
|
default: '',
|
|
|
|
|
|
options: typeOptionMap,
|
|
|
|
|
|
};
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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 || '—');
|
|
|
|
|
|
};
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
let html = '<table><thead><tr>';
|
|
|
|
|
|
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
2026-04-04 18:34:03 +02:00
|
|
|
|
html += `<th>${esc(t('Action'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('Age (h)'))}</th>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr></thead><tbody>';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
|
const ticketNo = String(entry.ticket_no || '');
|
|
|
|
|
|
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
|
|
|
|
|
const ageHours = Number.isFinite(Number(entry.age_hours)) ? String(entry.age_hours) : '—';
|
|
|
|
|
|
const rowAttrs = href ? ` class="app-clickable-row" data-href="${esc(href)}" tabindex="0"` : '';
|
|
|
|
|
|
html += `<tr${rowAttrs}>`;
|
|
|
|
|
|
html += ticketNo && href
|
|
|
|
|
|
? `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`
|
|
|
|
|
|
: `<td>${esc(ticketNo || '—')}</td>`;
|
|
|
|
|
|
html += `<td>${esc(formatRecommendationMessage(entry))}</td>`;
|
|
|
|
|
|
html += `<td>${esc(ageHours)}</td>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr>';
|
|
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tbody></table>';
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
function renderSupportContracts(entries) {
|
|
|
|
|
|
const contracts = Array.isArray(entries) ? entries : [];
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (contracts.length === 0) {
|
|
|
|
|
|
return renderEmptyState({
|
|
|
|
|
|
message: t('No contracts found for this customer.'),
|
|
|
|
|
|
size: 'compact',
|
|
|
|
|
|
align: 'center',
|
|
|
|
|
|
icon: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
const formatDate = (value) => {
|
|
|
|
|
|
const raw = String(value || '').trim();
|
|
|
|
|
|
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
|
|
|
|
|
const date = new Date(raw);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return raw;
|
|
|
|
|
|
return date.toLocaleDateString();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const amountFormatter = new Intl.NumberFormat(undefined, {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
|
maximumFractionDigits: 2,
|
|
|
|
|
|
});
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
let html = '<table><thead><tr>';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
html += `<th>${esc(t('Contract No.'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('Product type'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('State'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('Next invoicing'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('Amount'))}</th>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr></thead><tbody>';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
|
|
for (const contract of contracts.slice(0, 20)) {
|
|
|
|
|
|
const amount = Number(contract.total_payoff_amount ?? 0);
|
|
|
|
|
|
html += '<tr>';
|
|
|
|
|
|
html += `<td>${esc(contract.contract_no || '')}</td>`;
|
|
|
|
|
|
html += `<td>${esc(contract.product_type || '—')}</td>`;
|
|
|
|
|
|
html += `<td>${esc(contract.state || '—')}</td>`;
|
|
|
|
|
|
html += `<td>${esc(formatDate(contract.next_invoicing_date))}</td>`;
|
|
|
|
|
|
html += `<td>${esc(amountFormatter.format(Number.isFinite(amount) ? amount : 0))}</td>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr>';
|
|
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tbody></table>';
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
function contractTypeLabel(productType) {
|
|
|
|
|
|
return String(productType || '').trim() || '—';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ACCENT_COUNT = 8;
|
|
|
|
|
|
const accentTypeMap = new Map();
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
function contractAccentClass(productType) {
|
|
|
|
|
|
const key = String(productType || '').trim().toUpperCase();
|
|
|
|
|
|
if (key === '') return 'accent-0';
|
|
|
|
|
|
if (!accentTypeMap.has(key)) {
|
|
|
|
|
|
accentTypeMap.set(key, accentTypeMap.size % ACCENT_COUNT);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
return 'accent-' + accentTypeMap.get(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function contractIcon(productType) {
|
|
|
|
|
|
const type = String(productType || '').trim().toLowerCase();
|
|
|
|
|
|
if (type === 'wa') return 'bi-tools';
|
|
|
|
|
|
if (type === 'fb') return 'bi-cash-stack';
|
|
|
|
|
|
if (type === 'lz') return 'bi-key';
|
|
|
|
|
|
if (type === 'ho') return 'bi-cloud';
|
|
|
|
|
|
if (type === 'su') return 'bi-headset';
|
|
|
|
|
|
if (type === 'sw') return 'bi-code-square';
|
|
|
|
|
|
return 'bi-file-earmark-text';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Shared formatters for contract rendering
|
|
|
|
|
|
const contractAmountFmt = new Intl.NumberFormat(undefined, {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
|
maximumFractionDigits: 2,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const contractAmountFmtShort = new Intl.NumberFormat(undefined, {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
|
maximumFractionDigits: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function contractFormatDate(value) {
|
|
|
|
|
|
const raw = String(value || '').trim();
|
|
|
|
|
|
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
|
|
|
|
|
const date = new Date(raw);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return raw;
|
|
|
|
|
|
return date.toLocaleDateString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function contractStateClass(state) {
|
|
|
|
|
|
const s = String(state || '').toLowerCase();
|
|
|
|
|
|
if (s === 'aktiv') return 'state-active';
|
|
|
|
|
|
if (s === 'vorbereitung') return 'state-prep';
|
|
|
|
|
|
return 'state-ended';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function contractStateVariant(state) {
|
|
|
|
|
|
const s = String(state || '').toLowerCase();
|
|
|
|
|
|
if (s === 'aktiv') return 'success';
|
|
|
|
|
|
if (s === 'vorbereitung') return 'warning';
|
|
|
|
|
|
return 'neutral';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Store all contracts for dialog access */
|
|
|
|
|
|
let salesContracts = [];
|
|
|
|
|
|
|
|
|
|
|
|
function renderContractCards(contracts) {
|
|
|
|
|
|
if (!Array.isArray(contracts) || contracts.length === 0) {
|
|
|
|
|
|
return renderEmptyState({
|
|
|
|
|
|
message: t('No contract details available.'),
|
|
|
|
|
|
size: 'compact',
|
|
|
|
|
|
align: 'center',
|
|
|
|
|
|
icon: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
salesContracts = contracts;
|
|
|
|
|
|
|
|
|
|
|
|
let html = '<div class="helpdesk-contract-cards">';
|
|
|
|
|
|
for (let i = 0; i < contracts.length; i++) {
|
|
|
|
|
|
const contract = contracts[i];
|
|
|
|
|
|
const accent = contractAccentClass(contract.product_type);
|
|
|
|
|
|
const stateClass = contractStateClass(contract.state);
|
|
|
|
|
|
const monthlyAmt = Number(contract.monthly_amount ?? 0);
|
|
|
|
|
|
const lines = Array.isArray(contract.lines) ? contract.lines : [];
|
|
|
|
|
|
const typeLabel = contractTypeLabel(contract.product_type);
|
|
|
|
|
|
|
|
|
|
|
|
html += `<button type="button" class="helpdesk-contract-card ${accent} ${stateClass}" data-contract-index="${i}">`;
|
|
|
|
|
|
|
|
|
|
|
|
// Status badge — top-right
|
|
|
|
|
|
html += `<span class="helpdesk-contract-card-status">`;
|
|
|
|
|
|
html += `<span class="helpdesk-contract-card-dot"></span>${esc(contract.state || '—')}`;
|
|
|
|
|
|
html += `</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
// Type label (uppercase, prominent)
|
|
|
|
|
|
html += `<span class="helpdesk-contract-card-type">${esc(typeLabel)}</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
// Amount hero — or last invoicing date for inactive contracts
|
|
|
|
|
|
if (monthlyAmt > 0) {
|
|
|
|
|
|
html += `<span class="helpdesk-contract-card-amount">${esc(contractAmountFmtShort.format(monthlyAmt))}</span>`;
|
|
|
|
|
|
html += `<span class="helpdesk-contract-card-period">${esc(t('per month'))}</span>`;
|
|
|
|
|
|
} else if (stateClass === 'state-ended' && contract.next_invoicing_date) {
|
|
|
|
|
|
const dateStr = contractFormatDate(contract.next_invoicing_date);
|
|
|
|
|
|
if (dateStr !== '—') {
|
|
|
|
|
|
html += `<span class="helpdesk-contract-card-amount helpdesk-contract-card-amount-muted">${esc(dateStr)}</span>`;
|
|
|
|
|
|
html += `<span class="helpdesk-contract-card-period">${esc(t('Last invoicing'))}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderContractLinesTable(lines) {
|
|
|
|
|
|
if (!Array.isArray(lines) || lines.length === 0) {
|
|
|
|
|
|
return `<p class="muted">${esc(t('No contract details available.'))}</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
|
let html = '<table><thead><tr>';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr></thead><tbody>';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
const lineVariant = String(line.line_state || '').toLowerCase() === 'aktiv' ? 'success' : 'neutral';
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '<tr>';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</tbody></table>';
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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>`);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (contract.invoicing_period) {
|
|
|
|
|
|
metaItems.push(`<span><strong>${esc(t('Invoicing period'))}</strong>${esc(contract.invoicing_period)}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (contract.next_invoicing_date) {
|
|
|
|
|
|
metaItems.push(`<span><strong>${esc(t('Next invoicing'))}</strong>${esc(contractFormatDate(contract.next_invoicing_date))}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Number(contract.monthly_amount ?? 0) > 0) {
|
|
|
|
|
|
metaItems.push(`<span><strong>${esc(t('Monthly volume'))}</strong>${esc(contractAmountFmt.format(contract.monthly_amount))}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (contract.salesperson_code) {
|
|
|
|
|
|
metaItems.push(`<span><strong>${esc(t('Salesperson'))}</strong>${esc(contract.salesperson_code)}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Number(contract.support_hours ?? 0) > 0) {
|
|
|
|
|
|
metaItems.push(`<span><strong>${esc(t('Support hours'))}</strong>${esc(String(contract.support_hours))}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
dialogHtml += metaItems.join('');
|
|
|
|
|
|
dialogHtml += '</div>';
|
|
|
|
|
|
|
|
|
|
|
|
// Lines table
|
|
|
|
|
|
if (lines.length > 0) {
|
|
|
|
|
|
dialogHtml += `<h3 class="helpdesk-contract-dialog-section-title">${esc(t('positions'))} (${lines.length})</h3>`;
|
|
|
|
|
|
dialogHtml += '<div class="helpdesk-contract-dialog-lines">';
|
|
|
|
|
|
dialogHtml += renderContractLinesTable(lines);
|
|
|
|
|
|
dialogHtml += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Footer
|
|
|
|
|
|
dialogHtml += '<footer>';
|
|
|
|
|
|
dialogHtml += `<button type="button" class="secondary outline" data-dialog-close>${esc(t('Close'))}</button>`;
|
|
|
|
|
|
dialogHtml += '</footer>';
|
|
|
|
|
|
|
|
|
|
|
|
dialogHtml += '</article></dialog>';
|
|
|
|
|
|
|
|
|
|
|
|
document.body.insertAdjacentHTML('beforeend', dialogHtml);
|
|
|
|
|
|
const dialog = document.getElementById('helpdesk-contract-dialog');
|
|
|
|
|
|
dialog.addEventListener('click', (e) => {
|
|
|
|
|
|
if (e.target.closest('[data-dialog-close]')) {
|
|
|
|
|
|
dialog.close();
|
|
|
|
|
|
dialog.remove();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Close on backdrop click
|
|
|
|
|
|
if (e.target === dialog) {
|
|
|
|
|
|
dialog.close();
|
|
|
|
|
|
dialog.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
dialog.addEventListener('close', () => { dialog.remove(); });
|
|
|
|
|
|
dialog.showModal();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delegate click on contract cards
|
|
|
|
|
|
container.addEventListener('click', (e) => {
|
|
|
|
|
|
const card = e.target.closest('.helpdesk-contract-card[data-contract-index]');
|
|
|
|
|
|
if (!card) return;
|
|
|
|
|
|
const index = Number(card.dataset.contractIndex);
|
|
|
|
|
|
if (salesContracts[index]) {
|
|
|
|
|
|
openContractDialog(salesContracts[index]);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// KPI tile click → scroll to ticket list and set status filter
|
|
|
|
|
|
container.addEventListener('click', (e) => {
|
|
|
|
|
|
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter]');
|
|
|
|
|
|
if (!kpi) return;
|
|
|
|
|
|
const filterValue = kpi.dataset.kpiFilter || '';
|
|
|
|
|
|
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
|
|
|
|
|
|
if (statusSelect instanceof HTMLSelectElement) {
|
|
|
|
|
|
statusSelect.value = filterValue;
|
|
|
|
|
|
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
|
}
|
|
|
|
|
|
const ticketSection = document.querySelector('.helpdesk-support-ticket-list');
|
|
|
|
|
|
if (ticketSection) {
|
|
|
|
|
|
ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function renderEscalatedTickets(entries, meta) {
|
|
|
|
|
|
const escalatedEntries = Array.isArray(entries) ? entries : [];
|
|
|
|
|
|
const escalationMeta = (meta && typeof meta === 'object') ? meta : {};
|
|
|
|
|
|
|
|
|
|
|
|
if (escalationMeta.available === false) {
|
|
|
|
|
|
return errorNotice(t('Could not load escalated tickets.'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (escalatedEntries.length === 0) {
|
|
|
|
|
|
return renderEmptyState({
|
|
|
|
|
|
message: t('No escalated tickets right now.'),
|
|
|
|
|
|
size: 'compact',
|
|
|
|
|
|
align: 'center',
|
|
|
|
|
|
icon: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatDateTime = (value) => {
|
|
|
|
|
|
const raw = String(value || '').trim();
|
|
|
|
|
|
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
|
|
|
|
|
const date = new Date(raw);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return raw;
|
|
|
|
|
|
return date.toLocaleString();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatDuration = (secondsValue) => {
|
|
|
|
|
|
const totalSeconds = Number(secondsValue);
|
|
|
|
|
|
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
|
|
|
|
|
return '—';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const seconds = Math.floor(totalSeconds);
|
|
|
|
|
|
const days = Math.floor(seconds / 86400);
|
|
|
|
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
|
|
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
|
|
|
|
|
|
|
|
if (days > 0) {
|
|
|
|
|
|
const dayLabel = days === 1 ? t('day') : t('days');
|
|
|
|
|
|
return hours > 0 ? `${days} ${dayLabel} ${hours} ${t('hours')}` : `${days} ${dayLabel}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hours > 0) {
|
|
|
|
|
|
return `${hours} ${t('hours')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `${Math.max(1, minutes)} min`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let html = '<table><thead><tr>';
|
|
|
|
|
|
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('Escalation code'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('SLA target'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('Last activity'))}</th>`;
|
|
|
|
|
|
html += `<th>${esc(t('Overdue by'))}</th>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr></thead><tbody>';
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
|
|
for (const entry of escalatedEntries.slice(0, 25)) {
|
|
|
|
|
|
const ticketNo = String(entry.ticket_no || '');
|
|
|
|
|
|
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
|
|
|
|
|
html += `<tr class="app-clickable-row" data-href="${esc(href)}" tabindex="0">`;
|
|
|
|
|
|
html += `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`;
|
|
|
|
|
|
html += `<td>${esc(String(entry.escalation_code || '—'))}</td>`;
|
|
|
|
|
|
html += `<td>${esc(formatDuration(entry.target_seconds))}</td>`;
|
|
|
|
|
|
html += `<td>${esc(formatDateTime(entry.last_activity_iso))}</td>`;
|
|
|
|
|
|
html += `<td>${esc(formatDuration(entry.overdue_seconds))}</td>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
html += '</tr>';
|
|
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
|
|
html += '</tbody></table>';
|
2026-04-02 17:48:27 +02:00
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
function updateBadge(id, count) {
|
|
|
|
|
|
const badge = document.getElementById(id);
|
|
|
|
|
|
if (badge) {
|
|
|
|
|
|
badge.textContent = String(count);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
function setText(id, value) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (el) {
|
|
|
|
|
|
el.textContent = String(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
function setHtml(id, html) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (el) {
|
|
|
|
|
|
el.innerHTML = html;
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
function showLoading(id) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
2026-04-02 21:05:54 +02:00
|
|
|
|
if (el) {
|
|
|
|
|
|
el.setAttribute('aria-busy', 'true');
|
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hideLoading(id) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
2026-04-02 21:05:54 +02:00
|
|
|
|
if (el) {
|
|
|
|
|
|
el.removeAttribute('aria-busy');
|
|
|
|
|
|
el.hidden = true;
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showContent(id) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (el) {
|
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
container.addEventListener('click', (e) => {
|
2026-04-02 21:05:54 +02:00
|
|
|
|
if (e.target.closest('a')) return;
|
|
|
|
|
|
const row = e.target.closest('.app-clickable-row[data-href]');
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
const href = row.dataset.href;
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (href) {
|
|
|
|
|
|
window.location.href = href;
|
|
|
|
|
|
}
|
2026-04-02 21:05:54 +02:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (href) {
|
|
|
|
|
|
window.location.href = href;
|
|
|
|
|
|
}
|
2026-04-02 21:05:54 +02:00
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (refreshFromUrl) {
|
|
|
|
|
|
gridDataUrl.searchParams.set('refresh', '1');
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const categoryResponse = await fetchTicketCategories(config, appBase);
|
|
|
|
|
|
hydrateTicketCategoryFilter(config, categoryResponse?.categories || []);
|
|
|
|
|
|
|
|
|
|
|
|
const gridOptions = {
|
|
|
|
|
|
gridjs,
|
|
|
|
|
|
container: '#helpdesk-tickets-grid',
|
|
|
|
|
|
dataUrl: gridDataUrl.toString(),
|
|
|
|
|
|
appBase,
|
|
|
|
|
|
columns: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: labels.ticketNo || 'Ticket No.',
|
|
|
|
|
|
sort: true,
|
|
|
|
|
|
formatter: (cell) => gridjs.html(`<a href="${cell.url}">${cell.no}</a>`),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ name: labels.description || 'Description', sort: false },
|
|
|
|
|
|
{
|
|
|
|
|
|
name: labels.status || 'Status',
|
|
|
|
|
|
sort: true,
|
|
|
|
|
|
formatter: (cell) => {
|
|
|
|
|
|
if (!cell || typeof cell !== 'object') return gridjs.html('-');
|
|
|
|
|
|
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || '-'}</span>`);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{ name: labels.category || 'Category', sort: false },
|
|
|
|
|
|
{ name: labels.support || 'Support', sort: false },
|
|
|
|
|
|
{ name: labels.created || 'Created', sort: true },
|
|
|
|
|
|
{ name: labels.lastActivity || 'Last activity', sort: true },
|
|
|
|
|
|
{ name: 'ticket_url', hidden: true },
|
|
|
|
|
|
],
|
|
|
|
|
|
sortColumns: ['No', null, 'Ticket_State', null, null, 'Created_On', 'Last_Activity_Date', null],
|
|
|
|
|
|
paginationLimit: 10,
|
|
|
|
|
|
language: config.gridLang || {},
|
|
|
|
|
|
mapData: (data) => (data.data || []).map((row) => [
|
|
|
|
|
|
{ no: row.No || '', url: row.ticket_url || '#' },
|
|
|
|
|
|
row.Description || '',
|
|
|
|
|
|
{ variant: row.state_variant || 'neutral', label: row.Ticket_State || '' },
|
|
|
|
|
|
row.Category_1_Description || '',
|
|
|
|
|
|
row.Support_User_Name || '',
|
|
|
|
|
|
row.created_formatted || '',
|
|
|
|
|
|
row.last_activity_formatted || '',
|
|
|
|
|
|
row.ticket_url || '',
|
|
|
|
|
|
]),
|
|
|
|
|
|
search: config.gridSearch || null,
|
|
|
|
|
|
filterSchema: config.filterSchema || [],
|
|
|
|
|
|
urlSync: true,
|
|
|
|
|
|
actions: { enabled: false },
|
|
|
|
|
|
rowDblClick: {
|
|
|
|
|
|
getUrl: (rowData) => {
|
|
|
|
|
|
const url = rowData?.cells?.[7]?.data;
|
|
|
|
|
|
return url || '';
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
rowInteraction: { linkColumn: 0 },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
initStandardListPage({
|
|
|
|
|
|
grid: gridOptions,
|
|
|
|
|
|
filters: {
|
|
|
|
|
|
mode: 'drawer',
|
|
|
|
|
|
chipMeta: config.filterChipMeta || {},
|
|
|
|
|
|
watchInputs: ['#helpdesk-ticket-search'],
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
ticketsGridInitialized = true;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
ticketsGridInitializing = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
async function renderSupportTab() {
|
|
|
|
|
|
if (supportTabRendered) return;
|
|
|
|
|
|
|
|
|
|
|
|
showLoading('support-loading');
|
|
|
|
|
|
const result = await fetchSupportDashboard();
|
|
|
|
|
|
hideLoading('support-loading');
|
|
|
|
|
|
showContent('support-content');
|
|
|
|
|
|
|
|
|
|
|
|
const recommendationsEl = document.getElementById('support-system-recommendations');
|
|
|
|
|
|
const contractsEl = document.getElementById('support-contracts-widget');
|
|
|
|
|
|
const escalationEl = document.getElementById('support-escalation-widget');
|
|
|
|
|
|
if (!result?.ok) {
|
|
|
|
|
|
setText('support-kpi-open-tickets', '—');
|
|
|
|
|
|
setText('support-kpi-created-30d', '—');
|
|
|
|
|
|
setText('support-kpi-closed-30d', '—');
|
|
|
|
|
|
setText('support-kpi-avg-age', '—');
|
|
|
|
|
|
if (recommendationsEl) {
|
|
|
|
|
|
recommendationsEl.innerHTML = errorNotice(t('Could not load tickets.'));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (contractsEl) {
|
|
|
|
|
|
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (escalationEl) {
|
|
|
|
|
|
escalationEl.innerHTML = errorNotice(t('Could not load escalated tickets.'));
|
|
|
|
|
|
}
|
|
|
|
|
|
supportTabRendered = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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>`;
|
|
|
|
|
|
};
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// 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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// 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>`;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// Recommendations widget
|
|
|
|
|
|
if (recommendationsEl) {
|
|
|
|
|
|
const recommendations = Array.isArray(result.recommendations)
|
|
|
|
|
|
? result.recommendations
|
|
|
|
|
|
: (Array.isArray(result.actions) ? result.actions : []);
|
|
|
|
|
|
if (recommendations.length > 0) {
|
|
|
|
|
|
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
} else {
|
2026-04-04 18:34:03 +02:00
|
|
|
|
recommendationsEl.innerHTML = renderEmptyState({
|
|
|
|
|
|
message: t('No system recommendations right now.'),
|
|
|
|
|
|
size: 'compact',
|
|
|
|
|
|
});
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// Contracts widget
|
|
|
|
|
|
if (contractsEl) {
|
|
|
|
|
|
const contracts = Array.isArray(result.contracts) ? result.contracts : [];
|
|
|
|
|
|
const contractsMeta = (result.contracts_meta && typeof result.contracts_meta === 'object')
|
|
|
|
|
|
? result.contracts_meta
|
|
|
|
|
|
: {};
|
|
|
|
|
|
|
|
|
|
|
|
if (contracts.length === 0 && contractsMeta.available === false) {
|
|
|
|
|
|
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
|
|
|
|
|
|
} else if (contracts.length > 0) {
|
|
|
|
|
|
contractsEl.innerHTML = renderSupportContracts(contracts);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
} else {
|
2026-04-04 18:34:03 +02:00
|
|
|
|
contractsEl.innerHTML = renderEmptyState({
|
|
|
|
|
|
message: t('No contracts found for this customer.'),
|
|
|
|
|
|
size: 'compact',
|
|
|
|
|
|
});
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// Escalation widget
|
|
|
|
|
|
if (escalationEl) {
|
|
|
|
|
|
const escalationEntries = Array.isArray(result.escalation_entries) ? result.escalation_entries : [];
|
|
|
|
|
|
const escalationMeta = (result.escalation_meta && typeof result.escalation_meta === 'object')
|
|
|
|
|
|
? result.escalation_meta
|
|
|
|
|
|
: {};
|
|
|
|
|
|
|
|
|
|
|
|
if (escalationEntries.length > 0 || escalationMeta.available === false) {
|
|
|
|
|
|
escalationEl.innerHTML = renderEscalatedTickets(escalationEntries, escalationMeta);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
escalationEl.innerHTML = renderEmptyState({
|
|
|
|
|
|
message: t('No escalated tickets right now.'),
|
|
|
|
|
|
size: 'compact',
|
|
|
|
|
|
});
|
2026-04-02 21:46:01 +02:00
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
supportTabRendered = true;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// Render KPIs
|
|
|
|
|
|
const kpis = (result.kpis && typeof result.kpis === 'object') ? result.kpis : {};
|
|
|
|
|
|
const amountFmt = new Intl.NumberFormat(undefined, {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
|
maximumFractionDigits: 0,
|
|
|
|
|
|
});
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// 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>`;
|
|
|
|
|
|
};
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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>`);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
} else {
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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>`);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (totalPositions > 0) {
|
|
|
|
|
|
avgParts.push(`<span class="helpdesk-kpi-trend-neutral">${esc(totalPositions + ' ' + t('positions'))}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
setHtml('sales-kpi-avg-value-trend', avgParts.join(' · '));
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Next invoicing + countdown
|
|
|
|
|
|
setText('sales-kpi-next-invoicing', formatDate(kpis.next_invoicing_date));
|
|
|
|
|
|
const daysUntil = kpis.days_until_next_invoicing;
|
|
|
|
|
|
if (daysUntil !== null && daysUntil !== undefined) {
|
|
|
|
|
|
const cls = daysUntil <= 7 ? 'helpdesk-kpi-trend-negative' : (daysUntil <= 30 ? 'helpdesk-kpi-trend-neutral' : 'helpdesk-kpi-trend-positive');
|
|
|
|
|
|
setHtml('sales-kpi-next-invoicing-trend',
|
|
|
|
|
|
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Render contracts
|
|
|
|
|
|
const detailEl = document.getElementById('sales-contracts-detail');
|
|
|
|
|
|
if (detailEl) {
|
|
|
|
|
|
detailEl.innerHTML = `<h3 class="helpdesk-support-section-title">${esc(t('Contract overview'))}</h3>`
|
|
|
|
|
|
+ renderContractCards(result.contracts || []);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Init contacts grid (server-side, same pattern as tickets)
|
|
|
|
|
|
initContactsGrid();
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
salesTabRendered = true;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 16:45:41 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
// --- Controlling tab ---
|
|
|
|
|
|
|
|
|
|
|
|
const controllingDashboardUrl = container.dataset.controllingDashboardUrl || '';
|
|
|
|
|
|
let controllingTabRendered = false;
|
|
|
|
|
|
let controllingCurrentPeriod = 90;
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchControllingDashboard(periodDays) {
|
|
|
|
|
|
const params = buildBaseParams();
|
|
|
|
|
|
params.set('periodDays', String(periodDays));
|
|
|
|
|
|
const url = controllingDashboardUrl + '?' + params.toString();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url, { credentials: 'same-origin' });
|
|
|
|
|
|
return await res.json();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function renderControllingTab(periodDays) {
|
|
|
|
|
|
showLoading('controlling-spinner');
|
|
|
|
|
|
const contentEl = document.getElementById('controlling-content');
|
|
|
|
|
|
const errorEl = document.getElementById('controlling-error');
|
|
|
|
|
|
const emptyEl = document.getElementById('controlling-empty');
|
|
|
|
|
|
if (contentEl) contentEl.hidden = true;
|
|
|
|
|
|
if (errorEl) errorEl.hidden = true;
|
|
|
|
|
|
if (emptyEl) emptyEl.hidden = true;
|
|
|
|
|
|
|
|
|
|
|
|
const result = await fetchControllingDashboard(periodDays);
|
|
|
|
|
|
hideLoading('controlling-spinner');
|
|
|
|
|
|
|
|
|
|
|
|
if (!result?.ok) {
|
|
|
|
|
|
if (errorEl) {
|
|
|
|
|
|
errorEl.innerHTML = errorNotice(t('Could not load controlling dashboard.'));
|
|
|
|
|
|
errorEl.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
controllingTabRendered = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const kpis = result.kpis || {};
|
|
|
|
|
|
const meta = result.meta || {};
|
|
|
|
|
|
|
|
|
|
|
|
if (meta.created_in_period === 0 && meta.created_in_prev === 0 && kpis.backlog_open === 0) {
|
|
|
|
|
|
if (emptyEl) {
|
|
|
|
|
|
emptyEl.innerHTML = renderEmptyState({
|
|
|
|
|
|
message: t('No controlling data available.'),
|
|
|
|
|
|
size: 'compact',
|
|
|
|
|
|
icon: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
emptyEl.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
controllingTabRendered = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (contentEl) contentEl.hidden = false;
|
|
|
|
|
|
|
|
|
|
|
|
// --- KPI 1: Ticket Volume ---
|
|
|
|
|
|
const volume = Number(kpis.volume ?? 0);
|
|
|
|
|
|
const volumePrev = Number(kpis.volume_prev ?? 0);
|
|
|
|
|
|
const volumeDelta = Number(kpis.volume_delta ?? 0);
|
|
|
|
|
|
setText('controlling-kpi-volume', String(volume));
|
|
|
|
|
|
if (volumeDelta !== 0 || volumePrev > 0) {
|
|
|
|
|
|
const arrow = volumeDelta > 0 ? '↑' : volumeDelta < 0 ? '↓' : '—';
|
|
|
|
|
|
const abs = Math.abs(volumeDelta);
|
|
|
|
|
|
// More tickets = more load = negative for controlling
|
|
|
|
|
|
const cls = volumeDelta > 0 ? 'helpdesk-kpi-trend-negative'
|
|
|
|
|
|
: volumeDelta < 0 ? 'helpdesk-kpi-trend-positive'
|
|
|
|
|
|
: 'helpdesk-kpi-trend-neutral';
|
|
|
|
|
|
setHtml('controlling-kpi-volume-trend',
|
|
|
|
|
|
`<span class="${cls}">${arrow} ${abs} ${esc(t('vs. prev. period'))}</span>`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- KPI 2: Close-Rate ---
|
|
|
|
|
|
const closeRate = kpis.close_rate;
|
|
|
|
|
|
const closeRatePrev = kpis.close_rate_prev;
|
|
|
|
|
|
setText('controlling-kpi-closerate', closeRate != null ? closeRate.toFixed(1) + '×' : '—');
|
|
|
|
|
|
if (closeRate != null && closeRatePrev != null) {
|
|
|
|
|
|
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive'
|
|
|
|
|
|
: 'helpdesk-kpi-trend-negative';
|
|
|
|
|
|
const prevLabel = closeRatePrev.toFixed(1) + '×';
|
|
|
|
|
|
setHtml('controlling-kpi-closerate-trend',
|
|
|
|
|
|
`<span class="${cls}">${esc(t('vs. prev. period'))}: ${prevLabel}</span>`
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (closeRate != null) {
|
|
|
|
|
|
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
|
|
|
|
|
|
const label = closeRate >= 1.0 ? t('Backlog shrinking') : t('Backlog growing');
|
|
|
|
|
|
setHtml('controlling-kpi-closerate-trend', `<span class="${cls}">${esc(label)}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- KPI 3: Avg. Resolution ---
|
|
|
|
|
|
const resolution = kpis.resolution_hours;
|
|
|
|
|
|
const resolutionPrev = kpis.resolution_hours_prev;
|
|
|
|
|
|
setText('controlling-kpi-resolution', resolution != null ? fmtHours(resolution) : '—');
|
|
|
|
|
|
if (resolution != null && resolutionPrev != null) {
|
|
|
|
|
|
const delta = resolution - resolutionPrev;
|
|
|
|
|
|
if (delta === 0) {
|
|
|
|
|
|
setHtml('controlling-kpi-resolution-trend',
|
|
|
|
|
|
`<span class="helpdesk-kpi-trend-neutral">— ${esc(t('vs. prev. period'))}</span>`
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const arrow = delta > 0 ? '↑' : '↓';
|
|
|
|
|
|
const absDelta = Math.abs(Math.round(delta));
|
|
|
|
|
|
// Lower resolution = better
|
|
|
|
|
|
const cls = delta < 0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
|
|
|
|
|
|
setHtml('controlling-kpi-resolution-trend',
|
|
|
|
|
|
`<span class="${cls}">${arrow} ${fmtHours(absDelta)} ${esc(t('vs. prev. period'))}</span>`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- KPI 4: Open Backlog ---
|
|
|
|
|
|
const backlog = Number(kpis.backlog_open ?? 0);
|
|
|
|
|
|
const unassigned = Number(kpis.backlog_unassigned ?? 0);
|
|
|
|
|
|
const critical = Number(kpis.backlog_critical ?? 0);
|
|
|
|
|
|
setText('controlling-kpi-backlog', String(backlog));
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
if (unassigned > 0) parts.push(unassigned + ' ' + t('unassigned'));
|
|
|
|
|
|
if (critical > 0) parts.push(critical + ' ' + t('critical'));
|
|
|
|
|
|
if (parts.length > 0) {
|
|
|
|
|
|
const cls = critical > 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral';
|
|
|
|
|
|
setHtml('controlling-kpi-backlog-trend', `<span class="${cls}">${esc(parts.join(', '))}</span>`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Trend chart
|
|
|
|
|
|
renderTrendChart(result.trend || []);
|
|
|
|
|
|
|
|
|
|
|
|
// Efficiency
|
|
|
|
|
|
renderEfficiencyWidget(result.efficiency || {});
|
|
|
|
|
|
|
|
|
|
|
|
// Risk indicators
|
|
|
|
|
|
renderControllingRiskIndicators(result.risk_indicators || [], result.risk_summary || {});
|
|
|
|
|
|
|
|
|
|
|
|
controllingTabRendered = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderTrendChart(trend) {
|
|
|
|
|
|
const chartEl = document.getElementById('controlling-trend-chart');
|
|
|
|
|
|
if (!chartEl || !trend.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
let max = 0;
|
|
|
|
|
|
for (const bucket of trend) {
|
|
|
|
|
|
if (bucket.created > max) max = bucket.created;
|
|
|
|
|
|
if (bucket.closed > max) max = bucket.closed;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (max === 0) max = 1;
|
|
|
|
|
|
|
|
|
|
|
|
let rowsHtml = '';
|
|
|
|
|
|
for (const bucket of trend) {
|
|
|
|
|
|
const createdPct = Math.round((bucket.created / max) * 100);
|
|
|
|
|
|
const closedPct = Math.round((bucket.closed / max) * 100);
|
|
|
|
|
|
rowsHtml += `<div class="helpdesk-hbar-row">
|
|
|
|
|
|
<span class="helpdesk-hbar-label">${esc(bucket.label)}</span>
|
|
|
|
|
|
<div class="helpdesk-hbar-track">
|
|
|
|
|
|
<div class="helpdesk-hbar helpdesk-hbar-created" style="width:${createdPct}%">
|
|
|
|
|
|
<span class="helpdesk-hbar-value">${bucket.created}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="helpdesk-hbar helpdesk-hbar-closed" style="width:${closedPct}%">
|
|
|
|
|
|
<span class="helpdesk-hbar-value">${bucket.closed}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chartEl.innerHTML = `
|
|
|
|
|
|
<div class="helpdesk-hbar-chart">${rowsHtml}</div>
|
|
|
|
|
|
<div class="helpdesk-hbar-legend">
|
|
|
|
|
|
<span class="helpdesk-hbar-legend-item">
|
|
|
|
|
|
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-created"></span>
|
|
|
|
|
|
${esc(t('Created'))}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="helpdesk-hbar-legend-item">
|
|
|
|
|
|
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-closed"></span>
|
|
|
|
|
|
${esc(t('Closed'))}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderEfficiencyWidget(efficiency) {
|
|
|
|
|
|
const el = document.getElementById('controlling-efficiency');
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
|
|
const rows = [
|
|
|
|
|
|
[t('Avg. resolution'), efficiency.resolution_hours != null ? fmtHours(efficiency.resolution_hours) : '—'],
|
|
|
|
|
|
[t('Oldest open'), efficiency.oldest_open_hours != null ? fmtHours(efficiency.oldest_open_hours) : '—'],
|
|
|
|
|
|
[t('Without assignee'), String(efficiency.unassigned_count ?? 0)],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
el.innerHTML = '<table class="helpdesk-controlling-kv-table">' +
|
|
|
|
|
|
rows.map(([label, value]) => `<tr><td>${esc(label)}</td><td>${esc(value)}</td></tr>`).join('') +
|
|
|
|
|
|
'</table>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderControllingRiskIndicators(indicators, summary) {
|
|
|
|
|
|
const el = document.getElementById('controlling-risk-indicators');
|
|
|
|
|
|
if (!el || !indicators.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
const level = summary.level || 'low';
|
|
|
|
|
|
const levelLabel = t(level.charAt(0).toUpperCase() + level.slice(1));
|
|
|
|
|
|
const levelCls = level === 'high' ? 'helpdesk-kpi-trend-negative'
|
|
|
|
|
|
: level === 'medium' ? 'helpdesk-kpi-trend-neutral'
|
|
|
|
|
|
: 'helpdesk-kpi-trend-positive';
|
|
|
|
|
|
|
|
|
|
|
|
let html = `<div class="helpdesk-controlling-risk-summary">
|
|
|
|
|
|
<span class="${levelCls}">${esc(levelLabel)}</span>
|
|
|
|
|
|
<span class="helpdesk-controlling-risk-summary-count">${summary.triggered ?? 0}/${summary.total ?? 0} ${esc(t('flags'))}</span>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
html += '<div class="helpdesk-controlling-risk-list">';
|
|
|
|
|
|
for (const ind of indicators) {
|
|
|
|
|
|
const dotCls = ind.triggered ? 'helpdesk-controlling-risk-dot-triggered' : 'helpdesk-controlling-risk-dot-ok';
|
|
|
|
|
|
html += `<div class="helpdesk-controlling-risk-row">
|
|
|
|
|
|
<span class="helpdesk-controlling-risk-dot ${dotCls}"></span>
|
|
|
|
|
|
<span class="helpdesk-controlling-risk-label">${esc(t(ind.label) || ind.label)}</span>
|
|
|
|
|
|
<span class="helpdesk-controlling-risk-value">${esc(ind.value)} / ${esc(ind.threshold)}</span>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
el.innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Period selector (radio inputs)
|
|
|
|
|
|
const periodSelector = document.getElementById('controlling-period-selector');
|
|
|
|
|
|
if (periodSelector) {
|
|
|
|
|
|
periodSelector.addEventListener('change', (e) => {
|
|
|
|
|
|
const radio = e.target.closest('input[name="controlling-period"]');
|
|
|
|
|
|
if (!radio) return;
|
|
|
|
|
|
const period = parseInt(radio.value, 10);
|
|
|
|
|
|
if (!period || period === controllingCurrentPeriod) return;
|
|
|
|
|
|
|
|
|
|
|
|
controllingCurrentPeriod = period;
|
|
|
|
|
|
controllingTabRendered = false;
|
|
|
|
|
|
renderControllingTab(period);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-04 18:34:03 +02:00
|
|
|
|
if (tabName === 'support') {
|
|
|
|
|
|
renderSupportTab();
|
2026-04-02 17:48:27 +02:00
|
|
|
|
initTicketsGrid();
|
2026-04-04 18:34:03 +02:00
|
|
|
|
} else if (tabName === 'sales') {
|
|
|
|
|
|
renderSalesTab();
|
|
|
|
|
|
} else if (tabName === 'controlling') {
|
|
|
|
|
|
if (!controllingTabRendered) {
|
|
|
|
|
|
renderControllingTab(controllingCurrentPeriod);
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
for (const panel of tabPanels) {
|
|
|
|
|
|
observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
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());
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
|
renderSupportTab();
|
|
|
|
|
|
initTicketsGrid();
|
2026-04-03 16:45:41 +02:00
|
|
|
|
renderDebitorCommunicationAside();
|
2026-04-02 17:48:27 +02:00
|
|
|
|
}
|