Files
breadcrumb-the-shire/modules/helpdesk/web/js/pages/helpdesk-updates-index.js
fs da0abc824a feat(helpdesk): add software updates tracking with BC ticket integration
New Updates page in the Helpdesk module that fetches UPDATE/UPDATE-HF tickets
from Business Central (PBI_LV_Tickets) and allows assigning a domain and Gitea
link via a dialog. Ticket status (from BC) and assignment status (local) are
shown as separate columns with filters for both plus type and free-text search.
Assigned updates also appear on the domain detail page. Includes session-cached
BC fetch with refresh button, admin permissions, migration, and 16 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:21:40 +02:00

326 lines
14 KiB
JavaScript

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 } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-updates');
if (config) {
const gridjs = window.gridjs;
if (!gridjs) {
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed: Grid.js missing', { module: 'helpdesk-updates', component: 'grid' });
} else {
const appBase = getAppBase();
const labels = config.labels || {};
const canManage = config.canManage || false;
const gridOptions = {
gridjs,
container: '#helpdesk-updates-grid',
dataUrl: config.dataUrl || 'helpdesk/updates-data',
appBase,
columns: [
{
name: labels.ticketNo || 'Ticket',
sort: false,
formatter: (cell) => {
const no = escapeHtml(String(cell?.no || ''));
const url = String(cell?.url || '').trim();
const desc = escapeHtml(String(cell?.description || ''));
const tip = desc ? ` data-tooltip="${desc}" data-tooltip-pos="top"` : '';
if (no === '') return gridjs.html('<span class="text-muted">—</span>');
if (url === '') return gridjs.html(`<span${tip}>${no}</span>`);
return gridjs.html(`<a href="${escapeHtml(url)}"${tip}>${no}</a>`);
},
},
{
name: labels.ticketState || 'Ticket status',
sort: false,
formatter: (cell) => {
const label = escapeHtml(cell?.label || '');
const variant = cell?.variant || 'neutral';
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
},
},
{
name: labels.assignment || 'Assignment',
sort: false,
formatter: (cell) => {
const label = escapeHtml(cell?.label || '');
const variant = cell?.variant || 'neutral';
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
},
},
{
name: labels.actions || '',
sort: false,
formatter: (cell) => {
if (!cell) return '';
let buttons = '';
// Assign/Edit button (only if user can manage)
if (canManage) {
// raw-html-ok: all values passed through escapeHtml, cell data is from our own API
const data = escapeHtml(JSON.stringify(cell));
const buttonLabel = cell.status === 'open'
? escapeHtml(labels.assign || 'Assign')
: escapeHtml(labels.edit || 'Edit');
const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil';
buttons += `<button type="button" class="secondary outline small" data-update-assign='${data}' data-tooltip="${buttonLabel}" data-tooltip-pos="top" aria-label="${buttonLabel}"><i class="bi ${icon}"></i></button>`;
}
// Gitea link button (if URL exists)
const giteaUrl = String(cell.gitea_path || '').trim();
if (giteaUrl !== '') {
const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea');
buttons += `<a href="${escapeHtml(giteaUrl)}" target="_blank" rel="noopener" role="button" class="secondary outline small" data-tooltip="${giteaLabel}" data-tooltip-pos="top" aria-label="${giteaLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`;
}
if (buttons === '') return '';
return gridjs.html(`<span class="app-inline-actions">${buttons}</span>`);
},
},
{
name: labels.category || 'Type',
sort: false,
formatter: (cell) => {
const label = escapeHtml(cell?.label || '');
const variant = cell?.variant || 'neutral';
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
},
},
{
name: labels.debitor || 'Customer',
sort: false,
formatter: (cell) => {
const name = escapeHtml(String(cell?.name || ''));
const url = String(cell?.url || '').trim();
if (name === '') return gridjs.html('<span class="text-muted">—</span>');
const style = 'display:inline-block;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom';
if (url === '') return gridjs.html(`<span style="${style}" title="${name}">${name}</span>`);
return gridjs.html(`<a href="${escapeHtml(url)}" style="${style}" title="${name}">${name}</a>`);
},
},
{ name: labels.domain || 'Domain', sort: false },
{ name: labels.createdOn || 'Created', sort: false },
],
paginationLimit: 50,
language: config.gridLang || {},
mapData: (data) => {
const rows = data.rows || data.data || [];
// Column order: Ticket | Ticket-Status | Assignment | Actions | Type | Customer | Domain | Created
return rows.map((row) => [
{ no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' },
{ label: row.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' },
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
{
ticket_no: row.ticket_no || '',
debitor_no: row.debitor_no || '',
debitor_name: row.debitor_name || '',
category_code: row.category_code || '',
domain_no: row.domain_no || '',
domain_url: row.domain_url || '',
gitea_path: row.gitea_path || '',
notes: row.notes || '',
status: row.status || 'open',
},
{ label: row.category_label || '', variant: row.category_variant || 'neutral' },
{ name: row.debitor_name || '', url: row.debitor_url || '' },
row.domain_url || '',
row.created_on || '',
]);
},
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-updates-search-input'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed', { module: 'helpdesk-updates', component: 'grid' });
}
// Refresh button — bypasses session cache and reloads grid data from BC
const refreshButton = document.getElementById('updates-refresh-button');
if (refreshButton) {
refreshButton.addEventListener('click', async () => {
refreshButton.disabled = true;
refreshButton.setAttribute('aria-busy', 'true');
try {
// Ping the data endpoint with refresh=1 to invalidate the cache
const refreshUrl = new URL(config.dataUrl, appBase);
refreshUrl.searchParams.set('refresh', '1');
await fetch(refreshUrl.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
});
// Now force the grid to re-render (next fetch will get fresh data)
gridConfig?.grid?.forceRender();
} catch {
// Ignore — grid will still show last data
} finally {
refreshButton.disabled = false;
refreshButton.removeAttribute('aria-busy');
}
});
}
// Assignment dialog logic
if (canManage) {
const dialog = document.getElementById('update-assign-dialog');
const form = document.getElementById('update-assign-form');
const contextEl = document.getElementById('update-assign-context');
const domainSelect = document.getElementById('update-assign-domain');
const giteaInput = document.getElementById('update-assign-gitea');
const notesInput = document.getElementById('update-assign-notes');
const ticketNoInput = document.getElementById('update-assign-ticket-no');
const debitorNoInput = document.getElementById('update-assign-debitor-no');
const debitorNameInput = document.getElementById('update-assign-debitor-name');
const categoryCodeInput = document.getElementById('update-assign-category-code');
const domainUrlInput = document.getElementById('update-assign-domain-url');
const submitButton = document.getElementById('update-assign-submit');
if (dialog && form) {
const closeDialog = () => {
try { dialog.close(); } catch { /* no-op */ }
document.body.classList.remove('modal-is-open');
};
// Close buttons
dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => {
button.addEventListener('click', (e) => { e.preventDefault(); closeDialog(); });
});
dialog.addEventListener('cancel', (e) => { e.preventDefault(); closeDialog(); });
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); });
// Domain select change -> update hidden domain_url
domainSelect.addEventListener('change', () => {
const selected = domainSelect.options[domainSelect.selectedIndex];
domainUrlInput.value = selected?.dataset?.url || '';
});
/**
* Populate the domain <select> with options loaded from the API.
* Uses textContent and DOM methods instead of innerHTML to avoid XSS.
*/
const populateDomainSelect = (domains, preselectedNo) => {
domainSelect.textContent = '';
if (!Array.isArray(domains) || domains.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = labels.noDomains || 'No domains found';
domainSelect.appendChild(opt);
} else {
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = labels.selectDomain || 'Select domain...';
domainSelect.appendChild(placeholder);
for (const d of domains) {
const opt = document.createElement('option');
opt.value = d.value || '';
opt.textContent = d.label || d.value || '';
opt.dataset.url = d.url || '';
if (preselectedNo && d.value === preselectedNo) {
opt.selected = true;
domainUrlInput.value = d.url || '';
}
domainSelect.appendChild(opt);
}
}
domainSelect.disabled = false;
};
// Open dialog on assign button click (delegated)
document.addEventListener('click', async (e) => {
const button = e.target.closest('[data-update-assign]');
if (!button) return;
let data;
try { data = JSON.parse(button.dataset.updateAssign); } catch { return; }
// Populate form
ticketNoInput.value = data.ticket_no || '';
debitorNoInput.value = data.debitor_no || '';
debitorNameInput.value = data.debitor_name || '';
categoryCodeInput.value = data.category_code || '';
giteaInput.value = data.gitea_path || '';
notesInput.value = data.notes || '';
domainUrlInput.value = data.domain_url || '';
const categoryDisplay = data.category_code === 'UPDATE-HF'
? (labels.hotfix || 'Hotfix')
: (labels.update || 'Update');
contextEl.textContent = `${data.ticket_no} \u00b7 ${data.debitor_name} \u00b7 ${categoryDisplay}`;
// Show loading state in domain select
domainSelect.textContent = '';
const loadingOpt = document.createElement('option');
loadingOpt.value = '';
loadingOpt.textContent = labels.loadingDomains || 'Loading...';
domainSelect.appendChild(loadingOpt);
domainSelect.disabled = true;
document.body.classList.add('modal-is-open');
dialog.showModal();
try {
const domainsUrl = new URL(config.domainsDataUrl, appBase);
domainsUrl.searchParams.set('debitor_no', data.debitor_no);
const res = await fetch(domainsUrl.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
});
const domains = await res.json();
populateDomainSelect(domains, data.domain_no);
} catch {
populateDomainSelect([], null);
}
});
// Form submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
const formData = new URLSearchParams(new FormData(form));
if (config.csrfKey) {
formData.set(config.csrfKey, config.csrfToken || '');
}
try {
const res = await fetch(new URL(config.assignUrl, appBase).toString(), {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'X-Requested-With': 'fetch',
},
body: formData,
});
const result = await res.json().catch(() => null);
if (res.ok && result?.ok) {
closeDialog();
gridConfig?.grid?.forceRender();
} else {
const errorMsg = result?.errors?.general || labels.assignError || 'Failed to assign';
window.alert(errorMsg);
}
} catch {
window.alert(labels.assignError || 'Failed to assign');
} finally {
submitButton.disabled = false;
submitButton.removeAttribute('aria-busy');
}
});
}
}
}
}