Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-analytics.js

219 lines
7.4 KiB
JavaScript
Raw Normal View History

/**
* 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 <div> — 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();
},
};
}