feat(helpdesk): align module with core list/drawer standards
- add helpdesk module pages, services, settings and tests - standardize debtor list on drawer/grid contracts and robust filter drawer behavior - add helpdesk aside panel navigation and settings visibility provider - switch primary list slug to helpdesk/debitor and remove helpdesk/search compatibility - include required core contract updates for list contracts and detail/drawer integration
This commit is contained in:
29
modules/helpdesk/web/js/helpdesk-clickable-rows.js
Normal file
29
modules/helpdesk/web/js/helpdesk-clickable-rows.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared clickable-row behaviour for helpdesk tables.
|
||||
* Navigates to `data-href` on click or Enter/Space key.
|
||||
*/
|
||||
export function initClickableRows() {
|
||||
const rows = document.querySelectorAll('.app-clickable-row[data-href]');
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.addEventListener('click', (event) => {
|
||||
if (event.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
const href = row.dataset.href;
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
|
||||
row.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
const href = row.dataset.href;
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
571
modules/helpdesk/web/js/helpdesk-detail.js
Normal file
571
modules/helpdesk/web/js/helpdesk-detail.js
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* 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 `<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 ---
|
||||
|
||||
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(`<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] = 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();
|
||||
}
|
||||
6
modules/helpdesk/web/js/helpdesk-search.js
Normal file
6
modules/helpdesk/web/js/helpdesk-search.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Helpdesk search page — clickable row navigation.
|
||||
*/
|
||||
import { initClickableRows } from './helpdesk-clickable-rows.js';
|
||||
|
||||
initClickableRows();
|
||||
77
modules/helpdesk/web/js/helpdesk-settings.js
Normal file
77
modules/helpdesk/web/js/helpdesk-settings.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Helpdesk settings page — auth mode toggle and connection test.
|
||||
*/
|
||||
|
||||
// Toggle OAuth2 vs Basic Auth field visibility
|
||||
const authModeSelect = document.getElementById('auth_mode');
|
||||
const basicFields = document.getElementById('helpdesk-basic-auth-fields');
|
||||
const oauth2Fields = document.getElementById('helpdesk-oauth2-fields');
|
||||
|
||||
const toggleAuthFields = () => {
|
||||
if (!authModeSelect || !basicFields || !oauth2Fields) {
|
||||
return;
|
||||
}
|
||||
const isOAuth2 = authModeSelect.value === 'oauth2';
|
||||
basicFields.style.display = isOAuth2 ? 'none' : '';
|
||||
oauth2Fields.style.display = isOAuth2 ? '' : 'none';
|
||||
};
|
||||
|
||||
if (authModeSelect) {
|
||||
authModeSelect.addEventListener('change', toggleAuthFields);
|
||||
toggleAuthFields();
|
||||
}
|
||||
|
||||
// Connection test button
|
||||
const testButton = document.getElementById('helpdesk-test-connection-button');
|
||||
const testResult = document.getElementById('helpdesk-test-connection-result');
|
||||
|
||||
if (testButton && testResult) {
|
||||
testButton.addEventListener('click', async () => {
|
||||
testButton.disabled = true;
|
||||
testResult.textContent = testButton.dataset.testingLabel || 'Testing...';
|
||||
testResult.className = '';
|
||||
|
||||
try {
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const csrfInput = document.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfInput?.value || csrfMeta?.content || '';
|
||||
|
||||
const formData = new FormData();
|
||||
if (csrfToken) {
|
||||
formData.append('csrf_token', csrfToken);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
new URL('helpdesk/settings/test-connection-data', document.baseURI).href,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
testResult.textContent = data.message || 'Connection successful';
|
||||
testResult.style.color = 'var(--color-success, green)';
|
||||
} else {
|
||||
let msg = data.error || 'Connection failed';
|
||||
if (data.debug?.url) {
|
||||
msg += '\nURL: ' + data.debug.url;
|
||||
}
|
||||
if (data.debug?.http_code) {
|
||||
msg += ' (HTTP ' + data.debug.http_code + ')';
|
||||
}
|
||||
testResult.textContent = msg;
|
||||
testResult.style.color = 'var(--color-error, red)';
|
||||
testResult.style.whiteSpace = 'pre-wrap';
|
||||
}
|
||||
} catch {
|
||||
testResult.textContent = 'Connection failed';
|
||||
testResult.style.color = 'var(--color-error, red)';
|
||||
} finally {
|
||||
testButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
161
modules/helpdesk/web/js/helpdesk-ticket.js
Normal file
161
modules/helpdesk/web/js/helpdesk-ticket.js
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Helpdesk ticket detail page — async activity timeline.
|
||||
*
|
||||
* Loads the ticket activity log (PBI_FP_TicketLog) via fetch and renders
|
||||
* a chronological timeline of status changes and messages.
|
||||
*/
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-ticket-no]');
|
||||
if (container) {
|
||||
init(container);
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
const ticketNo = container.dataset.ticketNo || '';
|
||||
const logUrl = container.dataset.ticketLogUrl || '';
|
||||
|
||||
if (!ticketNo || !logUrl) return;
|
||||
|
||||
// i18n
|
||||
const i18nEl = document.getElementById('helpdesk-ticket-i18n');
|
||||
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
|
||||
const t = (key) => i18n[key] || key;
|
||||
|
||||
// Date/time formatter
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
});
|
||||
const timeFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(raw) {
|
||||
if (!raw || raw === '0001-01-01' || raw === '0001-01-01T00:00:00Z') return '';
|
||||
try {
|
||||
return dateFmt.format(new Date(raw));
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(raw) {
|
||||
if (!raw) return '';
|
||||
// BC time format: "15:21:09.76" — parse as today's time
|
||||
const parts = raw.split(':');
|
||||
if (parts.length < 2) return raw;
|
||||
return parts[0] + ':' + parts[1];
|
||||
}
|
||||
|
||||
function typeIcon(type) {
|
||||
switch (type) {
|
||||
case 'Nachricht': return 'bi-chat-dots-fill';
|
||||
case 'Status': return 'bi-arrow-repeat';
|
||||
case 'Weiterleitung': return 'bi-forward-fill';
|
||||
case 'Datei': return 'bi-paperclip';
|
||||
default: return 'bi-circle-fill';
|
||||
}
|
||||
}
|
||||
|
||||
function typeVariant(type) {
|
||||
switch (type) {
|
||||
case 'Nachricht': return 'message';
|
||||
case 'Status': return 'status';
|
||||
case 'Weiterleitung': return 'forward';
|
||||
case 'Datei': return 'file';
|
||||
default: return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(type, stateSubtype) {
|
||||
switch (type) {
|
||||
case 'Nachricht': return t('sent a message');
|
||||
case 'Status': {
|
||||
if (stateSubtype) {
|
||||
return t('changed status') + ' → ' + stateSubtype;
|
||||
}
|
||||
return t('changed status');
|
||||
}
|
||||
case 'Weiterleitung': return t('Forwarded');
|
||||
case 'Datei': return t('attached a file');
|
||||
default: return type || t('Activity');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline(entries) {
|
||||
if (entries.length === 0) {
|
||||
return `<div class="app-empty-state app-empty-state-compact">
|
||||
<p class="app-empty-state-message">${esc(t('No activity found.'))}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let html = '<div class="helpdesk-timeline">';
|
||||
let lastDate = '';
|
||||
|
||||
for (const entry of entries) {
|
||||
const date = formatDate(entry.Creation_Date);
|
||||
const time = formatTime(entry.Creation_Time);
|
||||
const type = entry.Type || '';
|
||||
const user = entry.Created_By || '';
|
||||
const variant = typeVariant(type);
|
||||
const icon = typeIcon(type);
|
||||
const label = typeLabel(type, entry.State_Subtype || '');
|
||||
|
||||
// Date separator
|
||||
if (date !== lastDate) {
|
||||
html += `<div class="helpdesk-timeline-date">${esc(date)}</div>`;
|
||||
lastDate = date;
|
||||
}
|
||||
|
||||
html += `<div class="helpdesk-timeline-entry" data-variant="${variant}">`;
|
||||
html += `<div class="helpdesk-timeline-icon"><i class="bi ${icon}" aria-hidden="true"></i></div>`;
|
||||
html += '<div class="helpdesk-timeline-body">';
|
||||
html += `<strong>${esc(user)}</strong> ${esc(label)}`;
|
||||
if (time) {
|
||||
html += `<span class="helpdesk-timeline-time">${esc(time)}</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderError(message) {
|
||||
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
|
||||
}
|
||||
|
||||
// --- Load and render ---
|
||||
|
||||
async function loadTimeline() {
|
||||
const timelineEl = document.getElementById('ticket-timeline');
|
||||
const loadingEl = document.getElementById('timeline-loading');
|
||||
if (!timelineEl) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ ticketNo });
|
||||
const response = await fetch(logUrl + '?' + params.toString(), { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
|
||||
if (loadingEl) loadingEl.hidden = true;
|
||||
|
||||
if (!data.ok) {
|
||||
timelineEl.innerHTML = renderError(t('Could not load activity log.'));
|
||||
return;
|
||||
}
|
||||
|
||||
timelineEl.innerHTML = renderTimeline(data.log || []);
|
||||
} catch {
|
||||
if (loadingEl) loadingEl.hidden = true;
|
||||
timelineEl.innerHTML = renderError(t('Could not load activity log.'));
|
||||
}
|
||||
}
|
||||
|
||||
loadTimeline();
|
||||
}
|
||||
73
modules/helpdesk/web/js/pages/helpdesk-search-index.js
Normal file
73
modules/helpdesk/web/js/pages/helpdesk-search-index.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { warnOnce } from '/js/core/app-dom.js';
|
||||
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-search');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed: Grid.js missing', { module: 'helpdesk-search', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
const labels = config.labels || {};
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-search-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/search-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.debtorNo || 'Debtor No.',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const debtorNo = escapeHtml(cell?.no || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(debtorNo);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(detailUrl));
|
||||
return gridjs.html(`<a href="${href}">${debtorNo}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.name || 'Name', sort: true },
|
||||
{ name: labels.city || 'City', sort: true },
|
||||
{ name: labels.phone || 'Phone', sort: false },
|
||||
{ name: labels.email || 'E-Mail', sort: false },
|
||||
{ name: 'detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Name', 'City', null, null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ no: row.No || '', url: row.detail_url || '' },
|
||||
row.Name || '',
|
||||
row.City || '',
|
||||
row.Phone_No || '',
|
||||
row.E_Mail || '',
|
||||
row.detail_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[5]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#helpdesk-search-input'],
|
||||
},
|
||||
});
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed', { module: 'helpdesk-search', component: 'grid' });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user