forked from fa/breadcrumb-the-shire
Migrates the ticket detail view (communication feed + ticket metadata aside)
into the core detail drawer pattern. Click a ticket number in the customer's
support tab → drawer slides in with the full ticket thread; arrow icons step
through the current filter page; ↗ opens the full page.
- `modules/helpdesk/templates/helpdesk-ticket-detail.phtml` extracts the
body (communication feed container + metadata aside + i18n JSON) so the
full page and the fragment render from the same source.
- `ticket-fragment($id).php` / `(none).phtml` reuse BcODataGateway::getTicket
and enforce the same ABILITY_ACCESS guard as the full page. Connection
errors render inline without aborting the fragment.
- Route `helpdesk/ticket-fragment/{id}` declared in module.php.
- `helpdesk-detail.js` wires the tickets grid: ticket-no formatter emits
`data-drawer-trigger`, `linkColumn` disabled, `initDetailDrawer` called
with `onContentLoaded → initHelpdeskTicket(contentEl)` so the async
communication feed loads inside the drawer just like on the full page.
- `fetchUrl`/`fullUrl` written as single-expression arrows so the
DetailDrawerFragmentContractTest can discover the consumer and validate
the fragment endpoint files exist — the test now covers all three drawer
consumers (address book, admin users, helpdesk tickets).
The full-page ticket view is unchanged for users who deep-link directly;
only the router-to-body wiring moved through the partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1651 lines
62 KiB
JavaScript
1651 lines
62 KiB
JavaScript
/**
|
||
* Helpdesk debtor detail page — role-based dashboard rendering.
|
||
*
|
||
* - Support tab: health tiles, system recommendations, contracts/products widget, tickets grid
|
||
* - Sales tab: contact table (lazy-loaded)
|
||
* - Controlling tab: placeholder
|
||
* - Aside: cross-ticket communication feed
|
||
*/
|
||
import { initListSection } from '/js/core/app-list-page-module.js';
|
||
import { getJson } from '/js/core/app-http.js';
|
||
import { resolveHost } from '/js/core/app-dom.js';
|
||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||
import { initDetailDrawer } from '/js/components/app-detail-drawer.js';
|
||
import { initHelpdeskTicket } from './helpdesk-ticket.js';
|
||
import {
|
||
renderCommunicationError,
|
||
renderCommunicationFeed,
|
||
renderCommunicationHint,
|
||
} from './helpdesk-communication.js';
|
||
import { renderEmptyState } from './helpdesk-empty-state.js';
|
||
|
||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||
|
||
const resolveContainer = (root) => {
|
||
const host = resolveHost(root);
|
||
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-detail"]')) {
|
||
return host;
|
||
}
|
||
return host.querySelector('.app-details-container[data-app-component="helpdesk-detail"]');
|
||
};
|
||
|
||
export function initHelpdeskDetail(root = document) {
|
||
const container = resolveContainer(root);
|
||
if (!container) {
|
||
return EMPTY_API;
|
||
}
|
||
|
||
return init(container);
|
||
}
|
||
|
||
function init(container) {
|
||
const listenerController = new AbortController();
|
||
const requestController = new AbortController();
|
||
const on = (target, type, handler, options = {}) => {
|
||
if (!target || typeof target.addEventListener !== 'function') {
|
||
return;
|
||
}
|
||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||
};
|
||
|
||
const customerNo = container.dataset.customerNo || '';
|
||
const customerName = container.dataset.customerName || '';
|
||
const supportDashboardUrl = container.dataset.supportDashboardUrl || '';
|
||
const contactsUrl = container.dataset.contactsUrl || '';
|
||
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
|
||
const communicationUrl = container.dataset.communicationUrl || '';
|
||
const fileDownloadUrl = container.dataset.fileDownloadUrl || '';
|
||
const salesDashboardUrl = container.dataset.salesDashboardUrl || '';
|
||
const meetingsUrl = container.dataset.meetingsUrl || '';
|
||
const refreshFromUrl = (() => {
|
||
const value = new URLSearchParams(window.location.search).get('refresh');
|
||
if (!value) return false;
|
||
const normalized = String(value).trim().toLowerCase();
|
||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||
})();
|
||
|
||
if (!customerNo || !customerName) {
|
||
return EMPTY_API;
|
||
}
|
||
|
||
const i18nEl = document.getElementById('helpdesk-i18n');
|
||
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
|
||
const t = (key) => i18n[key] || key;
|
||
|
||
let supportDashboardPromise = null;
|
||
let salesDashboardPromise = null;
|
||
let meetingsPromise = null;
|
||
let contactsPromise = null;
|
||
let ticketCategoriesPromise = null;
|
||
let debitorCommunicationPromise = null;
|
||
|
||
let supportTabRendered = false;
|
||
let salesTabRendered = false;
|
||
let ticketsGridInitialized = false;
|
||
let ticketsGridInitializing = false;
|
||
|
||
function buildBaseParams() {
|
||
const params = new URLSearchParams({ customerNo, customerName });
|
||
if (refreshFromUrl) {
|
||
params.set('refresh', '1');
|
||
}
|
||
return params;
|
||
}
|
||
|
||
function fetchSupportDashboard() {
|
||
if (!supportDashboardPromise) {
|
||
if (!supportDashboardUrl) {
|
||
supportDashboardPromise = Promise.resolve({ ok: false, error: 'No support dashboard endpoint configured' });
|
||
} else {
|
||
const params = buildBaseParams();
|
||
supportDashboardPromise = getJson(`${supportDashboardUrl}?${params.toString()}`, {
|
||
signal: requestController.signal,
|
||
})
|
||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||
}
|
||
}
|
||
|
||
return supportDashboardPromise;
|
||
}
|
||
|
||
function fetchSalesDashboard() {
|
||
if (!salesDashboardPromise) {
|
||
if (!salesDashboardUrl) {
|
||
salesDashboardPromise = Promise.resolve({ ok: false, error: 'No sales dashboard endpoint configured' });
|
||
} else {
|
||
const params = buildBaseParams();
|
||
salesDashboardPromise = getJson(`${salesDashboardUrl}?${params.toString()}`, {
|
||
signal: requestController.signal,
|
||
})
|
||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||
}
|
||
}
|
||
|
||
return salesDashboardPromise;
|
||
}
|
||
|
||
function fetchMeetings() {
|
||
if (!meetingsPromise) {
|
||
if (!meetingsUrl) {
|
||
meetingsPromise = Promise.resolve({ ok: false, error: 'No meetings endpoint configured' });
|
||
} else {
|
||
const params = new URLSearchParams({ customerNo });
|
||
if (refreshFromUrl) params.set('refresh', '1');
|
||
meetingsPromise = getJson(`${meetingsUrl}?${params.toString()}`, {
|
||
signal: requestController.signal,
|
||
})
|
||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||
}
|
||
}
|
||
return meetingsPromise;
|
||
}
|
||
|
||
function fetchContacts() {
|
||
if (!contactsPromise) {
|
||
const params = buildBaseParams();
|
||
contactsPromise = getJson(`${contactsUrl}?${params.toString()}`, {
|
||
signal: requestController.signal,
|
||
})
|
||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||
}
|
||
|
||
return contactsPromise;
|
||
}
|
||
|
||
function fetchTicketCategories(config, appBase) {
|
||
if (!ticketCategoriesPromise) {
|
||
const categoryOptionsUrl = config.categoryOptionsUrl || '';
|
||
if (!categoryOptionsUrl) {
|
||
ticketCategoriesPromise = Promise.resolve({ ok: false, categories: [] });
|
||
return ticketCategoriesPromise;
|
||
}
|
||
|
||
const url = new URL(categoryOptionsUrl, appBase);
|
||
url.searchParams.set('customerNo', config.customerNo || customerNo);
|
||
url.searchParams.set('customerName', config.customerName || customerName);
|
||
if (refreshFromUrl) {
|
||
url.searchParams.set('refresh', '1');
|
||
}
|
||
|
||
ticketCategoriesPromise = getJson(url.toString(), {
|
||
signal: requestController.signal,
|
||
})
|
||
.catch(() => ({ ok: false, categories: [] }));
|
||
}
|
||
|
||
return ticketCategoriesPromise;
|
||
}
|
||
|
||
function fetchDebitorCommunication() {
|
||
if (!debitorCommunicationPromise) {
|
||
if (!communicationUrl) {
|
||
debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
|
||
} else {
|
||
const params = buildBaseParams();
|
||
debitorCommunicationPromise = getJson(`${communicationUrl}?${params.toString()}`, {
|
||
signal: requestController.signal,
|
||
})
|
||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||
}
|
||
}
|
||
|
||
return debitorCommunicationPromise;
|
||
}
|
||
|
||
/**
|
||
* Hydrate a dynamic select filter with values from the API response.
|
||
* Works for category, support user, and contact filters.
|
||
*/
|
||
function hydrateSelectFilter(config, selectorId, filterKey, values) {
|
||
const selectEl = document.querySelector(selectorId);
|
||
if (!(selectEl instanceof HTMLSelectElement)) return;
|
||
|
||
const uniqueValues = Array.from(new Set(
|
||
(Array.isArray(values) ? values : [])
|
||
.map(item => String(item || '').trim())
|
||
.filter(Boolean),
|
||
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||
|
||
const currentValue = String(selectEl.value || '');
|
||
selectEl.innerHTML = '';
|
||
|
||
const allOption = document.createElement('option');
|
||
allOption.value = '';
|
||
allOption.textContent = t('All');
|
||
selectEl.appendChild(allOption);
|
||
|
||
const optionMap = {};
|
||
for (const value of uniqueValues) {
|
||
const option = document.createElement('option');
|
||
option.value = value;
|
||
option.textContent = value;
|
||
selectEl.appendChild(option);
|
||
optionMap[value] = value;
|
||
}
|
||
|
||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(optionMap, currentValue)) {
|
||
selectEl.value = currentValue;
|
||
} else {
|
||
selectEl.value = '';
|
||
}
|
||
|
||
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
|
||
config.filterChipMeta = {};
|
||
}
|
||
|
||
const currentMeta = (config.filterChipMeta[filterKey] && typeof config.filterChipMeta[filterKey] === 'object')
|
||
? config.filterChipMeta[filterKey]
|
||
: {};
|
||
|
||
config.filterChipMeta[filterKey] = {
|
||
...currentMeta,
|
||
type: 'select',
|
||
default: '',
|
||
options: optionMap,
|
||
};
|
||
}
|
||
|
||
function esc(str) {
|
||
const d = document.createElement('div');
|
||
d.textContent = str;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function ticketUrl(ticketNo) {
|
||
return ticketBaseUrl + encodeURIComponent(ticketNo) + '?from=' + encodeURIComponent(customerNo);
|
||
}
|
||
|
||
function errorNotice(message) {
|
||
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
|
||
}
|
||
|
||
let contactsGridInitialized = false;
|
||
let contactsGridInitializing = false;
|
||
|
||
async function initContactsGrid() {
|
||
if (contactsGridInitialized || contactsGridInitializing) return;
|
||
contactsGridInitializing = true;
|
||
|
||
const contactsConfig = readPageConfig('helpdesk-contacts');
|
||
if (!contactsConfig) {
|
||
contactsGridInitializing = false;
|
||
return;
|
||
}
|
||
|
||
const labels = contactsConfig.labels || {};
|
||
const gridjsLib = window.gridjs;
|
||
if (!gridjsLib) {
|
||
contactsGridInitializing = false;
|
||
return;
|
||
}
|
||
|
||
const baseEl = document.querySelector('base');
|
||
const appBase = (baseEl && baseEl.href) ? baseEl.href : '/';
|
||
|
||
const gridDataUrl = new URL(contactsConfig.dataUrl || '', appBase);
|
||
gridDataUrl.searchParams.set('customerNo', contactsConfig.customerNo || customerNo);
|
||
gridDataUrl.searchParams.set('customerName', contactsConfig.customerName || customerName);
|
||
if (refreshFromUrl) {
|
||
gridDataUrl.searchParams.set('refresh', '1');
|
||
}
|
||
|
||
try {
|
||
// Fetch type options from first data response
|
||
const typeOptionsUrl = new URL(gridDataUrl.toString());
|
||
typeOptionsUrl.searchParams.set('limit', '1');
|
||
const typeResp = await getJson(typeOptionsUrl.toString(), {
|
||
signal: requestController.signal,
|
||
}).catch(() => null);
|
||
const typeOptions = Array.isArray(typeResp?.type_options) ? typeResp.type_options : [];
|
||
hydrateContactTypeFilter(contactsConfig, typeOptions);
|
||
|
||
const gridOptions = {
|
||
gridjs: gridjsLib,
|
||
container: '#helpdesk-contacts-grid',
|
||
dataUrl: gridDataUrl.toString(),
|
||
appBase,
|
||
columns: [
|
||
{ name: labels.name || 'Name', sort: true },
|
||
{ name: labels.type || 'Type', sort: true },
|
||
{ name: labels.jobTitle || 'Job title', sort: true },
|
||
{ name: labels.email || 'E-Mail', sort: true },
|
||
{ name: labels.phone || 'Phone', sort: true },
|
||
{ name: labels.mobile || 'Mobile', sort: false },
|
||
],
|
||
sortColumns: ['Name', 'Type', 'Job_Title', 'E_Mail', 'Phone_No', null],
|
||
paginationLimit: 10,
|
||
language: contactsConfig.gridLang || {},
|
||
mapData: (data) => (data.data || []).map((row) => [
|
||
row.Name || '',
|
||
row.Type || '',
|
||
row.Job_Title || '',
|
||
row.E_Mail || '',
|
||
row.Phone_No || '',
|
||
row.Mobile_Phone_No || '',
|
||
]),
|
||
search: contactsConfig.gridSearch || null,
|
||
filterSchema: contactsConfig.filterSchema || [],
|
||
urlSync: false,
|
||
actions: { enabled: false },
|
||
};
|
||
|
||
// Scope filter UI to the contacts section to avoid collision with tickets filter
|
||
const contactsSection = document.getElementById('sales-contacts-section');
|
||
const contactsChipsEl = contactsSection
|
||
? contactsSection.querySelector('[data-active-filter-chips]')
|
||
: null;
|
||
const contactsLiveEl = contactsSection
|
||
? contactsSection.querySelector('[data-filter-live-region]')
|
||
: null;
|
||
|
||
initListSection({
|
||
moduleId: 'helpdesk-detail-contacts',
|
||
initErrorMessage: 'Helpdesk contacts grid init failed',
|
||
initOptions: {
|
||
grid: gridOptions,
|
||
filters: {
|
||
mode: 'drawer',
|
||
chipMeta: contactsConfig.filterChipMeta || {},
|
||
watchInputs: ['#helpdesk-contact-search'],
|
||
drawerRoot: contactsSection || undefined,
|
||
chips: contactsChipsEl ? { container: contactsChipsEl } : {},
|
||
live: contactsLiveEl ? { regionSelector: null } : {},
|
||
},
|
||
},
|
||
});
|
||
contactsGridInitialized = true;
|
||
} finally {
|
||
contactsGridInitializing = false;
|
||
}
|
||
}
|
||
|
||
function hydrateContactTypeFilter(contactsConfig, typeOptions) {
|
||
const select = document.getElementById('helpdesk-contact-type-filter');
|
||
if (!(select instanceof HTMLSelectElement) || !Array.isArray(typeOptions)) return;
|
||
|
||
const currentValue = String(select.value || '');
|
||
select.innerHTML = '';
|
||
|
||
const allOption = document.createElement('option');
|
||
allOption.value = '';
|
||
allOption.textContent = t('All');
|
||
select.appendChild(allOption);
|
||
|
||
const typeOptionMap = {};
|
||
for (const opt of typeOptions) {
|
||
const id = String(opt.id || '').trim();
|
||
const label = String(opt.description || opt.id || '').trim();
|
||
if (id === '') continue;
|
||
const option = document.createElement('option');
|
||
option.value = id;
|
||
option.textContent = label;
|
||
select.appendChild(option);
|
||
typeOptionMap[id] = label;
|
||
}
|
||
|
||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(typeOptionMap, currentValue)) {
|
||
select.value = currentValue;
|
||
} else {
|
||
select.value = '';
|
||
}
|
||
|
||
if (!contactsConfig.filterChipMeta || typeof contactsConfig.filterChipMeta !== 'object') {
|
||
contactsConfig.filterChipMeta = {};
|
||
}
|
||
|
||
const currentTypeMeta = (contactsConfig.filterChipMeta.type && typeof contactsConfig.filterChipMeta.type === 'object')
|
||
? contactsConfig.filterChipMeta.type
|
||
: {};
|
||
|
||
contactsConfig.filterChipMeta.type = {
|
||
...currentTypeMeta,
|
||
type: 'select',
|
||
default: '',
|
||
options: typeOptionMap,
|
||
};
|
||
}
|
||
|
||
function formatTemplate(template, params) {
|
||
const tpl = String(template || '');
|
||
const map = (params && typeof params === 'object') ? params : {};
|
||
|
||
return tpl.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => {
|
||
const value = map[key];
|
||
if (value === null || value === undefined) {
|
||
return '';
|
||
}
|
||
|
||
return String(value);
|
||
});
|
||
}
|
||
|
||
const severityMap = {
|
||
escalation_overdue: { cls: 'critical', icon: '🔴' },
|
||
high_risk_aging: { cls: 'high', icon: '🟠' },
|
||
unassigned_open: { cls: 'medium', icon: '🟡' },
|
||
stale_open: { cls: 'low', icon: '🔵' },
|
||
customer_backlog: { cls: 'low', icon: '🔵' },
|
||
};
|
||
|
||
function renderSystemRecommendations(recommendations) {
|
||
const entries = Array.isArray(recommendations) ? recommendations : [];
|
||
if (entries.length === 0) {
|
||
return renderEmptyState({
|
||
message: t('No system recommendations right now.'),
|
||
size: 'compact',
|
||
align: 'center',
|
||
icon: false,
|
||
});
|
||
}
|
||
|
||
const formatRecommendationMessage = (entry) => {
|
||
const messageKey = String(entry.message_key || '');
|
||
const template = t(messageKey);
|
||
const params = (entry.message_params && typeof entry.message_params === 'object')
|
||
? entry.message_params
|
||
: {};
|
||
|
||
if (template && template !== messageKey) {
|
||
return formatTemplate(template, params);
|
||
}
|
||
|
||
return messageKey || String(entry.rule_id || '—');
|
||
};
|
||
|
||
let html = '<div class="helpdesk-rec-list">';
|
||
for (const entry of entries) {
|
||
const ticketNo = String(entry.ticket_no || '');
|
||
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
||
const ruleId = String(entry.rule_id || '');
|
||
const severity = severityMap[ruleId] || { cls: 'low', icon: '🔵' };
|
||
const ageHours = Number.isFinite(Number(entry.age_hours)) ? Number(entry.age_hours) : null;
|
||
const ageLabel = ageHours !== null ? fmtHours(ageHours) : '';
|
||
const message = formatRecommendationMessage(entry);
|
||
|
||
const tag = href ? 'a' : 'div';
|
||
const hrefAttr = href ? ` href="${esc(href)}"` : '';
|
||
html += `<${tag}${hrefAttr} class="helpdesk-rec-card helpdesk-rec-${severity.cls}" tabindex="0">`;
|
||
html += `<div class="helpdesk-rec-card-header">`;
|
||
html += `<span class="helpdesk-rec-card-ticket">${esc(ticketNo || '—')}</span>`;
|
||
if (ageLabel) html += `<span class="helpdesk-rec-card-age">${esc(ageLabel)}</span>`;
|
||
html += `</div>`;
|
||
html += `<div class="helpdesk-rec-card-message">${esc(message)}</div>`;
|
||
html += `</${tag}>`;
|
||
}
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
|
||
function contractTypeLabel(productType) {
|
||
return String(productType || '').trim() || '—';
|
||
}
|
||
|
||
// Shared formatter for contract rendering (dialog, timeline)
|
||
const contractAmountFmt = new Intl.NumberFormat(undefined, {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
maximumFractionDigits: 2,
|
||
});
|
||
|
||
function contractFormatDate(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
||
const date = new Date(raw);
|
||
if (Number.isNaN(date.getTime())) return raw;
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
function contractStateVariant(state) {
|
||
const s = String(state || '').toLowerCase();
|
||
if (s === 'aktiv') return 'success';
|
||
if (s === 'vorbereitung') return 'warning';
|
||
return 'neutral';
|
||
}
|
||
|
||
/** Store all contracts for dialog access */
|
||
let salesContracts = [];
|
||
|
||
|
||
|
||
function renderContractLinesTable(lines) {
|
||
if (!Array.isArray(lines) || lines.length === 0) {
|
||
return `<p class="muted">${esc(t('No contract details available.'))}</p>`;
|
||
}
|
||
|
||
let html = '<table><thead><tr>';
|
||
html += `<th>${esc(t('Description'))}</th>`;
|
||
html += `<th>${esc(t('Type'))}</th>`;
|
||
html += `<th>${esc(t('Quantity'))}</th>`;
|
||
html += `<th>${esc(t('Unit price'))}</th>`;
|
||
html += `<th>${esc(t('Line amount'))}</th>`;
|
||
html += `<th>${esc(t('Line state'))}</th>`;
|
||
html += '</tr></thead><tbody>';
|
||
for (const line of lines) {
|
||
const lineVariant = String(line.line_state || '').toLowerCase() === 'aktiv' ? 'success' : 'neutral';
|
||
html += '<tr>';
|
||
html += `<td>${esc(line.description || '—')}</td>`;
|
||
html += `<td>${line.type ? `<span class="badge">${esc(line.type)}</span>` : '—'}</td>`;
|
||
html += `<td>${esc(String(line.quantity ?? 0))}</td>`;
|
||
html += `<td>${esc(contractAmountFmt.format(Number(line.unit_price ?? 0)))}</td>`;
|
||
html += `<td>${esc(contractAmountFmt.format(Number(line.line_amount ?? 0)))}</td>`;
|
||
html += `<td><span class="badge" data-variant="${lineVariant}">${esc(line.line_state || '—')}</span></td>`;
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
return html;
|
||
}
|
||
|
||
function openContractDialog(contract) {
|
||
// Remove existing dialog if present
|
||
const existing = document.getElementById('helpdesk-contract-dialog');
|
||
if (existing) existing.remove();
|
||
|
||
const lines = Array.isArray(contract.lines) ? contract.lines : [];
|
||
const variant = contractStateVariant(contract.state);
|
||
const typeLabel = contractTypeLabel(contract.product_type);
|
||
|
||
let dialogHtml = '<dialog id="helpdesk-contract-dialog">';
|
||
dialogHtml += '<article class="app-dialog-lg helpdesk-contract-dialog-article">';
|
||
|
||
// Header — follows core pattern: <h2> then <button class="close">
|
||
dialogHtml += '<header>';
|
||
dialogHtml += `<h2>${esc(contract.description || typeLabel)} <small>${esc(contract.contract_no || '')}</small></h2>`;
|
||
dialogHtml += '<button type="button" class="close" data-dialog-close aria-label="Close"></button>';
|
||
dialogHtml += '</header>';
|
||
|
||
// Contract meta
|
||
dialogHtml += '<div class="helpdesk-contract-dialog-meta">';
|
||
const metaItems = [];
|
||
metaItems.push(`<span><strong>${esc(t('Status'))}</strong><span class="badge" data-variant="${variant}">${esc(contract.state || '—')}</span></span>`);
|
||
metaItems.push(`<span><strong>${esc(t('Type'))}</strong>${esc(typeLabel)}</span>`);
|
||
if (contract.starting_date) {
|
||
metaItems.push(`<span><strong>${esc(t('Starting date'))}</strong>${esc(contractFormatDate(contract.starting_date))}</span>`);
|
||
}
|
||
if (contract.invoicing_period) {
|
||
metaItems.push(`<span><strong>${esc(t('Invoicing period'))}</strong>${esc(contract.invoicing_period)}</span>`);
|
||
}
|
||
if (contract.next_invoicing_date) {
|
||
metaItems.push(`<span><strong>${esc(t('Next invoicing'))}</strong>${esc(contractFormatDate(contract.next_invoicing_date))}</span>`);
|
||
}
|
||
if (Number(contract.monthly_amount ?? 0) > 0) {
|
||
metaItems.push(`<span><strong>${esc(t('Monthly volume'))}</strong>${esc(contractAmountFmt.format(contract.monthly_amount))}</span>`);
|
||
}
|
||
if (contract.salesperson_code) {
|
||
metaItems.push(`<span><strong>${esc(t('Salesperson'))}</strong>${esc(contract.salesperson_code)}</span>`);
|
||
}
|
||
if (Number(contract.support_hours ?? 0) > 0) {
|
||
metaItems.push(`<span><strong>${esc(t('Support hours'))}</strong>${esc(String(contract.support_hours))}</span>`);
|
||
}
|
||
dialogHtml += metaItems.join('');
|
||
dialogHtml += '</div>';
|
||
|
||
// Lines table
|
||
if (lines.length > 0) {
|
||
dialogHtml += `<h3 class="helpdesk-contract-dialog-section-title">${esc(t('positions'))} (${lines.length})</h3>`;
|
||
dialogHtml += '<div class="helpdesk-contract-dialog-lines">';
|
||
dialogHtml += renderContractLinesTable(lines);
|
||
dialogHtml += '</div>';
|
||
}
|
||
|
||
// Footer
|
||
dialogHtml += '<footer>';
|
||
dialogHtml += `<button type="button" class="secondary outline" data-dialog-close>${esc(t('Close'))}</button>`;
|
||
dialogHtml += '</footer>';
|
||
|
||
dialogHtml += '</article></dialog>';
|
||
|
||
document.body.insertAdjacentHTML('beforeend', dialogHtml);
|
||
const dialog = document.getElementById('helpdesk-contract-dialog');
|
||
on(dialog, 'click', (e) => {
|
||
if (e.target.closest('[data-dialog-close]')) {
|
||
dialog.close();
|
||
dialog.remove();
|
||
return;
|
||
}
|
||
// Close on backdrop click
|
||
if (e.target === dialog) {
|
||
dialog.close();
|
||
dialog.remove();
|
||
}
|
||
});
|
||
on(dialog, 'close', () => { dialog.remove(); });
|
||
dialog.showModal();
|
||
}
|
||
|
||
// Delegate click on contract cards and timeline rows
|
||
on(container, 'click', (e) => {
|
||
const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]');
|
||
if (!target) return;
|
||
const index = Number(target.dataset.contractIndex);
|
||
if (salesContracts[index]) {
|
||
openContractDialog(salesContracts[index]);
|
||
}
|
||
});
|
||
|
||
// KPI tile click → scroll to ticket list and apply filter or sort
|
||
on(container, 'click', (e) => {
|
||
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]');
|
||
if (!kpi) return;
|
||
|
||
// Apply status filter if present
|
||
if (kpi.dataset.kpiFilter !== undefined) {
|
||
const filterValue = kpi.dataset.kpiFilter || '';
|
||
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
|
||
if (statusSelect instanceof HTMLSelectElement) {
|
||
statusSelect.value = filterValue;
|
||
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
|
||
// Apply sort if present (format: "column:dir")
|
||
if (kpi.dataset.kpiSort) {
|
||
const [col, dir] = kpi.dataset.kpiSort.split(':');
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('order', col);
|
||
url.searchParams.set('dir', dir || 'asc');
|
||
// Also set open filter for "avg age" to show only open tickets
|
||
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
|
||
if (statusSelect instanceof HTMLSelectElement) {
|
||
statusSelect.value = 'open';
|
||
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
window.history.replaceState(null, '', url.toString());
|
||
}
|
||
|
||
const ticketSection = document.querySelector('.helpdesk-support-ticket-list');
|
||
if (ticketSection) {
|
||
ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
});
|
||
|
||
|
||
function setText(id, value) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.textContent = String(value);
|
||
}
|
||
}
|
||
|
||
function setHtml(id, html) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.innerHTML = html;
|
||
}
|
||
}
|
||
|
||
function fmtHours(h) {
|
||
const hours = Math.round(h);
|
||
if (hours < 24) return hours + 'h';
|
||
const days = Math.floor(hours / 24);
|
||
const rest = hours % 24;
|
||
return rest > 0 ? days + 'd ' + rest + 'h' : days + 'd';
|
||
}
|
||
|
||
function showLoading(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.hidden = false;
|
||
}
|
||
}
|
||
|
||
function hideLoading(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.hidden = true;
|
||
}
|
||
}
|
||
|
||
function showContent(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.hidden = false;
|
||
}
|
||
}
|
||
|
||
on(container, 'click', (e) => {
|
||
if (e.target.closest('a')) return;
|
||
const row = e.target.closest('.app-clickable-row[data-href]');
|
||
if (row) {
|
||
const href = row.dataset.href;
|
||
if (href) {
|
||
window.location.href = href;
|
||
}
|
||
}
|
||
});
|
||
|
||
on(container, 'keydown', (e) => {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
const row = e.target.closest('.app-clickable-row[data-href]');
|
||
if (row) {
|
||
e.preventDefault();
|
||
const href = row.dataset.href;
|
||
if (href) {
|
||
window.location.href = href;
|
||
}
|
||
return;
|
||
}
|
||
// Timeline row keyboard activation
|
||
const tlRow = e.target.closest('.helpdesk-tl-row-clickable[data-contract-index]');
|
||
if (tlRow) {
|
||
e.preventDefault();
|
||
const index = Number(tlRow.dataset.contractIndex);
|
||
if (salesContracts[index]) {
|
||
openContractDialog(salesContracts[index]);
|
||
}
|
||
}
|
||
});
|
||
|
||
async function initTicketsGrid() {
|
||
if (ticketsGridInitialized || ticketsGridInitializing) return;
|
||
ticketsGridInitializing = true;
|
||
|
||
const config = readPageConfig('helpdesk-tickets');
|
||
if (!config) {
|
||
ticketsGridInitializing = false;
|
||
return;
|
||
}
|
||
|
||
const labels = config.labels || {};
|
||
const gridjs = window.gridjs;
|
||
if (!gridjs) {
|
||
ticketsGridInitializing = false;
|
||
return;
|
||
}
|
||
|
||
const baseEl = document.querySelector('base');
|
||
const appBase = (baseEl && baseEl.href) ? baseEl.href : '/';
|
||
|
||
const gridDataUrl = new URL(config.dataUrl || '', appBase);
|
||
gridDataUrl.searchParams.set('customerNo', config.customerNo || customerNo);
|
||
gridDataUrl.searchParams.set('customerName', config.customerName || customerName);
|
||
if (refreshFromUrl) {
|
||
gridDataUrl.searchParams.set('refresh', '1');
|
||
}
|
||
|
||
try {
|
||
const filterOptionsResponse = await fetchTicketCategories(config, appBase);
|
||
hydrateSelectFilter(config, '#helpdesk-ticket-category-filter', 'category', filterOptionsResponse?.categories || []);
|
||
hydrateSelectFilter(config, '#helpdesk-ticket-support-filter', 'support', filterOptionsResponse?.support_users || []);
|
||
hydrateSelectFilter(config, '#helpdesk-ticket-contact-filter', 'contact', filterOptionsResponse?.contacts || []);
|
||
|
||
const gridOptions = {
|
||
gridjs,
|
||
container: '#helpdesk-tickets-grid',
|
||
dataUrl: gridDataUrl.toString(),
|
||
appBase,
|
||
columns: [
|
||
{
|
||
name: labels.ticketNo || 'Ticket No.',
|
||
sort: true,
|
||
formatter: (cell) => gridjs.html(`<a class="app-grid-link-cell" href="${cell.url}" data-drawer-trigger data-ticket-no="${cell.no || ''}">${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: false },
|
||
rowDataset: (row) => ({
|
||
uuid: row?.cells?.[0]?.data?.no || '',
|
||
}),
|
||
};
|
||
|
||
const section = initListSection({
|
||
moduleId: 'helpdesk-detail-tickets',
|
||
initErrorMessage: 'Helpdesk tickets grid init failed',
|
||
initOptions: {
|
||
grid: gridOptions,
|
||
filters: {
|
||
mode: 'drawer',
|
||
chipMeta: config.filterChipMeta || {},
|
||
watchInputs: ['#helpdesk-ticket-search'],
|
||
},
|
||
},
|
||
});
|
||
ticketsGridInitialized = true;
|
||
|
||
initDetailDrawer({
|
||
gridConfig: section?.gridConfig || null,
|
||
triggerSelector: '[data-drawer-trigger][data-ticket-no]',
|
||
rowUuidAttr: 'uuid',
|
||
fetchUrl: (ticketNo) => new URL(`helpdesk/ticket-fragment/${encodeURIComponent(ticketNo)}?from=${encodeURIComponent(customerNo || '')}`, appBase).toString(),
|
||
fullUrl: (ticketNo) => new URL(`helpdesk/ticket/${encodeURIComponent(ticketNo)}?from=${encodeURIComponent(customerNo || '')}`, appBase).toString(),
|
||
hashPrefix: 'ticket',
|
||
onContentLoaded: (contentEl) => {
|
||
try { initHelpdeskTicket(contentEl); } catch (err) { console.warn('[helpdesk-detail] initHelpdeskTicket failed', err); }
|
||
},
|
||
labels: {
|
||
close: labels.drawerClose || 'Close',
|
||
previous: labels.drawerPrev || 'Previous',
|
||
next: labels.drawerNext || 'Next',
|
||
openFull: labels.drawerOpenFull || 'Open full page',
|
||
loading: labels.drawerLoading || 'Loading',
|
||
error: labels.drawerError || 'Failed to load',
|
||
},
|
||
});
|
||
} finally {
|
||
ticketsGridInitializing = false;
|
||
}
|
||
}
|
||
|
||
async function renderSupportTab() {
|
||
if (supportTabRendered) return;
|
||
|
||
showLoading('support-loading');
|
||
const result = await fetchSupportDashboard();
|
||
hideLoading('support-loading');
|
||
showContent('support-content');
|
||
|
||
const recommendationsEl = document.getElementById('support-system-recommendations');
|
||
if (!result?.ok) {
|
||
setText('support-kpi-open-tickets', '—');
|
||
setText('support-kpi-created-30d', '—');
|
||
setText('support-kpi-closed-30d', '—');
|
||
setText('support-kpi-avg-age', '—');
|
||
if (recommendationsEl) {
|
||
recommendationsEl.innerHTML = errorNotice(t('Could not load tickets.'));
|
||
}
|
||
supportTabRendered = true;
|
||
return;
|
||
}
|
||
|
||
const health = (result.health && typeof result.health === 'object') ? result.health : {};
|
||
const openTickets = Number(health.open_tickets ?? 0);
|
||
const created30d = Number(health.created_30d ?? 0);
|
||
const createdPrev30d = Number(health.created_prev_30d ?? 0);
|
||
const closed30d = Number(health.closed_30d ?? 0);
|
||
const closedPrev30d = Number(health.closed_prev_30d ?? 0);
|
||
const avgAgeHours = health.avg_open_age_hours !== null && health.avg_open_age_hours !== undefined
|
||
? Number(health.avg_open_age_hours) : null;
|
||
const criticalTickets = Number(health.critical_tickets ?? 0);
|
||
|
||
// Helper: render trend indicator
|
||
const renderTrend = (current, previous, invertColor) => {
|
||
if (previous === 0 && current === 0) return '';
|
||
const delta = current - previous;
|
||
if (delta === 0) return `<span class="helpdesk-kpi-trend-neutral">— ${t('vs. prev. 30d')}</span>`;
|
||
const arrow = delta > 0 ? '↑' : '↓';
|
||
const abs = Math.abs(delta);
|
||
// For "resolved" more is good (invert), for "created/open" more is bad
|
||
const isPositive = invertColor ? delta > 0 : delta < 0;
|
||
const cls = isPositive ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
|
||
return `<span class="${cls}">${arrow} ${abs} ${t('vs. prev. 30d')}</span>`;
|
||
};
|
||
|
||
// 1. Open tickets + net flow trend
|
||
setText('support-kpi-open-tickets', openTickets);
|
||
const netFlow = created30d - closed30d;
|
||
const openTrendEl = document.getElementById('support-kpi-open-trend');
|
||
if (openTrendEl) {
|
||
if (netFlow > 0) {
|
||
openTrendEl.innerHTML = `<span class="helpdesk-kpi-trend-negative">↑ ${netFlow} ${t('net (30d)')}</span>`;
|
||
} else if (netFlow < 0) {
|
||
openTrendEl.innerHTML = `<span class="helpdesk-kpi-trend-positive">↓ ${Math.abs(netFlow)} ${t('net (30d)')}</span>`;
|
||
} else if (created30d > 0) {
|
||
openTrendEl.innerHTML = `<span class="helpdesk-kpi-trend-neutral">± 0 ${t('net (30d)')}</span>`;
|
||
}
|
||
}
|
||
|
||
// 2. Created last 30d + trend vs previous 30d
|
||
setText('support-kpi-created-30d', created30d);
|
||
const createdTrendEl = document.getElementById('support-kpi-created-trend');
|
||
if (createdTrendEl) {
|
||
createdTrendEl.innerHTML = renderTrend(created30d, createdPrev30d, false);
|
||
}
|
||
|
||
// 3. Closed last 30d + trend vs previous 30d
|
||
setText('support-kpi-closed-30d', closed30d);
|
||
const closedTrendEl = document.getElementById('support-kpi-closed-trend');
|
||
if (closedTrendEl) {
|
||
closedTrendEl.innerHTML = renderTrend(closed30d, closedPrev30d, true);
|
||
}
|
||
|
||
// 4. Avg age of open tickets
|
||
const formatAge = (hours) => {
|
||
if (hours === null) return '—';
|
||
if (hours < 24) return `${hours}h`;
|
||
const days = Math.round(hours / 24);
|
||
return `${days}d`;
|
||
};
|
||
setText('support-kpi-avg-age', formatAge(avgAgeHours));
|
||
const ageHintEl = document.getElementById('support-kpi-age-hint');
|
||
if (ageHintEl && criticalTickets > 0) {
|
||
ageHintEl.innerHTML = `<span class="helpdesk-kpi-trend-negative">${criticalTickets} ${t('critical')}</span>`;
|
||
}
|
||
|
||
|
||
// Recommendations + escalations merged — only show section when there are entries
|
||
const recommendationsWrapper = document.getElementById('support-recommendations-wrapper');
|
||
if (recommendationsEl) {
|
||
const recommendations = Array.isArray(result.recommendations)
|
||
? result.recommendations
|
||
: (Array.isArray(result.actions) ? result.actions : []);
|
||
if (recommendations.length > 0) {
|
||
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
|
||
if (recommendationsWrapper) recommendationsWrapper.hidden = false;
|
||
} else if (recommendationsWrapper) {
|
||
recommendationsWrapper.hidden = true;
|
||
}
|
||
}
|
||
|
||
supportTabRendered = true;
|
||
}
|
||
|
||
async function renderSalesTab() {
|
||
if (salesTabRendered) return;
|
||
|
||
showLoading('sales-loading');
|
||
const result = await fetchSalesDashboard();
|
||
hideLoading('sales-loading');
|
||
showContent('sales-content');
|
||
|
||
if (!result?.ok) {
|
||
setText('sales-kpi-monthly-volume', '—');
|
||
setText('sales-kpi-active-contracts', '—');
|
||
setText('sales-kpi-avg-value', '—');
|
||
setText('sales-kpi-next-invoicing', '—');
|
||
const detailEl = document.getElementById('sales-contracts-detail');
|
||
if (detailEl) detailEl.innerHTML = errorNotice(t('Could not load sales dashboard.'));
|
||
salesTabRendered = true;
|
||
return;
|
||
}
|
||
|
||
// Render KPIs
|
||
const kpis = (result.kpis && typeof result.kpis === 'object') ? result.kpis : {};
|
||
const amountFmt = new Intl.NumberFormat(undefined, {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
maximumFractionDigits: 0,
|
||
});
|
||
|
||
const formatDate = (value) => {
|
||
const raw = String(value || '').trim();
|
||
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
||
const date = new Date(raw);
|
||
if (Number.isNaN(date.getTime())) return raw;
|
||
return date.toLocaleDateString();
|
||
};
|
||
|
||
// Trend helper (same pattern as support dashboard)
|
||
const salesTrend = (current, previous, invertColor) => {
|
||
if (previous === 0 && current === 0) return '';
|
||
const delta = current - previous;
|
||
if (delta === 0) return `<span class="helpdesk-kpi-trend-neutral">— ${esc(t('vs. prev. 30d'))}</span>`;
|
||
const arrow = delta > 0 ? '↑' : '↓';
|
||
const abs = Math.abs(delta);
|
||
const isPositive = invertColor ? delta > 0 : delta < 0;
|
||
const cls = isPositive ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
|
||
return `<span class="${cls}">${arrow} ${abs} ${esc(t('vs. prev. 30d'))}</span>`;
|
||
};
|
||
|
||
const started30d = Number(kpis.started_30d ?? 0);
|
||
const startedPrev30d = Number(kpis.started_prev_30d ?? 0);
|
||
const ended30d = Number(kpis.ended_30d ?? 0);
|
||
const endedPrev30d = Number(kpis.ended_prev_30d ?? 0);
|
||
|
||
// 1. Monthly volume + net contract flow trend
|
||
setText('sales-kpi-monthly-volume', amountFmt.format(Number(kpis.monthly_volume ?? 0)));
|
||
const netContracts = started30d - ended30d;
|
||
if (started30d > 0 || ended30d > 0) {
|
||
const netCls = netContracts > 0 ? 'helpdesk-kpi-trend-positive' : (netContracts < 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral');
|
||
const netArrow = netContracts > 0 ? '↑' : (netContracts < 0 ? '↓' : '—');
|
||
const netAbs = Math.abs(netContracts);
|
||
setHtml('sales-kpi-monthly-volume-trend',
|
||
`<span class="${netCls}">${netArrow} ${netAbs} ${esc(t('net (30d)'))}</span>`);
|
||
} else {
|
||
setHtml('sales-kpi-monthly-volume-trend', '');
|
||
}
|
||
|
||
// 2. Active contracts + trend arrow (started vs previous period)
|
||
setText('sales-kpi-active-contracts', kpis.active_contracts ?? 0);
|
||
const totalContracts = Number(kpis.total_contracts ?? 0);
|
||
const contractsTrendHtml = salesTrend(started30d, startedPrev30d, false);
|
||
const totalHint = totalContracts > 0 ? `<span class="helpdesk-kpi-trend-neutral">${esc(t('of {total} total').replace('{total}', totalContracts))}</span>` : '';
|
||
setHtml('sales-kpi-active-contracts-trend', contractsTrendHtml
|
||
? contractsTrendHtml + (totalHint ? ' · ' + totalHint : '')
|
||
: totalHint);
|
||
|
||
// 3. Avg contract value + largest hint
|
||
setText('sales-kpi-avg-value', amountFmt.format(Number(kpis.avg_contract_value ?? 0)));
|
||
const largest = Number(kpis.largest_contract_volume ?? 0);
|
||
const activeCount = Number(kpis.active_contracts ?? 0);
|
||
const totalPositions = Number(kpis.total_positions ?? 0);
|
||
const avgParts = [];
|
||
if (activeCount > 1 && largest > 0) {
|
||
avgParts.push(`<span class="helpdesk-kpi-trend-neutral">${esc(t('largest'))}: ${esc(amountFmt.format(largest))}</span>`);
|
||
}
|
||
if (totalPositions > 0) {
|
||
avgParts.push(`<span class="helpdesk-kpi-trend-neutral">${esc(totalPositions + ' ' + t('positions'))}</span>`);
|
||
}
|
||
setHtml('sales-kpi-avg-value-trend', avgParts.join(' · '));
|
||
|
||
// 4. Next invoicing + countdown
|
||
setText('sales-kpi-next-invoicing', formatDate(kpis.next_invoicing_date));
|
||
const daysUntil = kpis.days_until_next_invoicing;
|
||
if (daysUntil !== null && daysUntil !== undefined) {
|
||
const cls = daysUntil <= 7 ? 'helpdesk-kpi-trend-negative' : (daysUntil <= 30 ? 'helpdesk-kpi-trend-neutral' : 'helpdesk-kpi-trend-positive');
|
||
setHtml('sales-kpi-next-invoicing-trend',
|
||
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
||
}
|
||
|
||
// Contract timeline (swimlane view — click on bar opens contract dialog)
|
||
renderContractTimeline(result.contracts || []);
|
||
|
||
// Init contacts grid (server-side, same pattern as tickets)
|
||
initContactsGrid();
|
||
|
||
// Meetings timeline (async, doesn't block sales tab)
|
||
renderMeetingsSection();
|
||
|
||
salesTabRendered = true;
|
||
}
|
||
|
||
// All values inserted via esc() which escapes HTML entities — safe innerHTML usage (same pattern as renderContractTimeline, renderSystemRecommendations)
|
||
async function renderMeetingsSection() {
|
||
const loadingEl = document.getElementById('meetings-loading');
|
||
const contentEl = document.getElementById('meetings-content');
|
||
const emptyEl = document.getElementById('meetings-empty');
|
||
const errorEl = document.getElementById('meetings-error');
|
||
if (!loadingEl || !contentEl) return;
|
||
|
||
loadingEl.hidden = false;
|
||
contentEl.hidden = true;
|
||
if (emptyEl) emptyEl.hidden = true;
|
||
if (errorEl) errorEl.hidden = true;
|
||
|
||
const result = await fetchMeetings();
|
||
|
||
loadingEl.hidden = true;
|
||
|
||
if (!result?.ok) {
|
||
if (errorEl) {
|
||
errorEl.innerHTML = errorNotice(t('Could not load meetings.')); // raw-html-ok: errorNotice returns escaped HTML
|
||
errorEl.hidden = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const kpis = result.kpis || {};
|
||
const timeline = result.timeline || {};
|
||
const upcoming = timeline.upcoming || {};
|
||
const past = timeline.past || {};
|
||
const hasEntries = Object.keys(upcoming).length > 0 || Object.keys(past).length > 0;
|
||
|
||
if (!hasEntries) {
|
||
if (emptyEl) {
|
||
emptyEl.innerHTML = renderEmptyState(t('No meetings found for this customer.')); // raw-html-ok: renderEmptyState returns escaped HTML
|
||
emptyEl.hidden = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Render timeline — all dynamic values passed through esc() which HTML-entity-escapes
|
||
const timelineEl = document.getElementById('meetings-timeline');
|
||
if (!timelineEl) { contentEl.hidden = false; return; }
|
||
|
||
let html = '';
|
||
|
||
if (Object.keys(upcoming).length > 0) {
|
||
html += `<div class="helpdesk-meetings-group-header">${esc(t('Upcoming meetings'))}</div>`;
|
||
html += renderMeetingGroups(upcoming);
|
||
}
|
||
|
||
if (Object.keys(past).length > 0) {
|
||
html += `<div class="helpdesk-meetings-group-header helpdesk-meetings-group-header-past">${esc(t('Past meetings'))}</div>`;
|
||
html += renderMeetingGroups(past);
|
||
}
|
||
|
||
timelineEl.innerHTML = html; // raw-html-ok: all values escaped via esc()
|
||
contentEl.hidden = false;
|
||
}
|
||
|
||
function renderMeetingGroups(groups) {
|
||
const monthFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'long' });
|
||
const dateFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
|
||
let html = '';
|
||
|
||
for (const [monthKey, entries] of Object.entries(groups)) {
|
||
const monthDate = new Date(monthKey + '-01');
|
||
const monthLabel = Number.isNaN(monthDate.getTime()) ? monthKey : monthFmt.format(monthDate);
|
||
html += `<div class="helpdesk-meetings-month"><span class="helpdesk-meetings-month-label">${esc(monthLabel)}</span>`;
|
||
|
||
for (const entry of entries) {
|
||
const d = new Date(entry.appointment_date);
|
||
const dateStr = Number.isNaN(d.getTime()) ? entry.appointment_date : dateFmt.format(d);
|
||
const statusCls = entry.is_released ? 'helpdesk-meetings-card-released' : 'helpdesk-meetings-card-pending';
|
||
const badgeCls = entry.is_released ? 'helpdesk-meetings-badge-released' : 'helpdesk-meetings-badge-pending';
|
||
const badgeLabel = entry.is_released ? t('Released') : t('Pending');
|
||
|
||
html += `<article class="helpdesk-meetings-card ${statusCls}">
|
||
<p class="helpdesk-meetings-card-comment">${esc(entry.comment || '—')}</p>
|
||
<p class="helpdesk-meetings-card-secondary">
|
||
<span class="helpdesk-meetings-card-context">
|
||
<time datetime="${esc(entry.appointment_date)}">${esc(dateStr)}</time>
|
||
<span>·</span>
|
||
<span>${esc(entry.salesperson_code)}</span>
|
||
</span>
|
||
<span class="helpdesk-meetings-badge ${badgeCls}" aria-label="${esc(badgeLabel)}">${esc(badgeLabel)}</span>
|
||
</p>
|
||
</article>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
function renderContractTimeline(contracts) {
|
||
const chartEl = document.getElementById('sales-trend-chart');
|
||
if (!chartEl || !contracts.length) return;
|
||
|
||
// Store contracts for dialog access
|
||
salesContracts = contracts;
|
||
|
||
const amountFmt = new Intl.NumberFormat(undefined, {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
maximumFractionDigits: 0,
|
||
});
|
||
|
||
const nowDate = new Date();
|
||
|
||
// Parse dates and compute timeline range
|
||
const items = [];
|
||
let globalMin = Infinity;
|
||
let globalMax = -Infinity;
|
||
|
||
for (let i = 0; i < contracts.length; i++) {
|
||
const c = contracts[i];
|
||
const startRaw = String(c.starting_date || '').trim();
|
||
if (!startRaw || startRaw.startsWith('0001')) continue;
|
||
|
||
const startTs = new Date(startRaw).getTime();
|
||
if (Number.isNaN(startTs)) continue;
|
||
|
||
const stateLower = String(c.state || '').toLowerCase();
|
||
const isActive = stateLower === 'aktiv';
|
||
|
||
let endTs;
|
||
if (isActive) {
|
||
endTs = nowDate.getTime();
|
||
} else {
|
||
const endRaw = String(c.next_invoicing_date || '').trim();
|
||
endTs = (endRaw && !endRaw.startsWith('0001')) ? new Date(endRaw).getTime() : startTs;
|
||
if (Number.isNaN(endTs)) endTs = startTs;
|
||
}
|
||
|
||
if (startTs < globalMin) globalMin = startTs;
|
||
if (endTs > globalMax) globalMax = endTs;
|
||
if (isActive && nowDate.getTime() > globalMax) globalMax = nowDate.getTime();
|
||
|
||
items.push({
|
||
contract_index: i,
|
||
contract_no: String(c.contract_no || ''),
|
||
product_type: String(c.product_type || '').trim(),
|
||
monthly_amount: Number(c.monthly_amount ?? 0),
|
||
is_active: isActive,
|
||
start_ts: startTs,
|
||
end_ts: endTs,
|
||
});
|
||
}
|
||
|
||
if (items.length === 0) return;
|
||
|
||
// Sort: active first, then by start date
|
||
items.sort((a, b) => {
|
||
if (a.is_active !== b.is_active) return a.is_active ? -1 : 1;
|
||
return a.start_ts - b.start_ts;
|
||
});
|
||
|
||
// Extend range to full years so all year ticks are always visible
|
||
const firstYear = new Date(globalMin).getFullYear();
|
||
const lastYear = new Date(globalMax).getFullYear();
|
||
const yearStartMin = new Date(firstYear, 0, 1).getTime();
|
||
const yearStartMax = new Date(lastYear, 0, 1).getTime();
|
||
const rangeMin = Math.min(globalMin, yearStartMin);
|
||
const rangeMax = Math.max(globalMax, yearStartMax);
|
||
const range = rangeMax - rangeMin || 1;
|
||
const padding = range * 0.02;
|
||
const timeMin = rangeMin - padding;
|
||
const timeMax = rangeMax + padding;
|
||
const timeRange = timeMax - timeMin;
|
||
|
||
// Build year tick marks + vertical guide lines
|
||
const minYear = new Date(globalMin).getFullYear();
|
||
const maxYear = new Date(globalMax).getFullYear();
|
||
let ticksHtml = '';
|
||
let guidesHtml = '';
|
||
for (let y = minYear; y <= maxYear; y++) {
|
||
const yearTs = new Date(y, 0, 1).getTime();
|
||
const pct = ((yearTs - timeMin) / timeRange) * 100;
|
||
if (pct >= 0 && pct <= 100) {
|
||
ticksHtml += `<div class="helpdesk-tl-tick" style="left:${pct.toFixed(2)}%">
|
||
<span class="helpdesk-tl-tick-label">${y}</span>
|
||
</div>`;
|
||
guidesHtml += `<div class="helpdesk-tl-guide" style="left:${pct.toFixed(2)}%"></div>`;
|
||
}
|
||
}
|
||
|
||
// Build swimlane rows
|
||
let rowsHtml = '';
|
||
for (const item of items) {
|
||
const leftPct = ((item.start_ts - timeMin) / timeRange) * 100;
|
||
const widthPct = ((item.end_ts - item.start_ts) / timeRange) * 100;
|
||
const barCls = item.is_active ? 'helpdesk-tl-bar-active' : 'helpdesk-tl-bar-ended';
|
||
const typeLabel = item.product_type ? item.product_type.toUpperCase() : '';
|
||
const amtLabel = item.monthly_amount > 0 ? amountFmt.format(item.monthly_amount) : '';
|
||
|
||
const startDate = new Date(item.start_ts).toLocaleDateString();
|
||
const endLabel = item.is_active ? t('active') : new Date(item.end_ts).toLocaleDateString();
|
||
const tooltip = `${item.contract_no} · ${startDate} – ${endLabel}` + (amtLabel ? ` · ${amtLabel}/M` : '');
|
||
|
||
rowsHtml += `<div class="helpdesk-tl-row helpdesk-tl-row-clickable" data-contract-index="${item.contract_index}" tabindex="0">
|
||
<div class="helpdesk-tl-label">
|
||
<span class="helpdesk-tl-type">${esc(typeLabel || item.contract_no)}</span>
|
||
<span class="helpdesk-tl-amount">${esc(amtLabel ? amtLabel + '/M' : '')}</span>
|
||
</div>
|
||
<div class="helpdesk-tl-track">
|
||
<div class="helpdesk-tl-bar ${barCls}" style="left:${leftPct.toFixed(2)}%;width:${Math.max(widthPct, 0.5).toFixed(2)}%"
|
||
data-tooltip="${esc(tooltip)}" data-tooltip-pos="top">${item.is_active ? '<span class="helpdesk-tl-bar-arrow"></span>' : ''}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
chartEl.innerHTML = `
|
||
<div class="helpdesk-tl">
|
||
<div class="helpdesk-tl-body">
|
||
<div class="helpdesk-tl-guides">${guidesHtml}</div>
|
||
<div class="helpdesk-tl-rows">${rowsHtml}</div>
|
||
</div>
|
||
<div class="helpdesk-tl-axis">${ticksHtml}</div>
|
||
</div>
|
||
<div class="helpdesk-tl-legend">
|
||
<span class="helpdesk-tl-legend-item">
|
||
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-active"></span>
|
||
${esc(t('active'))}
|
||
</span>
|
||
<span class="helpdesk-tl-legend-item">
|
||
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-ended"></span>
|
||
${esc(t('ended'))}
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function renderDebitorCommunicationAside() {
|
||
const feedEl = document.getElementById('debitor-communication-feed');
|
||
const loadingEl = document.getElementById('debitor-communication-loading');
|
||
const contentEl = document.getElementById('debitor-communication-content');
|
||
if (!feedEl || !loadingEl || !contentEl) return;
|
||
|
||
showLoading('debitor-communication-loading');
|
||
const result = await fetchDebitorCommunication();
|
||
hideLoading('debitor-communication-loading');
|
||
showContent('debitor-communication-content');
|
||
|
||
if (!result.ok) {
|
||
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
if ((result.meta?.soap_partial_failures || 0) > 0) {
|
||
html += renderCommunicationHint(t('Some ticket communications could not be loaded completely.'));
|
||
}
|
||
html += renderCommunicationFeed(result.entries || [], {
|
||
t,
|
||
emptyMessage: 'No communication found.',
|
||
showTicketNo: true,
|
||
ticketBaseUrl,
|
||
fileDownloadUrl,
|
||
});
|
||
feedEl.innerHTML = html;
|
||
}
|
||
|
||
// --- Controlling tab ---
|
||
|
||
const controllingDashboardUrl = container.dataset.controllingDashboardUrl || '';
|
||
let controllingTabRendered = false;
|
||
let controllingCurrentPeriod = 90;
|
||
|
||
async function fetchControllingDashboard(periodDays) {
|
||
const params = buildBaseParams();
|
||
params.set('periodDays', String(periodDays));
|
||
const url = controllingDashboardUrl + '?' + params.toString();
|
||
try {
|
||
return await getJson(url, { signal: requestController.signal });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function renderControllingTab(periodDays) {
|
||
showLoading('controlling-spinner');
|
||
const contentEl = document.getElementById('controlling-content');
|
||
const errorEl = document.getElementById('controlling-error');
|
||
const emptyEl = document.getElementById('controlling-empty');
|
||
if (contentEl) contentEl.hidden = true;
|
||
if (errorEl) errorEl.hidden = true;
|
||
if (emptyEl) emptyEl.hidden = true;
|
||
|
||
const result = await fetchControllingDashboard(periodDays);
|
||
hideLoading('controlling-spinner');
|
||
|
||
if (!result?.ok) {
|
||
if (errorEl) {
|
||
errorEl.innerHTML = errorNotice(t('Could not load controlling dashboard.'));
|
||
errorEl.hidden = false;
|
||
}
|
||
controllingTabRendered = true;
|
||
return;
|
||
}
|
||
|
||
const kpis = result.kpis || {};
|
||
const meta = result.meta || {};
|
||
|
||
if (meta.created_in_period === 0 && meta.created_in_prev === 0 && kpis.backlog_open === 0) {
|
||
if (emptyEl) {
|
||
emptyEl.innerHTML = renderEmptyState({
|
||
message: t('No controlling data available.'),
|
||
size: 'compact',
|
||
icon: false,
|
||
});
|
||
emptyEl.hidden = false;
|
||
}
|
||
controllingTabRendered = true;
|
||
return;
|
||
}
|
||
|
||
if (contentEl) contentEl.hidden = false;
|
||
|
||
// --- KPI 1: Ticket Volume ---
|
||
const volume = Number(kpis.volume ?? 0);
|
||
const volumePrev = Number(kpis.volume_prev ?? 0);
|
||
const volumeDelta = Number(kpis.volume_delta ?? 0);
|
||
setText('controlling-kpi-volume', String(volume));
|
||
if (volumeDelta !== 0 || volumePrev > 0) {
|
||
const arrow = volumeDelta > 0 ? '↑' : volumeDelta < 0 ? '↓' : '—';
|
||
const abs = Math.abs(volumeDelta);
|
||
// More tickets = more load = negative for controlling
|
||
const cls = volumeDelta > 0 ? 'helpdesk-kpi-trend-negative'
|
||
: volumeDelta < 0 ? 'helpdesk-kpi-trend-positive'
|
||
: 'helpdesk-kpi-trend-neutral';
|
||
setHtml('controlling-kpi-volume-trend',
|
||
`<span class="${cls}">${arrow} ${abs} ${esc(t('vs. prev. period'))}</span>`
|
||
);
|
||
}
|
||
|
||
// --- KPI 2: Close-Rate ---
|
||
const closeRate = kpis.close_rate;
|
||
const closeRatePrev = kpis.close_rate_prev;
|
||
setText('controlling-kpi-closerate', closeRate != null ? closeRate.toFixed(1) + '×' : '—');
|
||
if (closeRate != null && closeRatePrev != null) {
|
||
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive'
|
||
: 'helpdesk-kpi-trend-negative';
|
||
const prevLabel = closeRatePrev.toFixed(1) + '×';
|
||
setHtml('controlling-kpi-closerate-trend',
|
||
`<span class="${cls}">${esc(t('vs. prev. period'))}: ${prevLabel}</span>`
|
||
);
|
||
} else if (closeRate != null) {
|
||
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
|
||
const label = closeRate >= 1.0 ? t('Backlog shrinking') : t('Backlog growing');
|
||
setHtml('controlling-kpi-closerate-trend', `<span class="${cls}">${esc(label)}</span>`);
|
||
}
|
||
|
||
// --- KPI 3: Avg. Resolution ---
|
||
const resolution = kpis.resolution_hours;
|
||
const resolutionPrev = kpis.resolution_hours_prev;
|
||
setText('controlling-kpi-resolution', resolution != null ? fmtHours(resolution) : '—');
|
||
if (resolution != null && resolutionPrev != null) {
|
||
const delta = resolution - resolutionPrev;
|
||
if (delta === 0) {
|
||
setHtml('controlling-kpi-resolution-trend',
|
||
`<span class="helpdesk-kpi-trend-neutral">— ${esc(t('vs. prev. period'))}</span>`
|
||
);
|
||
} else {
|
||
const arrow = delta > 0 ? '↑' : '↓';
|
||
const absDelta = Math.abs(Math.round(delta));
|
||
// Lower resolution = better
|
||
const cls = delta < 0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative';
|
||
setHtml('controlling-kpi-resolution-trend',
|
||
`<span class="${cls}">${arrow} ${fmtHours(absDelta)} ${esc(t('vs. prev. period'))}</span>`
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- KPI 4: Open Backlog ---
|
||
const backlog = Number(kpis.backlog_open ?? 0);
|
||
const unassigned = Number(kpis.backlog_unassigned ?? 0);
|
||
const critical = Number(kpis.backlog_critical ?? 0);
|
||
setText('controlling-kpi-backlog', String(backlog));
|
||
const parts = [];
|
||
if (unassigned > 0) parts.push(unassigned + ' ' + t('unassigned'));
|
||
if (critical > 0) parts.push(critical + ' ' + t('critical'));
|
||
if (parts.length > 0) {
|
||
const cls = critical > 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral';
|
||
setHtml('controlling-kpi-backlog-trend', `<span class="${cls}">${esc(parts.join(', '))}</span>`);
|
||
}
|
||
|
||
// Trend chart
|
||
renderTrendChart(result.trend || []);
|
||
|
||
|
||
|
||
// Risk indicators
|
||
renderControllingRiskIndicators(result.risk_indicators || [], result.risk_summary || {});
|
||
|
||
controllingTabRendered = true;
|
||
}
|
||
|
||
function renderTrendChart(trend) {
|
||
const chartEl = document.getElementById('controlling-trend-chart');
|
||
if (!chartEl || !trend.length) return;
|
||
|
||
// Find max value for Y-axis scaling
|
||
let max = 0;
|
||
for (const bucket of trend) {
|
||
if (bucket.created > max) max = bucket.created;
|
||
if (bucket.closed > max) max = bucket.closed;
|
||
}
|
||
if (max === 0) max = 1;
|
||
|
||
// Compute nice grid lines (2-3 lines)
|
||
const gridStep = max <= 5 ? 1 : max <= 15 ? 5 : max <= 50 ? 10 : Math.ceil(max / 4 / 10) * 10;
|
||
let gridHtml = '';
|
||
for (let v = gridStep; v <= max; v += gridStep) {
|
||
const pct = ((v / max) * 100).toFixed(1);
|
||
gridHtml += `<div class="helpdesk-vchart-grid-line" style="bottom:${pct}%">
|
||
<span class="helpdesk-vchart-grid-label">${v}</span>
|
||
</div>`;
|
||
}
|
||
|
||
// Bar groups
|
||
let barsHtml = '';
|
||
for (const bucket of trend) {
|
||
const createdPct = ((bucket.created / max) * 100).toFixed(1);
|
||
const closedPct = ((bucket.closed / max) * 100).toFixed(1);
|
||
barsHtml += `<div class="helpdesk-vchart-group">
|
||
<div class="helpdesk-vchart-bars">
|
||
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-created" style="height:${createdPct}%" data-tooltip="${bucket.created}" data-tooltip-pos="top"></div>
|
||
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-closed" style="height:${closedPct}%" data-tooltip="${bucket.closed}" data-tooltip-pos="top"></div>
|
||
</div>
|
||
<span class="helpdesk-vchart-label">${esc(bucket.label)}</span>
|
||
</div>`;
|
||
}
|
||
|
||
chartEl.innerHTML = `
|
||
<div class="helpdesk-vchart-area">
|
||
${gridHtml}
|
||
<div class="helpdesk-vchart-groups">${barsHtml}</div>
|
||
</div>
|
||
<div class="helpdesk-vchart-legend">
|
||
<span class="helpdesk-vchart-legend-item">
|
||
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-created"></span>
|
||
${esc(t('Created'))}
|
||
</span>
|
||
<span class="helpdesk-vchart-legend-item">
|
||
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-closed"></span>
|
||
${esc(t('Closed'))}
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderControllingRiskIndicators(indicators, summary) {
|
||
const el = document.getElementById('controlling-risk-indicators');
|
||
if (!el || !indicators.length) return;
|
||
|
||
// Count triggered from actual indicator data (single source of truth)
|
||
const total = indicators.length;
|
||
const triggered = indicators.filter(ind => !!ind.triggered).length;
|
||
const summaryLabel = triggered === 0
|
||
? t('All checks passed')
|
||
: triggered + ' ' + t('of') + ' ' + total + ' ' + t('checks flagged');
|
||
const summaryCls = triggered === 0 ? 'helpdesk-risk-summary-ok'
|
||
: triggered <= 2 ? 'helpdesk-risk-summary-warn'
|
||
: 'helpdesk-risk-summary-critical';
|
||
|
||
let html = `<div class="helpdesk-risk-header">
|
||
<span class="helpdesk-risk-header-icon ${summaryCls}">${triggered === 0 ? '✓' : '⚠'}</span>
|
||
<span class="helpdesk-risk-header-label">${esc(summaryLabel)}</span>
|
||
</div>`;
|
||
html += '<div class="helpdesk-risk-checks">';
|
||
for (const ind of indicators) {
|
||
const icon = ind.triggered ? '⚠' : '✓';
|
||
const iconCls = ind.triggered ? 'helpdesk-risk-icon-warn' : 'helpdesk-risk-icon-ok';
|
||
const barCls = ind.triggered ? 'helpdesk-risk-bar-over' : 'helpdesk-risk-bar-ok';
|
||
|
||
// Threshold bar: normalised to whichever is larger (value or threshold)
|
||
// so the bar always fits inside the track and the limit marker stays visible.
|
||
const rawVal = ind.raw_value ?? 0;
|
||
const thresholdVal = ind.threshold_value ?? 1;
|
||
const scale = Math.max(rawVal, thresholdVal, 1); // prevent division by 0
|
||
const fillPct = Math.min(100, (rawVal / scale) * 100);
|
||
const limitPct = Math.min(100, (thresholdVal / scale) * 100);
|
||
|
||
// Format display value with fmtHours for hour-based units
|
||
const displayValue = ind.unit === 'h' && typeof rawVal === 'number' && rawVal > 0
|
||
? fmtHours(rawVal)
|
||
: esc(ind.value);
|
||
const limitLabel = ind.unit === 'h' ? t('Limit') + ' ' + fmtHours(thresholdVal) : t('Limit') + ' ' + thresholdVal + ind.unit;
|
||
|
||
const descText = ind.description ? t(ind.description) : '';
|
||
|
||
html += `<div class="helpdesk-risk-check">
|
||
<div class="helpdesk-risk-check-header">
|
||
<span class="helpdesk-risk-icon ${iconCls}">${icon}</span>
|
||
<span class="helpdesk-risk-check-label">${esc(t(ind.label) || ind.label)}</span>
|
||
</div>
|
||
<div class="helpdesk-risk-check-value">${displayValue}</div>
|
||
${descText ? `<div class="helpdesk-risk-check-desc">${esc(descText)}</div>` : ''}
|
||
<div class="helpdesk-risk-bar-wrapper">
|
||
<div class="helpdesk-risk-bar-track">
|
||
<div class="helpdesk-risk-bar-fill ${barCls}" style="width:${fillPct}%"></div>
|
||
</div>
|
||
<div class="helpdesk-risk-bar-limit" style="left:${limitPct}%"></div>
|
||
</div>
|
||
<div class="helpdesk-risk-bar-legend">${esc(limitLabel)}</div>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// Period selector (radio inputs)
|
||
const periodSelector = document.getElementById('controlling-period-selector');
|
||
on(periodSelector, 'change', (e) => {
|
||
const radio = e.target.closest('input[name="controlling-period"]');
|
||
if (!radio) return;
|
||
const period = parseInt(radio.value, 10);
|
||
if (!period || period === controllingCurrentPeriod) return;
|
||
|
||
controllingCurrentPeriod = period;
|
||
controllingTabRendered = false;
|
||
void renderControllingTab(period);
|
||
});
|
||
|
||
const tabPanels = container.querySelectorAll('[data-tab-panel]');
|
||
const observer = new MutationObserver(() => {
|
||
for (const panel of tabPanels) {
|
||
if (panel.hidden) continue;
|
||
const tabName = panel.dataset.tabPanel;
|
||
if (tabName === 'support') {
|
||
renderSupportTab();
|
||
initTicketsGrid();
|
||
} else if (tabName === 'sales') {
|
||
renderSalesTab();
|
||
} else if (tabName === 'controlling') {
|
||
if (!controllingTabRendered) {
|
||
renderControllingTab(controllingCurrentPeriod);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
for (const panel of tabPanels) {
|
||
observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] });
|
||
}
|
||
|
||
const refreshButton = container.querySelector('button[name="support-refresh"]')
|
||
|| document.getElementById('support-refresh-button');
|
||
on(refreshButton, 'click', () => {
|
||
refreshButton.setAttribute('aria-busy', 'true');
|
||
refreshButton.setAttribute('disabled', 'disabled');
|
||
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('refresh', '1');
|
||
window.location.assign(url.toString());
|
||
});
|
||
|
||
renderSupportTab();
|
||
void initTicketsGrid();
|
||
void renderDebitorCommunicationAside();
|
||
|
||
return {
|
||
destroy: () => {
|
||
listenerController.abort();
|
||
requestController.abort();
|
||
observer.disconnect();
|
||
const dialog = document.getElementById('helpdesk-contract-dialog');
|
||
if (dialog) {
|
||
dialog.remove();
|
||
}
|
||
},
|
||
};
|
||
}
|