219 lines
7.4 KiB
JavaScript
219 lines
7.4 KiB
JavaScript
|
|
/**
|
||
|
|
* 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();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|