/** * 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 { initClickableRows } from './helpdesk-clickable-rows.js'; import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { readPageConfig } from '/js/core/app-page-config.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 || ''; 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 overviewTicketsPromise = null; let ticketCategoriesPromise = 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 all tickets for overview tab (uses the grid data endpoint with high limit). * Returns { data: [...], total: N } format from gridJsonDataResult. */ function fetchOverviewTickets() { if (!overviewTicketsPromise) { const params = new URLSearchParams({ customerNo, customerName, limit: '200', 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 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 `

${esc(message)}

${hint ? `

${esc(hint)}

` : ''}
`; } function errorNotice(message) { return ``; } // --- Ticket table renderer (compact, for overview) --- function renderTicketTableCompact(tickets) { if (tickets.length === 0) return emptyState(t('No open tickets'), ''); let html = ''; html += ``; html += ``; html += ``; html += ``; html += ''; for (const tk of tickets) { const no = tk.No || ''; const url = ticketUrl(no); const state = tk.Ticket_State || ''; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
${esc(t('Ticket No.'))}${esc(t('Description'))}${esc(t('Status'))}${esc(t('Created'))}
${esc(no)}${esc(tk.Description || '')}${esc(state)}${esc(formatDate(tk.Created_On))}
'; 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 = ''; html += ``; html += ``; html += ``; html += ``; html += ''; for (const tk of sorted) { const no = tk.No || ''; const url = ticketUrl(no); const state = tk.Ticket_State || ''; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
${esc(t('Ticket No.'))}${esc(t('Description'))}${esc(t('Status'))}${esc(t('Last activity'))}
${esc(no)}${esc(tk.Description || '')}${esc(state)}${esc(formatDate(tk.Last_Activity_Date))}
'; 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 = ''; html += ``; html += ``; html += ``; html += ``; html += ''; for (const c of top5) { html += ''; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
${esc(t('Name'))}${esc(t('Job title'))}${esc(t('E-Mail'))}${esc(t('Phone'))}
${esc(c.Name || '')}${esc(c.Job_Title || '')}${esc(c.E_Mail || '')}${esc(c.Phone_No || '')}
'; 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 = '
'; html += `${esc(t('Contacts'))} (${contacts.length})
`; html += ''; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; for (const c of contacts) { html += ''; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; html += ''; } html += '
${esc(t('No.'))}${esc(t('Name'))}${esc(t('Type'))}${esc(t('Job title'))}${esc(t('E-Mail'))}${esc(t('Phone'))}${esc(t('Mobile'))}
${esc(c.No || '')}${esc(c.Name || '')}${esc(c.Type || '')}${esc(c.Job_Title || '')}${esc(c.E_Mail || '')}${esc(c.Phone_No || '')}${esc(c.Mobile_Phone_No || '')}
'; 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 --- 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; } // --- Tab switch links --- container.addEventListener('click', (e) => { const link = e.target.closest('[data-switch-tab]'); if (!link) return; e.preventDefault(); const tabName = link.dataset.switchTab; const tabButton = container.querySelector(`[data-tab="${tabName}"]`); if (tabButton) tabButton.click(); }); // --- 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(`${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; } } // --- Render overview tab --- async function renderOverview() { showLoading('overview-loading'); const [ticketsResult, contactsResult] = await Promise.all([ fetchOverviewTickets(), fetchContacts(), ]); 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 if (Array.isArray(allTickets)) { const openCount = allTickets.filter(tk => isOpen(tk.Ticket_State || '')).length; updateKpi('open-tickets', String(openCount)); // Last activity date — find max Last_Activity_Date 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) : '—'); // Update tickets tab badge const totalTickets = ticketsResult.total ?? allTickets.length; updateBadge('tab-badge-tickets', totalTickets); } if (contactsResult.ok) { const contacts = contactsResult.contacts || []; updateKpi('contacts', String(contacts.length)); updateBadge('tab-badge-contacts', contacts.length); contactsRendered.overview = true; } // Init clickable rows for newly rendered content initClickableRows(); } // --- 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; initClickableRows(); } // --- 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(); }