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

663 lines
22 KiB
JavaScript

/**
* Helpdesk debtor detail page — async data loading + dashboard rendering.
*
* - Overview tab: open tickets, recent activity, top contacts (manual HTML)
* - Tickets tab: Grid.js server-side grid with search, status filter, pagination
* - Contacts tab: full contact table (manual HTML)
* - KPI tile updates from overview data
*/
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';
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 contactsUrl = container.dataset.contactsUrl || '';
const ticketsUrl = container.dataset.ticketsUrl || '';
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
const summaryUrl = container.dataset.summaryUrl || '';
const communicationUrl = container.dataset.communicationUrl || '';
if (!customerNo || !customerName) return;
// i18n
const i18nEl = document.getElementById('helpdesk-i18n');
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
const t = (key) => i18n[key] || key;
// State — promise caching ensures only 1 request per resource
let contactsPromise = null;
let summaryPromise = null;
let overviewTicketsPromise = null;
let ticketCategoriesPromise = null;
let debitorCommunicationPromise = null;
let contactsRendered = { overview: false, full: false };
let ticketsGridInitialized = false;
let ticketsGridInitializing = false;
// Date formatter
const dateFmt = new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
// --- Fetch helpers ---
function fetchContacts() {
if (!contactsPromise) {
const params = new URLSearchParams({ customerNo, customerName });
contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
return contactsPromise;
}
/**
* Fetch exact ticket KPI summary from dedicated endpoint.
* Returns { ok, total, open, last_activity }.
*/
function fetchSummary() {
if (!summaryPromise) {
const params = new URLSearchParams({ customerNo, customerName });
summaryPromise = fetch(summaryUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false }));
}
return summaryPromise;
}
/**
* Fetch tickets for overview display (limited to recent entries for rendering).
* Returns { data: [...], total: N } format from gridJsonDataResult.
*/
function fetchOverviewTickets() {
if (!overviewTicketsPromise) {
const params = new URLSearchParams({
customerNo,
customerName,
limit: '20',
offset: '0',
order: 'Created_On',
dir: 'desc',
});
overviewTicketsPromise = fetch(ticketsUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ data: [], total: 0 }));
}
return overviewTicketsPromise;
}
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);
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 = new URLSearchParams({ customerNo, customerName });
debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return debitorCommunicationPromise;
}
function hydrateTicketCategoryFilter(config, categories) {
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
if (!(categoryFilter instanceof HTMLSelectElement)) return;
const uniqueCategories = Array.from(new Set(
(Array.isArray(categories) ? categories : [])
.map(item => String(item || '').trim())
.filter(Boolean),
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
const currentValue = String(categoryFilter.value || '');
categoryFilter.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = t('All');
categoryFilter.appendChild(allOption);
const categoryOptionMap = {};
for (const category of uniqueCategories) {
const option = document.createElement('option');
option.value = category;
option.textContent = category;
categoryFilter.appendChild(option);
categoryOptionMap[category] = category;
}
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) {
categoryFilter.value = currentValue;
} else {
categoryFilter.value = '';
}
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
config.filterChipMeta = {};
}
const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object')
? config.filterChipMeta.category
: {};
config.filterChipMeta.category = {
...currentCategoryMeta,
type: 'select',
default: '',
options: categoryOptionMap,
};
}
// --- Rendering helpers ---
function esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function formatDate(raw) {
if (!raw || raw === '0001-01-01T00:00:00Z') return '';
try {
return dateFmt.format(new Date(raw));
} catch {
return '';
}
}
function stateVariant(state) {
switch (state) {
case 'Offen': case 'Open': return 'primary';
case 'In Bearbeitung': case 'In Progress': return 'warning';
case 'Erledigt': case 'Resolved': case 'Closed': case 'Geschlossen': return 'success';
default: return 'neutral';
}
}
function isOpen(state) {
return !['Erledigt', 'Resolved', 'Closed', 'Geschlossen'].includes(state);
}
function ticketUrl(ticketNo) {
return ticketBaseUrl + encodeURIComponent(ticketNo) + '?from=' + encodeURIComponent(customerNo);
}
function emptyState(message, hint) {
return `<div class="app-empty-state app-empty-state-compact">
<p class="app-empty-state-message">${esc(message)}</p>
${hint ? `<p class="app-empty-state-hint">${esc(hint)}</p>` : ''}
</div>`;
}
function errorNotice(message) {
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
}
// --- Ticket table renderer (compact, for overview) ---
function renderTicketTableCompact(tickets) {
if (tickets.length === 0) return emptyState(t('No open tickets'), '');
let html = '<table><thead><tr>';
html += `<th>${esc(t('Ticket No.'))}</th>`;
html += `<th>${esc(t('Description'))}</th>`;
html += `<th>${esc(t('Status'))}</th>`;
html += `<th>${esc(t('Created'))}</th>`;
html += '</tr></thead><tbody>';
for (const tk of tickets) {
const no = tk.No || '';
const url = ticketUrl(no);
const state = tk.Ticket_State || '';
html += `<tr class="app-clickable-row" data-href="${esc(url)}" tabindex="0">`;
html += `<td><a href="${esc(url)}">${esc(no)}</a></td>`;
html += `<td>${esc(tk.Description || '')}</td>`;
html += `<td><span class="badge" data-variant="${stateVariant(state)}">${esc(state)}</span></td>`;
html += `<td>${esc(formatDate(tk.Created_On))}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
// --- Recent activity renderer ---
function renderRecentActivity(tickets) {
const sorted = [...tickets].sort((a, b) => {
const da = a.Last_Activity_Date || '';
const db = b.Last_Activity_Date || '';
return db.localeCompare(da);
}).slice(0, 5);
if (sorted.length === 0) return emptyState(t('No recent activity found.'), '');
let html = '<table><thead><tr>';
html += `<th>${esc(t('Ticket No.'))}</th>`;
html += `<th>${esc(t('Description'))}</th>`;
html += `<th>${esc(t('Status'))}</th>`;
html += `<th>${esc(t('Last activity'))}</th>`;
html += '</tr></thead><tbody>';
for (const tk of sorted) {
const no = tk.No || '';
const url = ticketUrl(no);
const state = tk.Ticket_State || '';
html += `<tr class="app-clickable-row" data-href="${esc(url)}" tabindex="0">`;
html += `<td><a href="${esc(url)}">${esc(no)}</a></td>`;
html += `<td>${esc(tk.Description || '')}</td>`;
html += `<td><span class="badge" data-variant="${stateVariant(state)}">${esc(state)}</span></td>`;
html += `<td>${esc(formatDate(tk.Last_Activity_Date))}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
// --- Contact table renderers ---
function renderContactTableCompact(contacts) {
if (contacts.length === 0) {
return emptyState(t('No contacts found'), '');
}
const top5 = contacts.slice(0, 5);
let html = '<table><thead><tr>';
html += `<th>${esc(t('Name'))}</th>`;
html += `<th>${esc(t('Job title'))}</th>`;
html += `<th>${esc(t('E-Mail'))}</th>`;
html += `<th>${esc(t('Phone'))}</th>`;
html += '</tr></thead><tbody>';
for (const c of top5) {
html += '<tr>';
html += `<td>${esc(c.Name || '')}</td>`;
html += `<td>${esc(c.Job_Title || '')}</td>`;
html += `<td>${esc(c.E_Mail || '')}</td>`;
html += `<td>${esc(c.Phone_No || '')}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function renderContactTableFull(contacts) {
if (contacts.length === 0) {
return emptyState(t('No contacts found'), t('No contacts are linked to this debtor in Business Central.'));
}
let html = '<div class="app-stats-table"><div class="app-stats-table-header">';
html += `${esc(t('Contacts'))} (${contacts.length})</div>`;
html += '<table role="grid"><thead><tr>';
html += `<th>${esc(t('No.'))}</th>`;
html += `<th>${esc(t('Name'))}</th>`;
html += `<th>${esc(t('Type'))}</th>`;
html += `<th>${esc(t('Job title'))}</th>`;
html += `<th>${esc(t('E-Mail'))}</th>`;
html += `<th>${esc(t('Phone'))}</th>`;
html += `<th>${esc(t('Mobile'))}</th>`;
html += '</tr></thead><tbody>';
for (const c of contacts) {
html += '<tr>';
html += `<td>${esc(c.No || '')}</td>`;
html += `<td>${esc(c.Name || '')}</td>`;
html += `<td>${esc(c.Type || '')}</td>`;
html += `<td>${esc(c.Job_Title || '')}</td>`;
html += `<td>${esc(c.E_Mail || '')}</td>`;
html += `<td>${esc(c.Phone_No || '')}</td>`;
html += `<td>${esc(c.Mobile_Phone_No || '')}</td>`;
html += '</tr>';
}
html += '</tbody></table></div>';
return html;
}
// --- KPI tile updates ---
function updateKpi(kpiKey, value) {
const wrapper = container.querySelector(`[data-kpi="${kpiKey}"]`);
if (!wrapper) return;
const countEl = wrapper.querySelector('.app-tile-count');
if (countEl) {
countEl.textContent = value;
}
}
// --- Tab badge updates ---
function updateBadge(id, count) {
const badge = document.getElementById(id);
if (badge) badge.textContent = String(count);
}
// --- Show/hide loading states (aria-busy pattern) ---
function showLoading(id) {
const el = document.getElementById(id);
if (el) {
el.setAttribute('aria-busy', 'true');
el.hidden = false;
}
}
function hideLoading(id) {
const el = document.getElementById(id);
if (el) {
el.removeAttribute('aria-busy');
el.hidden = true;
}
}
function showContent(id) {
const el = document.getElementById(id);
if (el) el.hidden = false;
}
// --- Delegated click handling for clickable rows + tab switch links ---
container.addEventListener('click', (e) => {
// Tab switch links
const link = e.target.closest('[data-switch-tab]');
if (link) {
e.preventDefault();
const tabName = link.dataset.switchTab;
const tabButton = container.querySelector(`[data-tab="${tabName}"]`);
if (tabButton) tabButton.click();
return;
}
// Clickable rows (delegated — no re-init needed after render)
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;
}
});
// --- Grid.js tickets tab ---
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);
try {
const categoryResponse = await fetchTicketCategories(config, appBase);
hydrateTicketCategoryFilter(config, categoryResponse?.categories || []);
const gridOptions = {
gridjs,
container: '#helpdesk-tickets-grid',
dataUrl: gridDataUrl.toString(),
appBase,
columns: [
{
name: labels.ticketNo || 'Ticket No.',
sort: true,
formatter: (cell) => gridjs.html(`<a href="${cell.url}">${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: 0 },
};
initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-ticket-search'],
},
});
ticketsGridInitialized = true;
} finally {
ticketsGridInitializing = false;
}
}
// --- Render overview tab ---
async function renderOverview() {
showLoading('overview-loading');
const [ticketsResult, contactsResult, summaryResult] = await Promise.all([
fetchOverviewTickets(),
fetchContacts(),
fetchSummary(),
]);
hideLoading('overview-loading');
showContent('overview-content');
// Extract tickets from grid data format { data: [...], total: N }
const allTickets = ticketsResult.data || [];
// Open tickets section
const openTicketsEl = document.getElementById('overview-open-tickets');
if (openTicketsEl) {
if (!Array.isArray(allTickets) || allTickets.length === 0) {
openTicketsEl.innerHTML = emptyState(t('No open tickets'), '');
} else {
const openTickets = allTickets.filter(tk => isOpen(tk.Ticket_State || ''));
openTicketsEl.innerHTML = renderTicketTableCompact(openTickets.slice(0, 10));
}
}
// Recent activity section
const recentEl = document.getElementById('overview-recent-activity');
if (recentEl) {
if (!Array.isArray(allTickets) || allTickets.length === 0) {
recentEl.innerHTML = emptyState(t('No recent activity found.'), '');
} else {
recentEl.innerHTML = renderRecentActivity(allTickets);
}
}
// Top contacts section
const contactsEl = document.getElementById('overview-contacts');
if (contactsEl) {
if (!contactsResult.ok) {
contactsEl.innerHTML = errorNotice(t('Could not load contacts.'));
} else {
contactsEl.innerHTML = renderContactTableCompact(contactsResult.contacts || []);
}
}
// Update KPI tiles from exact server-side summary
if (summaryResult.ok) {
updateKpi('open-tickets', String(summaryResult.open ?? 0));
updateKpi('last-activity', summaryResult.last_activity ? formatDate(summaryResult.last_activity) : '—');
updateBadge('tab-badge-tickets', summaryResult.total ?? 0);
} else if (Array.isArray(allTickets)) {
// Fallback: calculate from fetched overview tickets when summary endpoint unavailable
const openCount = allTickets.filter(tk => isOpen(tk.Ticket_State || '')).length;
updateKpi('open-tickets', String(openCount));
let maxDate = '';
for (const tk of allTickets) {
const d = tk.Last_Activity_Date || '';
if (d > maxDate && d !== '0001-01-01T00:00:00Z') maxDate = d;
}
updateKpi('last-activity', maxDate ? formatDate(maxDate) : '—');
updateBadge('tab-badge-tickets', ticketsResult.total ?? allTickets.length);
}
if (contactsResult.ok) {
const contacts = contactsResult.contacts || [];
updateKpi('contacts', String(contacts.length));
updateBadge('tab-badge-contacts', contacts.length);
contactsRendered.overview = true;
}
}
// --- Render full contacts tab ---
async function renderContactsTab() {
if (contactsRendered.full) return;
showLoading('contacts-loading');
const result = await fetchContacts();
hideLoading('contacts-loading');
const contentEl = document.getElementById('contacts-content');
if (!contentEl) return;
if (!result.ok) {
contentEl.innerHTML = errorNotice(t('Could not load contacts.'));
} else {
contentEl.innerHTML = renderContactTableFull(result.contacts || []);
updateBadge('tab-badge-contacts', (result.contacts || []).length);
}
showContent('contacts-content');
contactsRendered.full = true;
}
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,
});
feedEl.innerHTML = html;
}
// --- Tab change observer ---
// Watch for tab panel visibility changes to trigger lazy rendering
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 === 'tickets' && !ticketsGridInitialized) {
initTicketsGrid();
} else if (tabName === 'contacts' && !contactsRendered.full) {
renderContactsTab();
}
}
});
for (const panel of tabPanels) {
observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] });
}
// --- Initial load ---
// Start loading immediately — overview is the default tab
renderOverview();
renderDebitorCommunicationAside();
}