/** * Helpdesk debtor detail page โ€” role-based dashboard rendering. * * - Support tab: health tiles, system recommendations, contracts/products widget, tickets grid * - Sales tab: contact table (lazy-loaded) * - Controlling tab: placeholder * - Aside: cross-ticket communication feed */ import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { readPageConfig } from '/js/core/app-page-config.js'; import { renderCommunicationError, renderCommunicationFeed, renderCommunicationHint, } from './helpdesk-communication.js'; import { renderEmptyState } from './helpdesk-empty-state.js'; const container = document.querySelector('.app-details-container[data-customer-no]'); if (container) { init(container); } function init(container) { const customerNo = container.dataset.customerNo || ''; const customerName = container.dataset.customerName || ''; const supportDashboardUrl = container.dataset.supportDashboardUrl || ''; const contactsUrl = container.dataset.contactsUrl || ''; const ticketBaseUrl = container.dataset.ticketBaseUrl || ''; const communicationUrl = container.dataset.communicationUrl || ''; const fileDownloadUrl = container.dataset.fileDownloadUrl || ''; const salesDashboardUrl = container.dataset.salesDashboardUrl || ''; const refreshFromUrl = (() => { const value = new URLSearchParams(window.location.search).get('refresh'); if (!value) return false; const normalized = String(value).trim().toLowerCase(); return normalized === '1' || normalized === 'true' || normalized === 'yes'; })(); if (!customerNo || !customerName) return; const i18nEl = document.getElementById('helpdesk-i18n'); const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {}; const t = (key) => i18n[key] || key; let supportDashboardPromise = null; let salesDashboardPromise = null; let contactsPromise = null; let ticketCategoriesPromise = null; let debitorCommunicationPromise = null; let supportTabRendered = false; let salesTabRendered = false; let ticketsGridInitialized = false; let ticketsGridInitializing = false; function buildBaseParams() { const params = new URLSearchParams({ customerNo, customerName }); if (refreshFromUrl) { params.set('refresh', '1'); } return params; } function fetchSupportDashboard() { if (!supportDashboardPromise) { if (!supportDashboardUrl) { supportDashboardPromise = Promise.resolve({ ok: false, error: 'No support dashboard endpoint configured' }); } else { const params = buildBaseParams(); supportDashboardPromise = fetch(supportDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' }) .then(r => r.json()) .catch(() => ({ ok: false, error: 'Network error' })); } } return supportDashboardPromise; } function fetchSalesDashboard() { if (!salesDashboardPromise) { if (!salesDashboardUrl) { salesDashboardPromise = Promise.resolve({ ok: false, error: 'No sales dashboard endpoint configured' }); } else { const params = buildBaseParams(); salesDashboardPromise = fetch(salesDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' }) .then(r => r.json()) .catch(() => ({ ok: false, error: 'Network error' })); } } return salesDashboardPromise; } function fetchContacts() { if (!contactsPromise) { const params = buildBaseParams(); contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' }) .then(r => r.json()) .catch(() => ({ ok: false, error: 'Network error' })); } return contactsPromise; } function fetchTicketCategories(config, appBase) { if (!ticketCategoriesPromise) { const categoryOptionsUrl = config.categoryOptionsUrl || ''; if (!categoryOptionsUrl) { ticketCategoriesPromise = Promise.resolve({ ok: false, categories: [] }); return ticketCategoriesPromise; } const url = new URL(categoryOptionsUrl, appBase); url.searchParams.set('customerNo', config.customerNo || customerNo); url.searchParams.set('customerName', config.customerName || customerName); if (refreshFromUrl) { url.searchParams.set('refresh', '1'); } ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' }) .then(r => r.json()) .catch(() => ({ ok: false, categories: [] })); } return ticketCategoriesPromise; } function fetchDebitorCommunication() { if (!debitorCommunicationPromise) { if (!communicationUrl) { debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' }); } else { const params = buildBaseParams(); debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' }) .then(r => r.json()) .catch(() => ({ ok: false, error: 'Network error' })); } } return debitorCommunicationPromise; } /** * Hydrate a dynamic select filter with values from the API response. * Works for category, support user, and contact filters. */ function hydrateSelectFilter(config, selectorId, filterKey, values) { const selectEl = document.querySelector(selectorId); if (!(selectEl instanceof HTMLSelectElement)) return; const uniqueValues = Array.from(new Set( (Array.isArray(values) ? values : []) .map(item => String(item || '').trim()) .filter(Boolean), )).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); const currentValue = String(selectEl.value || ''); selectEl.innerHTML = ''; const allOption = document.createElement('option'); allOption.value = ''; allOption.textContent = t('All'); selectEl.appendChild(allOption); const optionMap = {}; for (const value of uniqueValues) { const option = document.createElement('option'); option.value = value; option.textContent = value; selectEl.appendChild(option); optionMap[value] = value; } if (currentValue !== '' && Object.prototype.hasOwnProperty.call(optionMap, currentValue)) { selectEl.value = currentValue; } else { selectEl.value = ''; } if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') { config.filterChipMeta = {}; } const currentMeta = (config.filterChipMeta[filterKey] && typeof config.filterChipMeta[filterKey] === 'object') ? config.filterChipMeta[filterKey] : {}; config.filterChipMeta[filterKey] = { ...currentMeta, type: 'select', default: '', options: optionMap, }; } function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } function ticketUrl(ticketNo) { return ticketBaseUrl + encodeURIComponent(ticketNo) + '?from=' + encodeURIComponent(customerNo); } function errorNotice(message) { return ``; } let contactsGridInitialized = false; let contactsGridInitializing = false; async function initContactsGrid() { if (contactsGridInitialized || contactsGridInitializing) return; contactsGridInitializing = true; const contactsConfig = readPageConfig('helpdesk-contacts'); if (!contactsConfig) { contactsGridInitializing = false; return; } const labels = contactsConfig.labels || {}; const gridjsLib = window.gridjs; if (!gridjsLib) { contactsGridInitializing = false; return; } const baseEl = document.querySelector('base'); const appBase = (baseEl && baseEl.href) ? baseEl.href : '/'; const gridDataUrl = new URL(contactsConfig.dataUrl || '', appBase); gridDataUrl.searchParams.set('customerNo', contactsConfig.customerNo || customerNo); gridDataUrl.searchParams.set('customerName', contactsConfig.customerName || customerName); if (refreshFromUrl) { gridDataUrl.searchParams.set('refresh', '1'); } try { // Fetch type options from first data response const typeOptionsUrl = new URL(gridDataUrl.toString()); typeOptionsUrl.searchParams.set('limit', '1'); const typeResp = await fetch(typeOptionsUrl.toString()).then((r) => r.json()).catch(() => null); const typeOptions = Array.isArray(typeResp?.type_options) ? typeResp.type_options : []; hydrateContactTypeFilter(contactsConfig, typeOptions); const gridOptions = { gridjs: gridjsLib, container: '#helpdesk-contacts-grid', dataUrl: gridDataUrl.toString(), appBase, columns: [ { name: labels.name || 'Name', sort: true }, { name: labels.type || 'Type', sort: true }, { name: labels.jobTitle || 'Job title', sort: true }, { name: labels.email || 'E-Mail', sort: true }, { name: labels.phone || 'Phone', sort: true }, { name: labels.mobile || 'Mobile', sort: false }, ], sortColumns: ['Name', 'Type', 'Job_Title', 'E_Mail', 'Phone_No', null], paginationLimit: 10, language: contactsConfig.gridLang || {}, mapData: (data) => (data.data || []).map((row) => [ row.Name || '', row.Type || '', row.Job_Title || '', row.E_Mail || '', row.Phone_No || '', row.Mobile_Phone_No || '', ]), search: contactsConfig.gridSearch || null, filterSchema: contactsConfig.filterSchema || [], urlSync: false, actions: { enabled: false }, }; // Scope filter UI to the contacts section to avoid collision with tickets filter const contactsSection = document.getElementById('sales-contacts-section'); const contactsChipsEl = contactsSection ? contactsSection.querySelector('[data-active-filter-chips]') : null; const contactsLiveEl = contactsSection ? contactsSection.querySelector('[data-filter-live-region]') : null; initStandardListPage({ grid: gridOptions, filters: { mode: 'drawer', chipMeta: contactsConfig.filterChipMeta || {}, watchInputs: ['#helpdesk-contact-search'], drawerRoot: contactsSection || undefined, chips: contactsChipsEl ? { container: contactsChipsEl } : {}, live: contactsLiveEl ? { regionSelector: null } : {}, }, }); contactsGridInitialized = true; } finally { contactsGridInitializing = false; } } function hydrateContactTypeFilter(contactsConfig, typeOptions) { const select = document.getElementById('helpdesk-contact-type-filter'); if (!(select instanceof HTMLSelectElement) || !Array.isArray(typeOptions)) return; const currentValue = String(select.value || ''); select.innerHTML = ''; const allOption = document.createElement('option'); allOption.value = ''; allOption.textContent = t('All'); select.appendChild(allOption); const typeOptionMap = {}; for (const opt of typeOptions) { const id = String(opt.id || '').trim(); const label = String(opt.description || opt.id || '').trim(); if (id === '') continue; const option = document.createElement('option'); option.value = id; option.textContent = label; select.appendChild(option); typeOptionMap[id] = label; } if (currentValue !== '' && Object.prototype.hasOwnProperty.call(typeOptionMap, currentValue)) { select.value = currentValue; } else { select.value = ''; } if (!contactsConfig.filterChipMeta || typeof contactsConfig.filterChipMeta !== 'object') { contactsConfig.filterChipMeta = {}; } const currentTypeMeta = (contactsConfig.filterChipMeta.type && typeof contactsConfig.filterChipMeta.type === 'object') ? contactsConfig.filterChipMeta.type : {}; contactsConfig.filterChipMeta.type = { ...currentTypeMeta, type: 'select', default: '', options: typeOptionMap, }; } function formatTemplate(template, params) { const tpl = String(template || ''); const map = (params && typeof params === 'object') ? params : {}; return tpl.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => { const value = map[key]; if (value === null || value === undefined) { return ''; } return String(value); }); } const severityMap = { escalation_overdue: { cls: 'critical', icon: '๐Ÿ”ด' }, high_risk_aging: { cls: 'high', icon: '๐ŸŸ ' }, unassigned_open: { cls: 'medium', icon: '๐ŸŸก' }, stale_open: { cls: 'low', icon: '๐Ÿ”ต' }, customer_backlog: { cls: 'low', icon: '๐Ÿ”ต' }, }; function renderSystemRecommendations(recommendations) { const entries = Array.isArray(recommendations) ? recommendations : []; if (entries.length === 0) { return renderEmptyState({ message: t('No system recommendations right now.'), size: 'compact', align: 'center', icon: false, }); } const formatRecommendationMessage = (entry) => { const messageKey = String(entry.message_key || ''); const template = t(messageKey); const params = (entry.message_params && typeof entry.message_params === 'object') ? entry.message_params : {}; if (template && template !== messageKey) { return formatTemplate(template, params); } return messageKey || String(entry.rule_id || 'โ€”'); }; let html = '
'; 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 += `
`; html += `${esc(ticketNo || 'โ€”')}`; if (ageLabel) html += `${esc(ageLabel)}`; html += `
`; html += `
${esc(message)}
`; html += ``; } html += '
'; 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 `

${esc(t('No contract details available.'))}

`; } let html = ''; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; for (const line of lines) { const lineVariant = String(line.line_state || '').toLowerCase() === 'aktiv' ? 'success' : 'neutral'; html += ''; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
${esc(t('Description'))}${esc(t('Type'))}${esc(t('Quantity'))}${esc(t('Unit price'))}${esc(t('Line amount'))}${esc(t('Line state'))}
${esc(line.description || 'โ€”')}${line.type ? `${esc(line.type)}` : 'โ€”'}${esc(String(line.quantity ?? 0))}${esc(contractAmountFmt.format(Number(line.unit_price ?? 0)))}${esc(contractAmountFmt.format(Number(line.line_amount ?? 0)))}${esc(line.line_state || 'โ€”')}
'; 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 = ''; dialogHtml += '
'; // Header โ€” follows core pattern:

then '; dialogHtml += ''; // Contract meta dialogHtml += '
'; const metaItems = []; metaItems.push(`${esc(t('Status'))}${esc(contract.state || 'โ€”')}`); metaItems.push(`${esc(t('Type'))}${esc(typeLabel)}`); if (contract.starting_date) { metaItems.push(`${esc(t('Starting date'))}${esc(contractFormatDate(contract.starting_date))}`); } if (contract.invoicing_period) { metaItems.push(`${esc(t('Invoicing period'))}${esc(contract.invoicing_period)}`); } if (contract.next_invoicing_date) { metaItems.push(`${esc(t('Next invoicing'))}${esc(contractFormatDate(contract.next_invoicing_date))}`); } if (Number(contract.monthly_amount ?? 0) > 0) { metaItems.push(`${esc(t('Monthly volume'))}${esc(contractAmountFmt.format(contract.monthly_amount))}`); } if (contract.salesperson_code) { metaItems.push(`${esc(t('Salesperson'))}${esc(contract.salesperson_code)}`); } if (Number(contract.support_hours ?? 0) > 0) { metaItems.push(`${esc(t('Support hours'))}${esc(String(contract.support_hours))}`); } dialogHtml += metaItems.join(''); dialogHtml += '
'; // Lines table if (lines.length > 0) { dialogHtml += `

${esc(t('positions'))} (${lines.length})

`; dialogHtml += '
'; dialogHtml += renderContractLinesTable(lines); dialogHtml += '
'; } // Footer dialogHtml += ''; dialogHtml += '
'; document.body.insertAdjacentHTML('beforeend', dialogHtml); const dialog = document.getElementById('helpdesk-contract-dialog'); dialog.addEventListener('click', (e) => { if (e.target.closest('[data-dialog-close]')) { dialog.close(); dialog.remove(); return; } // Close on backdrop click if (e.target === dialog) { dialog.close(); dialog.remove(); } }); dialog.addEventListener('close', () => { dialog.remove(); }); dialog.showModal(); } // Delegate click on contract cards and timeline rows container.addEventListener('click', (e) => { const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]'); if (!target) return; const index = Number(target.dataset.contractIndex); if (salesContracts[index]) { openContractDialog(salesContracts[index]); } }); // KPI tile click โ†’ scroll to ticket list and apply filter or sort container.addEventListener('click', (e) => { const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]'); if (!kpi) return; // Apply status filter if present if (kpi.dataset.kpiFilter !== undefined) { const filterValue = kpi.dataset.kpiFilter || ''; const statusSelect = document.getElementById('helpdesk-ticket-status-filter'); if (statusSelect instanceof HTMLSelectElement) { statusSelect.value = filterValue; statusSelect.dispatchEvent(new Event('change', { bubbles: true })); } } // Apply sort if present (format: "column:dir") if (kpi.dataset.kpiSort) { const [col, dir] = kpi.dataset.kpiSort.split(':'); const url = new URL(window.location.href); url.searchParams.set('order', col); url.searchParams.set('dir', dir || 'asc'); // Also set open filter for "avg age" to show only open tickets const statusSelect = document.getElementById('helpdesk-ticket-status-filter'); if (statusSelect instanceof HTMLSelectElement) { statusSelect.value = 'open'; statusSelect.dispatchEvent(new Event('change', { bubbles: true })); } window.history.replaceState(null, '', url.toString()); } const ticketSection = document.querySelector('.helpdesk-support-ticket-list'); if (ticketSection) { ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); function setText(id, value) { const el = document.getElementById(id); if (el) { el.textContent = String(value); } } function setHtml(id, html) { const el = document.getElementById(id); if (el) { el.innerHTML = html; } } function fmtHours(h) { const hours = Math.round(h); if (hours < 24) return hours + 'h'; const days = Math.floor(hours / 24); const rest = hours % 24; return rest > 0 ? days + 'd ' + rest + 'h' : days + 'd'; } function showLoading(id) { const el = document.getElementById(id); if (el) { el.hidden = false; } } function hideLoading(id) { const el = document.getElementById(id); if (el) { el.hidden = true; } } function showContent(id) { const el = document.getElementById(id); if (el) { el.hidden = false; } } container.addEventListener('click', (e) => { if (e.target.closest('a')) return; const row = e.target.closest('.app-clickable-row[data-href]'); if (row) { const href = row.dataset.href; if (href) { window.location.href = href; } } }); container.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const row = e.target.closest('.app-clickable-row[data-href]'); if (row) { e.preventDefault(); const href = row.dataset.href; if (href) { window.location.href = href; } return; } // Timeline row keyboard activation const tlRow = e.target.closest('.helpdesk-tl-row-clickable[data-contract-index]'); if (tlRow) { e.preventDefault(); const index = Number(tlRow.dataset.contractIndex); if (salesContracts[index]) { openContractDialog(salesContracts[index]); } } }); async function initTicketsGrid() { if (ticketsGridInitialized || ticketsGridInitializing) return; ticketsGridInitializing = true; const config = readPageConfig('helpdesk-tickets'); if (!config) { ticketsGridInitializing = false; return; } const labels = config.labels || {}; const gridjs = window.gridjs; if (!gridjs) { ticketsGridInitializing = false; return; } const baseEl = document.querySelector('base'); const appBase = (baseEl && baseEl.href) ? baseEl.href : '/'; const gridDataUrl = new URL(config.dataUrl || '', appBase); gridDataUrl.searchParams.set('customerNo', config.customerNo || customerNo); gridDataUrl.searchParams.set('customerName', config.customerName || customerName); if (refreshFromUrl) { gridDataUrl.searchParams.set('refresh', '1'); } try { const filterOptionsResponse = await fetchTicketCategories(config, appBase); hydrateSelectFilter(config, '#helpdesk-ticket-category-filter', 'category', filterOptionsResponse?.categories || []); hydrateSelectFilter(config, '#helpdesk-ticket-support-filter', 'support', filterOptionsResponse?.support_users || []); hydrateSelectFilter(config, '#helpdesk-ticket-contact-filter', 'contact', filterOptionsResponse?.contacts || []); const gridOptions = { gridjs, container: '#helpdesk-tickets-grid', dataUrl: gridDataUrl.toString(), appBase, columns: [ { name: labels.ticketNo || 'Ticket No.', sort: true, formatter: (cell) => gridjs.html(`${cell.no}`), }, { 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(`${cell.label || '-'}`); }, }, { name: labels.category || 'Category', sort: false }, { name: labels.support || 'Support', sort: false }, { name: labels.created || 'Created', sort: true }, { name: labels.lastActivity || 'Last activity', sort: true }, { name: 'ticket_url', hidden: true }, ], sortColumns: ['No', null, 'Ticket_State', null, null, 'Created_On', 'Last_Activity_Date', null], paginationLimit: 10, language: config.gridLang || {}, mapData: (data) => (data.data || []).map((row) => [ { no: row.No || '', url: row.ticket_url || '#' }, row.Description || '', { variant: row.state_variant || 'neutral', label: row.Ticket_State || '' }, row.Category_1_Description || '', row.Support_User_Name || '', row.created_formatted || '', row.last_activity_formatted || '', row.ticket_url || '', ]), search: config.gridSearch || null, filterSchema: config.filterSchema || [], urlSync: true, actions: { enabled: false }, rowDblClick: { getUrl: (rowData) => { const url = rowData?.cells?.[7]?.data; return url || ''; }, }, rowInteraction: { linkColumn: 0 }, }; initStandardListPage({ grid: gridOptions, filters: { mode: 'drawer', chipMeta: config.filterChipMeta || {}, watchInputs: ['#helpdesk-ticket-search'], }, }); ticketsGridInitialized = true; } finally { ticketsGridInitializing = false; } } async function renderSupportTab() { if (supportTabRendered) return; showLoading('support-loading'); const result = await fetchSupportDashboard(); hideLoading('support-loading'); showContent('support-content'); const recommendationsEl = document.getElementById('support-system-recommendations'); if (!result?.ok) { setText('support-kpi-open-tickets', 'โ€”'); setText('support-kpi-created-30d', 'โ€”'); setText('support-kpi-closed-30d', 'โ€”'); setText('support-kpi-avg-age', 'โ€”'); if (recommendationsEl) { recommendationsEl.innerHTML = errorNotice(t('Could not load tickets.')); } supportTabRendered = true; return; } const health = (result.health && typeof result.health === 'object') ? result.health : {}; const openTickets = Number(health.open_tickets ?? 0); const created30d = Number(health.created_30d ?? 0); const createdPrev30d = Number(health.created_prev_30d ?? 0); const closed30d = Number(health.closed_30d ?? 0); const closedPrev30d = Number(health.closed_prev_30d ?? 0); const avgAgeHours = health.avg_open_age_hours !== null && health.avg_open_age_hours !== undefined ? Number(health.avg_open_age_hours) : null; const criticalTickets = Number(health.critical_tickets ?? 0); // Helper: render trend indicator const renderTrend = (current, previous, invertColor) => { if (previous === 0 && current === 0) return ''; const delta = current - previous; if (delta === 0) return `โ€” ${t('vs. prev. 30d')}`; 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 `${arrow} ${abs} ${t('vs. prev. 30d')}`; }; // 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 = `โ†‘ ${netFlow} ${t('net (30d)')}`; } else if (netFlow < 0) { openTrendEl.innerHTML = `โ†“ ${Math.abs(netFlow)} ${t('net (30d)')}`; } else if (created30d > 0) { openTrendEl.innerHTML = `ยฑ 0 ${t('net (30d)')}`; } } // 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 = `${criticalTickets} ${t('critical')}`; } // 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 `โ€” ${esc(t('vs. prev. 30d'))}`; 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 `${arrow} ${abs} ${esc(t('vs. prev. 30d'))}`; }; 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', `${netArrow} ${netAbs} ${esc(t('net (30d)'))}`); } 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 ? `${esc(t('of {total} total').replace('{total}', totalContracts))}` : ''; 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(`${esc(t('largest'))}: ${esc(amountFmt.format(largest))}`); } if (totalPositions > 0) { avgParts.push(`${esc(totalPositions + ' ' + t('positions'))}`); } 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', `${esc(t('in {days} days').replace('{days}', daysUntil))}`); } // Contract timeline (swimlane view โ€” click on bar opens contract dialog) renderContractTimeline(result.contracts || []); // Init contacts grid (server-side, same pattern as tickets) initContactsGrid(); salesTabRendered = true; } function renderContractTimeline(contracts) { const chartEl = document.getElementById('sales-trend-chart'); if (!chartEl || !contracts.length) return; // Store contracts for dialog access salesContracts = contracts; const amountFmt = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR', maximumFractionDigits: 0, }); const nowDate = new Date(); // Parse dates and compute timeline range const items = []; let globalMin = Infinity; let globalMax = -Infinity; for (let i = 0; i < contracts.length; i++) { const c = contracts[i]; const startRaw = String(c.starting_date || '').trim(); if (!startRaw || startRaw.startsWith('0001')) continue; const startTs = new Date(startRaw).getTime(); if (Number.isNaN(startTs)) continue; const stateLower = String(c.state || '').toLowerCase(); const isActive = stateLower === 'aktiv'; let endTs; if (isActive) { endTs = nowDate.getTime(); } else { const endRaw = String(c.next_invoicing_date || '').trim(); endTs = (endRaw && !endRaw.startsWith('0001')) ? new Date(endRaw).getTime() : startTs; if (Number.isNaN(endTs)) endTs = startTs; } if (startTs < globalMin) globalMin = startTs; if (endTs > globalMax) globalMax = endTs; if (isActive && nowDate.getTime() > globalMax) globalMax = nowDate.getTime(); items.push({ contract_index: i, contract_no: String(c.contract_no || ''), product_type: String(c.product_type || '').trim(), monthly_amount: Number(c.monthly_amount ?? 0), is_active: isActive, start_ts: startTs, end_ts: endTs, }); } if (items.length === 0) return; // Sort: active first, then by start date items.sort((a, b) => { if (a.is_active !== b.is_active) return a.is_active ? -1 : 1; return a.start_ts - b.start_ts; }); // Extend range to full years so all year ticks are always visible const firstYear = new Date(globalMin).getFullYear(); const lastYear = new Date(globalMax).getFullYear(); const yearStartMin = new Date(firstYear, 0, 1).getTime(); const yearStartMax = new Date(lastYear, 0, 1).getTime(); const rangeMin = Math.min(globalMin, yearStartMin); const rangeMax = Math.max(globalMax, yearStartMax); const range = rangeMax - rangeMin || 1; const padding = range * 0.02; const timeMin = rangeMin - padding; const timeMax = rangeMax + padding; const timeRange = timeMax - timeMin; // Build year tick marks + vertical guide lines const minYear = new Date(globalMin).getFullYear(); const maxYear = new Date(globalMax).getFullYear(); let ticksHtml = ''; let guidesHtml = ''; for (let y = minYear; y <= maxYear; y++) { const yearTs = new Date(y, 0, 1).getTime(); const pct = ((yearTs - timeMin) / timeRange) * 100; if (pct >= 0 && pct <= 100) { ticksHtml += `
${y}
`; guidesHtml += `
`; } } // 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 += `
${esc(typeLabel || item.contract_no)} ${esc(amtLabel ? amtLabel + '/M' : '')}
${item.is_active ? '' : ''}
`; } chartEl.innerHTML = `
${guidesHtml}
${rowsHtml}
${ticksHtml}
${esc(t('active'))} ${esc(t('ended'))}
`; } 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 { 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', `${arrow} ${abs} ${esc(t('vs. prev. period'))}` ); } // --- 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', `${esc(t('vs. prev. period'))}: ${prevLabel}` ); } 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', `${esc(label)}`); } // --- 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', `โ€” ${esc(t('vs. prev. period'))}` ); } 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', `${arrow} ${fmtHours(absDelta)} ${esc(t('vs. prev. period'))}` ); } } // --- 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', `${esc(parts.join(', '))}`); } // 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 += `
${v}
`; } // 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 += `
${esc(bucket.label)}
`; } chartEl.innerHTML = `
${gridHtml}
${barsHtml}
${esc(t('Created'))} ${esc(t('Closed'))}
`; } 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 = `
${triggered === 0 ? 'โœ“' : 'โš '} ${esc(summaryLabel)}
`; html += '
'; 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 += `
${icon} ${esc(t(ind.label) || ind.label)}
${displayValue}
${descText ? `
${esc(descText)}
` : ''}
${esc(limitLabel)}
`; } html += '
'; el.innerHTML = html; } // Period selector (radio inputs) const periodSelector = document.getElementById('controlling-period-selector'); if (periodSelector) { periodSelector.addEventListener('change', (e) => { const radio = e.target.closest('input[name="controlling-period"]'); if (!radio) return; const period = parseInt(radio.value, 10); if (!period || period === controllingCurrentPeriod) return; controllingCurrentPeriod = period; controllingTabRendered = false; renderControllingTab(period); }); } const tabPanels = container.querySelectorAll('[data-tab-panel]'); const observer = new MutationObserver(() => { for (const panel of tabPanels) { if (panel.hidden) continue; const tabName = panel.dataset.tabPanel; if (tabName === 'support') { renderSupportTab(); initTicketsGrid(); } else if (tabName === 'sales') { renderSalesTab(); } else if (tabName === 'controlling') { if (!controllingTabRendered) { renderControllingTab(controllingCurrentPeriod); } } } }); for (const panel of tabPanels) { observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] }); } const refreshButton = container.querySelector('button[name="support-refresh"]') || document.getElementById('support-refresh-button'); if (refreshButton) { refreshButton.addEventListener('click', () => { refreshButton.setAttribute('aria-busy', 'true'); refreshButton.setAttribute('disabled', 'disabled'); const url = new URL(window.location.href); url.searchParams.set('refresh', '1'); window.location.assign(url.toString()); }); } renderSupportTab(); initTicketsGrid(); renderDebitorCommunicationAside(); }