/** * Customer analytics — engagement overview (active vs. silent customers, * top communication). Reads the global ticket snapshot aggregated server-side. */ import { getJson } from '/js/core/app-http.js'; import { resolveHost } from '/js/core/app-dom.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-analytics"]')) { return host; } return host.querySelector('.app-details-container[data-app-component="helpdesk-analytics"]'); }; export function initHelpdeskAnalytics(root = document) { const container = resolveContainer(root); if (!container) { return EMPTY_API; } 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 dataUrl = container.dataset.analyticsUrl; const debitorBaseUrl = container.dataset.debitorBaseUrl || ''; const d = (key, fallback) => container.dataset[key] || fallback; const elLoading = document.getElementById('analytics-loading'); const elError = document.getElementById('analytics-error'); const elTruncated = document.getElementById('analytics-truncated'); const elContent = document.getElementById('analytics-content'); const elTiles = document.getElementById('analytics-tiles'); const elSilentTable = document.getElementById('analytics-silent-table'); const elSilentBody = document.getElementById('analytics-silent-body'); const elSilentEmpty = document.getElementById('analytics-silent-empty'); const elTopTable = document.getElementById('analytics-top-table'); const elTopBody = document.getElementById('analytics-top-body'); const elTopEmpty = document.getElementById('analytics-top-empty'); const elRefresh = container.querySelector('button[name="analytics-refresh"]'); const periodSelector = document.getElementById('analytics-period-selector'); let currentPeriod = 90; const showState = (state) => { if (elLoading) elLoading.hidden = state !== 'loading'; if (elError) elError.hidden = state !== 'error'; if (elContent) elContent.hidden = state !== 'success'; }; const h = (tag, cls, text) => { const element = document.createElement(tag); if (cls) element.className = cls; if (text !== undefined) element.textContent = text; return element; }; const customerCell = (row) => { const td = h('td'); const name = row.customer_name || row.customer_no; if (debitorBaseUrl && row.customer_no) { const link = h('a', '', name); link.href = debitorBaseUrl + encodeURIComponent(row.customer_no); td.appendChild(link); } else { td.textContent = name; } if (row.customer_name && row.customer_no) { td.appendChild(h('span', 'helpdesk-analytics-cust-no', ` ${row.customer_no}`)); } return td; }; const lastContactText = (row) => { if (row.days_since_contact === null || row.days_since_contact === undefined) { return d('labelNeverShort', 'never'); } if (row.days_since_contact === 0) { return row.last_contact_iso || ''; } return `${row.last_contact_iso || ''} · ${row.days_since_contact} ${d('labelDaysAgo', 'days ago')}`; }; // Mirror templates/partials/app-tile.phtml so the .app-tile CSS applies, but // render a non-navigating
— these are stat tiles, not links. const buildTile = ({ icon, tone, count, label }) => { const tile = h('div', 'app-tile helpdesk-analytics-tile'); if (tone) tile.dataset.tone = tone; const iconWrap = h('span', 'app-tile-icon'); iconWrap.appendChild(h('i', `bi ${icon}`)); tile.appendChild(iconWrap); tile.appendChild(h('span', 'app-tile-count', String(count))); tile.appendChild(h('span', 'app-tile-label', label)); return tile; }; const renderTiles = (summary) => { if (!elTiles) return; elTiles.replaceChildren(); elTiles.appendChild(buildTile({ icon: 'bi-people', tone: 'blue', count: summary.total_customers || 0, label: d('labelTotal', 'Customers'), })); elTiles.appendChild(buildTile({ icon: 'bi-chat-dots', tone: 'emerald', count: summary.active || 0, label: d('labelActive', 'Active'), })); elTiles.appendChild(buildTile({ icon: 'bi-exclamation-triangle', tone: 'amber', count: summary.silent || 0, label: d('labelSilent', 'No contact'), })); elTiles.appendChild(buildTile({ icon: 'bi-question-circle', tone: 'neutral', count: summary.no_contact_ever || 0, label: d('labelNever', 'Never in contact'), })); }; const renderSilent = (rows) => { if (!elSilentBody || !elSilentTable || !elSilentEmpty) return; elSilentBody.replaceChildren(); if (!rows || rows.length === 0) { elSilentTable.hidden = true; elSilentEmpty.hidden = false; return; } elSilentEmpty.hidden = true; elSilentTable.hidden = false; for (const row of rows) { const tr = h('tr'); tr.appendChild(customerCell(row)); const contactTd = h('td', '', lastContactText(row)); if (row.status === 'no_contact_ever') { contactTd.classList.add('helpdesk-analytics-never'); } tr.appendChild(contactTd); tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0))); elSilentBody.appendChild(tr); } }; const renderTop = (rows) => { if (!elTopBody || !elTopTable || !elTopEmpty) return; elTopBody.replaceChildren(); if (!rows || rows.length === 0) { elTopTable.hidden = true; elTopEmpty.hidden = false; return; } elTopEmpty.hidden = true; elTopTable.hidden = false; for (const row of rows) { const tr = h('tr'); tr.appendChild(customerCell(row)); tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0))); tr.appendChild(h('td', '', lastContactText(row))); elTopBody.appendChild(tr); } }; const fetchData = async (refresh = false) => { showState('loading'); if (elTruncated) elTruncated.hidden = true; const params = new URLSearchParams({ periodDays: String(currentPeriod) }); if (refresh) params.set('refresh', '1'); try { const json = await getJson(`${dataUrl}?${params.toString()}`, { signal: requestController.signal, }); if (!json.ok) { showState('error'); return; } renderTiles(json.summary || {}); renderSilent(json.silent || []); renderTop(json.top_communication || []); showState('success'); if (json.meta && json.meta.truncated && elTruncated) { elTruncated.hidden = false; } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return; } showState('error'); } }; on(periodSelector, 'change', (event) => { const radio = event.target.closest('input[name="analytics-period"]'); if (!radio) return; const period = parseInt(radio.value, 10); if (!period || period === currentPeriod) return; currentPeriod = period; void fetchData(); }); on(elRefresh, 'click', () => { void fetchData(true); }); void fetchData(); return { destroy: () => { listenerController.abort(); requestController.abort(); }, }; }