feat(helpdesk): add domain linking, bulk actions, and revision polish to handovers
Adds domain selection (cascaded from debitor) to handover creation and edit, bulk delete with confirmation dialog, and various UI improvements to the handover wizard and list page. Includes migration for domain columns, domain-select AJAX endpoint, and updated tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
124
modules/helpdesk/web/js/handover-domain-select.js
Normal file
124
modules/helpdesk/web/js/handover-domain-select.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Dynamic domain select for handover wizard step 1.
|
||||
*
|
||||
* Reads config from data-* attributes on #wizard-domain-group:
|
||||
* data-domains-url, data-text-initial, data-text-loading,
|
||||
* data-text-select, data-text-empty, data-text-error,
|
||||
* data-initial-debitor, data-initial-domain
|
||||
*/
|
||||
|
||||
const group = document.getElementById('wizard-domain-group');
|
||||
const lookupContainer = document.querySelector('[data-app-lookup]');
|
||||
const domainSelect = document.getElementById('wizard-domain');
|
||||
const domainUrlInput = document.getElementById('wizard-domain-url');
|
||||
const feedback = document.getElementById('wizard-domain-feedback');
|
||||
|
||||
if (group && lookupContainer && domainSelect) {
|
||||
const domainsUrl = group.dataset.domainsUrl || '';
|
||||
const textInitial = group.dataset.textInitial || '';
|
||||
const textLoading = group.dataset.textLoading || '';
|
||||
const textSelect = group.dataset.textSelect || '';
|
||||
const textEmpty = group.dataset.textEmpty || '';
|
||||
const textError = group.dataset.textError || '';
|
||||
|
||||
function clearOptions() {
|
||||
while (domainSelect.options.length > 0) {
|
||||
domainSelect.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
function addPlaceholder(text) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = text;
|
||||
domainSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
function setFeedback(text, variant) {
|
||||
if (!feedback) return;
|
||||
feedback.textContent = text;
|
||||
feedback.hidden = !text;
|
||||
feedback.dataset.variant = variant || '';
|
||||
}
|
||||
|
||||
function resetDomain() {
|
||||
clearOptions();
|
||||
addPlaceholder(textInitial);
|
||||
domainSelect.disabled = true;
|
||||
if (domainUrlInput) domainUrlInput.value = '';
|
||||
setFeedback('', '');
|
||||
}
|
||||
|
||||
async function loadDomains(debitorNo) {
|
||||
clearOptions();
|
||||
addPlaceholder(textLoading);
|
||||
domainSelect.disabled = true;
|
||||
group.setAttribute('aria-busy', 'true');
|
||||
setFeedback('', '');
|
||||
|
||||
try {
|
||||
const res = await fetch(domainsUrl + '?debitor_no=' + encodeURIComponent(debitorNo), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
const data = await res.json();
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
|
||||
clearOptions();
|
||||
addPlaceholder(textSelect);
|
||||
|
||||
if (items.length === 0) {
|
||||
domainSelect.disabled = true;
|
||||
setFeedback(textEmpty, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(function (item) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.value || '';
|
||||
opt.textContent = item.label || item.value || '';
|
||||
opt.dataset.url = item.url || '';
|
||||
domainSelect.appendChild(opt);
|
||||
});
|
||||
domainSelect.disabled = false;
|
||||
} catch {
|
||||
clearOptions();
|
||||
addPlaceholder(textSelect);
|
||||
domainSelect.disabled = true;
|
||||
setFeedback(textError, 'error');
|
||||
} finally {
|
||||
group.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
domainSelect.addEventListener('change', function () {
|
||||
const selected = domainSelect.options[domainSelect.selectedIndex];
|
||||
if (domainUrlInput) {
|
||||
domainUrlInput.value = selected?.dataset.url || '';
|
||||
}
|
||||
});
|
||||
|
||||
lookupContainer.addEventListener('lookup:select', function (e) {
|
||||
const debitorNo = e.detail?.value || '';
|
||||
if (debitorNo) {
|
||||
loadDomains(debitorNo);
|
||||
} else {
|
||||
resetDomain();
|
||||
}
|
||||
});
|
||||
|
||||
lookupContainer.addEventListener('lookup:clear', function () {
|
||||
resetDomain();
|
||||
});
|
||||
|
||||
// Restore selection if returning to step 1 with session data
|
||||
const initialDebitor = group.dataset.initialDebitor || '';
|
||||
const initialDomain = group.dataset.initialDomain || '';
|
||||
if (initialDebitor) {
|
||||
loadDomains(initialDebitor).then(function () {
|
||||
if (initialDomain && !domainSelect.disabled) {
|
||||
domainSelect.value = initialDomain;
|
||||
domainSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,17 @@ function init(root) {
|
||||
(t.move_down || 'Move down') + ' ' + (t.field_label || 'field') + ' ' + (index + 1),
|
||||
));
|
||||
}
|
||||
actions.appendChild(createIconButton(
|
||||
'bi-copy', 'secondary outline small',
|
||||
() => {
|
||||
const clone = { ...field, label: field.label, key: field.key || '' };
|
||||
if (field.options) { clone.options = field.options.map((o) => ({ ...o })); }
|
||||
fields.splice(index + 1, 0, clone);
|
||||
render();
|
||||
notifyChange();
|
||||
},
|
||||
(t.duplicate_field || 'Duplicate field') + ' ' + (index + 1),
|
||||
));
|
||||
actions.appendChild(createIconButton(
|
||||
'bi-trash', 'danger outline small',
|
||||
() => { fields.splice(index, 1); render(); notifyChange(); },
|
||||
|
||||
@@ -12,7 +12,8 @@ if (config) {
|
||||
const appBase = getAppBase();
|
||||
const labels = config.labels || {};
|
||||
|
||||
const debitorUrlIndex = 6;
|
||||
const domainBaseUrl = config.domainBaseUrl || '';
|
||||
const domainUrlIndex = 7;
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
@@ -20,7 +21,19 @@ if (config) {
|
||||
dataUrl: config.dataUrl || 'helpdesk/domains-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{ name: labels.no || 'No.', sort: true },
|
||||
{
|
||||
name: labels.no || 'No.',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const no = escapeHtml(cell?.no || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(no);
|
||||
}
|
||||
const href = escapeHtml(detailUrl);
|
||||
return gridjs.html(`<a href="${href}">${no}</a>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.customerName || 'Customer Name',
|
||||
sort: true,
|
||||
@@ -47,25 +60,32 @@ if (config) {
|
||||
},
|
||||
{ name: labels.administration || 'Administration', sort: true },
|
||||
{ name: 'debitor_url', hidden: true },
|
||||
{ name: 'domain_detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null],
|
||||
sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
row.No || '',
|
||||
{ name: row.Customer_Name || '', url: row.debitor_url || '' },
|
||||
row.URL || '',
|
||||
row.contract_type || '',
|
||||
{ state: row.State || '', variant: row.state_variant || 'neutral' },
|
||||
row.Administration || '',
|
||||
row.debitor_url || '',
|
||||
]),
|
||||
mapData: (data) => (data.data || []).map((row) => {
|
||||
const domainDetailUrl = domainBaseUrl && row.No
|
||||
? domainBaseUrl + encodeURIComponent(row.No)
|
||||
: '';
|
||||
return [
|
||||
{ no: row.No || '', url: domainDetailUrl },
|
||||
{ name: row.Customer_Name || '', url: row.debitor_url || '' },
|
||||
row.URL || '',
|
||||
row.contract_type || '',
|
||||
{ state: row.State || '', variant: row.state_variant || 'neutral' },
|
||||
row.Administration || '',
|
||||
row.debitor_url || '',
|
||||
domainDetailUrl,
|
||||
];
|
||||
}),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 1 },
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[debitorUrlIndex]?.data || '',
|
||||
getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ 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';
|
||||
import { bindBulkVisibility } from '/js/components/app-bulk-selection.js';
|
||||
import { confirmDialog } from '/js/core/app-confirm-dialog.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-handovers');
|
||||
if (config) {
|
||||
@@ -11,8 +13,12 @@ if (config) {
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
const labels = config.labels || {};
|
||||
const canManage = config.canManage || false;
|
||||
|
||||
const editUrlIndex = 6;
|
||||
// Column layout: Domain | Status | Customer | Product | Created | Created by | (edit_url hidden)
|
||||
// With selection: checkbox is prepended by grid factory, shifting indices +1
|
||||
const editUrlIndex = canManage ? 7 : 6;
|
||||
const domainColumnIndex = canManage ? 1 : 0;
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
@@ -21,21 +27,21 @@ if (config) {
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.id || 'ID',
|
||||
name: labels.domain || 'Domain',
|
||||
sort: true,
|
||||
width: '80px',
|
||||
formatter: (cell) => {
|
||||
const id = escapeHtml(String(cell?.id || ''));
|
||||
const domain = escapeHtml(String(cell?.domain || ''));
|
||||
const editUrl = String(cell?.url || '').trim();
|
||||
if (domain === '') {
|
||||
return gridjs.html('<span class="text-muted">—</span>');
|
||||
}
|
||||
if (editUrl === '') {
|
||||
return gridjs.html(id);
|
||||
return gridjs.html(domain);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(editUrl));
|
||||
return gridjs.html(`<a href="${href}">#${id}</a>`);
|
||||
return gridjs.html(`<a href="${href}">${domain}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.debitorName || 'Customer', sort: true },
|
||||
{ name: labels.productCode || 'Software product', sort: true },
|
||||
{
|
||||
name: labels.status || 'Status',
|
||||
sort: true,
|
||||
@@ -45,18 +51,20 @@ if (config) {
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.debitorName || 'Customer', sort: true },
|
||||
{ name: labels.productDisplay || 'Software product', sort: true },
|
||||
{ name: labels.createdAt || 'Created', sort: true },
|
||||
{ name: labels.createdBy || 'Created by', sort: true },
|
||||
{ name: 'edit_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by', null],
|
||||
sortColumns: ['domain_url', 'status', 'debitor_name', 'product_code', 'created_at', 'created_by', null],
|
||||
paginationLimit: 20,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ id: row.id || '', url: row.edit_url || '' },
|
||||
row.debitor_name || '',
|
||||
row.product_code || '',
|
||||
{ domain: row.domain_url || '', url: row.edit_url || '', id: row.id || '' },
|
||||
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
|
||||
row.debitor_name || '',
|
||||
row.product_display || '',
|
||||
row.created_at || '',
|
||||
row.created_by_name || '',
|
||||
row.edit_url || '',
|
||||
@@ -64,12 +72,34 @@ if (config) {
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowInteraction: { linkColumn: domainColumnIndex },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
if (canManage && gridjs.plugins?.selection?.RowSelection) {
|
||||
gridOptions.selection = {
|
||||
enabled: true,
|
||||
id: 'selectRow',
|
||||
component: gridjs.plugins.selection.RowSelection,
|
||||
selectAllLabel: labels.selectAll || 'Select all',
|
||||
props: {
|
||||
id: (row) => row.cell(domainColumnIndex).data?.id || '',
|
||||
},
|
||||
getSelectedIds: ({ container }) =>
|
||||
Array.from(container.querySelectorAll('.gridjs-tr-selected'))
|
||||
.map((tr) => {
|
||||
const link = tr.querySelector('a[href]');
|
||||
if (!link) return '';
|
||||
const href = link.getAttribute('href') || '';
|
||||
const match = href.match(/\/edit\/(\d+)/);
|
||||
return match ? match[1] : '';
|
||||
})
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
@@ -82,5 +112,53 @@ if (config) {
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed', { module: 'helpdesk-handovers', component: 'grid' });
|
||||
}
|
||||
|
||||
if (canManage && gridConfig) {
|
||||
bindBulkVisibility({
|
||||
containerSelector: '#helpdesk-handovers-grid',
|
||||
buttonSelector: '[data-handovers-bulk]',
|
||||
});
|
||||
|
||||
const bulkButtons = Array.from(document.querySelectorAll('[data-handovers-bulk]'));
|
||||
bulkButtons.forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const action = button.dataset.handoversBulk || '';
|
||||
const ids = gridConfig.selection?.getSelectedIds?.() || [];
|
||||
if (!ids.length || action !== 'delete') return;
|
||||
|
||||
const confirmed = await confirmDialog.confirm({
|
||||
message: labels.bulkDeleteConfirm || 'Delete selected handovers?',
|
||||
actionKind: 'delete',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
ids: ids.join(','),
|
||||
[config.csrfKey]: config.csrfToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch',
|
||||
},
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.ok) {
|
||||
gridConfig.selection?.clear?.();
|
||||
gridConfig.grid?.forceRender();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user