feat(js): add app-http contracts and migrate helpdesk runtime layer

This commit is contained in:
2026-04-20 22:31:13 +02:00
parent ec2f03e0cf
commit f189ef9df6
34 changed files with 2023 additions and 1134 deletions

View File

@@ -77,6 +77,96 @@ return [
'order' => 800,
],
],
'runtime.component' => [
[
'key' => 'helpdesk-detail',
'script' => 'modules/helpdesk/js/helpdesk-detail.js',
'export' => 'initHelpdeskDetail',
'selector' => '[data-app-component="helpdesk-detail"]',
'config_path' => 'components.helpdesk.detail',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.access',
'order' => 801,
],
[
'key' => 'helpdesk-domain-detail',
'script' => 'modules/helpdesk/js/helpdesk-domain-detail.js',
'export' => 'initHelpdeskDomainDetail',
'selector' => '[data-app-component="helpdesk-domain-detail"]',
'config_path' => 'components.helpdesk.domainDetail',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.access',
'order' => 802,
],
[
'key' => 'helpdesk-ticket',
'script' => 'modules/helpdesk/js/helpdesk-ticket.js',
'export' => 'initHelpdeskTicket',
'selector' => '[data-app-component="helpdesk-ticket"]',
'config_path' => 'components.helpdesk.ticket',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.access',
'order' => 803,
],
[
'key' => 'helpdesk-team',
'script' => 'modules/helpdesk/js/helpdesk-team.js',
'export' => 'initHelpdeskTeam',
'selector' => '[data-app-component="helpdesk-team"]',
'config_path' => 'components.helpdesk.team',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.team-workload.view',
'order' => 804,
],
[
'key' => 'helpdesk-risk-radar',
'script' => 'modules/helpdesk/js/helpdesk-risk-radar.js',
'export' => 'initHelpdeskRiskRadar',
'selector' => '[data-app-component="helpdesk-risk-radar"]',
'config_path' => 'components.helpdesk.riskRadar',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.risk-radar.view',
'order' => 805,
],
[
'key' => 'helpdesk-settings',
'script' => 'modules/helpdesk/js/helpdesk-settings.js',
'export' => 'initHelpdeskSettings',
'selector' => '[data-app-component="helpdesk-settings"]',
'config_path' => 'components.helpdesk.settings',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.settings.manage',
'order' => 806,
],
[
'key' => 'helpdesk-handover-domain-select',
'script' => 'modules/helpdesk/js/handover-domain-select.js',
'export' => 'initHandoverDomainSelect',
'selector' => '#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]',
'config_path' => 'components.helpdesk.handoverDomainSelect',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.handovers.create',
'order' => 807,
],
[
'key' => 'helpdesk-handover-schema-editor',
'script' => 'modules/helpdesk/js/handover-schema-editor.js',
'export' => 'initHandoverSchemaEditor',
'selector' => '#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]',
'config_path' => 'components.helpdesk.handoverSchemaEditor',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.software-products.manage',
'order' => 808,
],
],
],
'authorization_policies' => [

View File

@@ -26,6 +26,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
?>
<div class="app-details-container"
data-app-component="helpdesk-detail"
data-customer-no="<?php e($customerNo); ?>"
data-customer-name="<?php e($customerName); ?>"
data-support-dashboard-url="<?php e(lurl('helpdesk/debitor-support-dashboard-data')); ?>"
@@ -522,4 +523,3 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
]); ?></script>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-detail.js')); ?>"></script>

View File

@@ -32,6 +32,7 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral';
?>
<div class="app-details-container"
data-app-component="helpdesk-domain-detail"
data-domain-no="<?php e($domainNo); ?>"
data-customer-no="<?php e($customerNo); ?>"
data-customer-name="<?php e($customerName); ?>"
@@ -230,5 +231,4 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral';
'Hotfix' => t('Hotfix'),
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-domain-detail.js')); ?>"></script>
<?php endif; ?>

View File

@@ -71,7 +71,7 @@ $steps = [
</div>
</div>
<div class="app-wizard-field-group" id="wizard-domain-group"
<div class="app-wizard-field-group" id="wizard-domain-group" data-app-component="helpdesk-handover-domain-select"
data-domains-url="<?php e(lurl('helpdesk/handover-domains-data')); ?>"
data-text-initial="<?php e(t('Select a customer first...')); ?>"
data-text-loading="<?php e(t('Loading domains...')); ?>"
@@ -145,6 +145,3 @@ $steps = [
</div>
<?php endif; ?>
</div>
<script type="module" src="<?php e(assetVersion('js/components/app-lookup-field.js')); ?>"></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/handover-domain-select.js')); ?>"></script>

View File

@@ -13,6 +13,7 @@ $periodLabels = [
];
?>
<div class="app-details-container"
data-app-component="helpdesk-risk-radar"
data-risk-radar-url="<?php e(lurl('helpdesk/risk-radar-data')); ?>"
data-debitor-base-url="<?php e(lurl('helpdesk/debitor/')); ?>"
data-label-high="<?php e(t('High risk')); ?>"
@@ -119,5 +120,3 @@ $periodLabels = [
</div>
</section>
</div>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-risk-radar.js')); ?>"></script>

View File

@@ -63,7 +63,7 @@ $tenantHasOAuthSecret = (bool) ($tenantHasOAuthSecret ?? false);
$tenantAuthMode = trim((string) ($tenantRow['auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC));
$tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
?>
<div class="app-details-container">
<div class="app-details-container" data-app-component="helpdesk-settings">
<section>
<?php
$titlebar = [
@@ -488,5 +488,3 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
</form>
</section>
</div>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-settings.js')); ?>"></script>

View File

@@ -27,6 +27,7 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
<div class="handover-schema-split-editor">
<div
id="handover-schema-editor"
data-app-component="helpdesk-handover-schema-editor"
data-initial-fields="<?php e(json_encode($handoverSchemaFields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); ?>"
data-translations="<?php e(json_encode([
'add_field' => t('Add field'),

View File

@@ -87,4 +87,3 @@ $active = (int) ($values['active'] ?? 1);
</div>
</aside>
</div>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/handover-schema-editor.js')); ?>"></script>

View File

@@ -15,6 +15,7 @@ $periodLabels = [
];
?>
<div class="app-details-container"
data-app-component="helpdesk-team"
data-team-workload-url="<?php e(lurl('helpdesk/team-workload-data')); ?>"
data-label-not-assigned="<?php e(t('Not assigned')); ?>"
data-label-queue="<?php e(t('Queue')); ?>"
@@ -111,5 +112,3 @@ $periodLabels = [
</div>
</section>
</div>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-team.js')); ?>"></script>

View File

@@ -22,6 +22,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
?>
<div class="app-details-container"
data-app-component="helpdesk-ticket"
data-ticket-no="<?php e($ticketNo); ?>"
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
data-file-download-url="<?php e(lurl('helpdesk/ticket-file-data')); ?>"
@@ -158,5 +159,3 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
'logged an activity' => t('logged an activity'),
'on' => t('on'),
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-ticket.js')); ?>"></script>

View File

@@ -1,19 +1,34 @@
/**
* 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
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
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');
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveGroup = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]')) {
return host;
}
return host.querySelector('#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]');
};
export function initHandoverDomainSelect(root = document) {
const group = resolveGroup(root);
if (!group) {
return EMPTY_API;
}
const scope = group.closest('form') || document;
const lookupContainer = scope.querySelector('[data-app-lookup]');
const domainSelect = group.querySelector('#wizard-domain');
const domainUrlInput = group.querySelector('#wizard-domain-url');
const feedback = group.querySelector('#wizard-domain-feedback');
if (!lookupContainer || !domainSelect) {
return EMPTY_API;
}
if (group && lookupContainer && domainSelect) {
const domainsUrl = group.dataset.domainsUrl || '';
const textInitial = group.dataset.textInitial || '';
const textLoading = group.dataset.textLoading || '';
@@ -21,35 +36,48 @@ if (group && lookupContainer && domainSelect) {
const textEmpty = group.dataset.textEmpty || '';
const textError = group.dataset.textError || '';
function clearOptions() {
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const clearOptions = () => {
while (domainSelect.options.length > 0) {
domainSelect.remove(0);
}
}
};
function addPlaceholder(text) {
const addPlaceholder = (text) => {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = text;
domainSelect.appendChild(opt);
}
};
function setFeedback(text, variant) {
if (!feedback) return;
const setFeedback = (text, variant) => {
if (!feedback) {
return;
}
feedback.textContent = text;
feedback.hidden = !text;
feedback.dataset.variant = variant || '';
}
};
function resetDomain() {
const resetDomain = () => {
clearOptions();
addPlaceholder(textInitial);
domainSelect.disabled = true;
if (domainUrlInput) domainUrlInput.value = '';
if (domainUrlInput) {
domainUrlInput.value = '';
}
setFeedback('', '');
}
};
async function loadDomains(debitorNo) {
const loadDomains = async (debitorNo) => {
clearOptions();
addPlaceholder(textLoading);
domainSelect.disabled = true;
@@ -57,10 +85,9 @@ if (group && lookupContainer && domainSelect) {
setFeedback('', '');
try {
const res = await fetch(domainsUrl + '?debitor_no=' + encodeURIComponent(debitorNo), {
headers: { 'Accept': 'application/json' },
const data = await getJson(`${domainsUrl}?debitor_no=${encodeURIComponent(debitorNo)}`, {
signal: requestController.signal,
});
const data = await res.json();
const items = Array.isArray(data) ? data : [];
clearOptions();
@@ -72,7 +99,7 @@ if (group && lookupContainer && domainSelect) {
return;
}
items.forEach(function (item) {
items.forEach((item) => {
const opt = document.createElement('option');
opt.value = item.value || '';
opt.textContent = item.label || item.value || '';
@@ -80,45 +107,53 @@ if (group && lookupContainer && domainSelect) {
domainSelect.appendChild(opt);
});
domainSelect.disabled = false;
} catch {
clearOptions();
addPlaceholder(textSelect);
domainSelect.disabled = true;
setFeedback(textError, 'error');
} catch (error) {
if (!(error instanceof Error && error.name === 'AbortError')) {
clearOptions();
addPlaceholder(textSelect);
domainSelect.disabled = true;
setFeedback(textError, 'error');
}
} finally {
group.removeAttribute('aria-busy');
}
}
};
domainSelect.addEventListener('change', function () {
on(domainSelect, 'change', () => {
const selected = domainSelect.options[domainSelect.selectedIndex];
if (domainUrlInput) {
domainUrlInput.value = selected?.dataset.url || '';
}
});
lookupContainer.addEventListener('lookup:select', function (e) {
const debitorNo = e.detail?.value || '';
on(lookupContainer, 'lookup:select', (event) => {
const debitorNo = event.detail?.value || '';
if (debitorNo) {
loadDomains(debitorNo);
void loadDomains(debitorNo);
} else {
resetDomain();
}
});
lookupContainer.addEventListener('lookup:clear', function () {
on(lookupContainer, 'lookup:clear', () => {
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 () {
void loadDomains(initialDebitor).then(() => {
if (initialDomain && !domainSelect.disabled) {
domainSelect.value = initialDomain;
domainSelect.dispatchEvent(new Event('change'));
}
});
}
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
}

View File

@@ -4,15 +4,19 @@
* Renders a structured field list from JSON and serializes changes
* back to a hidden input on form submit.
*/
import { resolveHost } from '/js/core/app-dom.js';
const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
const EMPTY_API = Object.freeze({ destroy: () => {} });
/** @type {HTMLElement|null} */
const container = document.getElementById('handover-schema-editor');
if (container) {
init(container);
}
const resolveEditor = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]')) {
return host;
}
return host.querySelector('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]');
};
/**
* Remove all child nodes from an element (safe alternative to setting markup).
@@ -25,10 +29,19 @@ function clearElement(el) {
}
function init(root) {
const listenerController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const t = JSON.parse(root.dataset.translations || '{}');
const initialFields = JSON.parse(root.dataset.initialFields || '[]');
const hiddenInput = document.getElementById('handover-schema-json');
const previewContainer = document.getElementById('handover-schema-preview');
const scope = root.closest('.app-details-container') || document;
const hiddenInput = scope.querySelector('#handover-schema-json');
const previewContainer = scope.querySelector('#handover-schema-preview');
const form = hiddenInput?.closest('form');
/** @type {Array<object>} */
@@ -38,9 +51,7 @@ function init(root) {
renderPreview();
syncHiddenInput();
if (form) {
form.addEventListener('submit', () => syncHiddenInput());
}
on(form, 'submit', () => syncHiddenInput());
function render() {
clearElement(root);
@@ -56,7 +67,7 @@ function init(root) {
addButton.className = 'secondary outline';
addButton.appendChild(createIcon('bi-plus-lg'));
addButton.appendChild(document.createTextNode(' ' + (t.add_field || 'Add field')));
addButton.addEventListener('click', () => {
on(addButton, 'click', () => {
fields.push({ type: 'text', key: '', label: '', required: false, options: [] });
render();
notifyChange();
@@ -86,7 +97,7 @@ function init(root) {
button.setAttribute('data-tooltip', t.add_field || 'Add field');
button.setAttribute('aria-label', t.add_field || 'Add field');
button.appendChild(createIcon('bi-plus'));
button.addEventListener('click', () => {
on(button, 'click', () => {
fields.splice(insertIndex, 0, { type: 'text', key: '', label: '', required: false, options: [] });
render();
notifyChange();
@@ -102,8 +113,8 @@ function init(root) {
const row = document.createElement('div');
row.className = 'handover-schema-row';
row.dataset.fieldIndex = index;
row.addEventListener('mouseenter', () => highlightPreview(index, true));
row.addEventListener('mouseleave', () => highlightPreview(index, false));
on(row, 'mouseenter', () => highlightPreview(index, true));
on(row, 'mouseleave', () => highlightPreview(index, false));
// --- Header: "Feld #N" title + action icons ---
const header = document.createElement('div');
@@ -162,7 +173,7 @@ function init(root) {
if (ft === field.type) opt.selected = true;
typeSelect.appendChild(opt);
});
typeSelect.addEventListener('change', () => {
on(typeSelect, 'change', () => {
field.type = typeSelect.value;
if (DISPLAY_ONLY_TYPES.includes(field.type)) {
delete field.key;
@@ -194,7 +205,7 @@ function init(root) {
}
labelInput.value = field.label || '';
labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1));
labelInput.addEventListener('input', () => { field.label = labelInput.value; notifyChange(); });
on(labelInput, 'input', () => { field.label = labelInput.value; notifyChange(); });
labelGroup.appendChild(labelInput);
body.appendChild(labelGroup);
@@ -209,7 +220,7 @@ function init(root) {
const reqInput = document.createElement('input');
reqInput.type = 'checkbox';
reqInput.checked = !!field.required;
reqInput.addEventListener('change', () => { field.required = reqInput.checked; notifyChange(); });
on(reqInput, 'change', () => { field.required = reqInput.checked; notifyChange(); });
reqLabel.appendChild(reqInput);
reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required')));
reqRow.appendChild(reqLabel);
@@ -233,7 +244,7 @@ function init(root) {
addOptButton.setAttribute('data-tooltip', t.add_option || 'Add option');
addOptButton.setAttribute('aria-label', t.add_option || 'Add option');
addOptButton.appendChild(createIcon('bi-plus-lg'));
addOptButton.addEventListener('click', () => {
on(addOptButton, 'click', () => {
field.options.push({ value: '', label: '' });
render();
notifyChange();
@@ -252,7 +263,7 @@ function init(root) {
lblInput.value = opt.label || '';
lblInput.placeholder = (t.option_label || 'Label') + ' ' + (optIndex + 1);
lblInput.setAttribute('aria-label', (t.option_label || 'Label') + ' ' + (optIndex + 1));
lblInput.addEventListener('input', () => { opt.label = lblInput.value; notifyChange(); });
on(lblInput, 'input', () => { opt.label = lblInput.value; notifyChange(); });
optRow.appendChild(lblInput);
optRow.appendChild(createIconButton(
@@ -294,7 +305,7 @@ function init(root) {
button.className = buttonClass;
button.setAttribute('aria-label', ariaLabel);
button.appendChild(createIcon(iconClass));
button.addEventListener('click', onClick);
on(button, 'click', onClick);
return button;
}
@@ -337,7 +348,7 @@ function init(root) {
if (field.type === 'checkbox') {
const input = document.createElement('input');
input.type = 'checkbox';
input.addEventListener('click', (e) => e.preventDefault());
on(input, 'click', (e) => e.preventDefault());
group.appendChild(input);
const label = document.createElement('label');
label.textContent = field.label || '';
@@ -408,4 +419,19 @@ function init(root) {
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
return {
destroy: () => {
listenerController.abort();
},
};
}
export function initHandoverSchemaEditor(root = document) {
const container = resolveEditor(root);
if (!container) {
return EMPTY_API;
}
return init(container);
}

View File

@@ -7,6 +7,8 @@
* - Aside: cross-ticket communication feed
*/
import { initStandardListPage } from '/js/core/app-grid-factory.js';
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
import { readPageConfig } from '/js/core/app-page-config.js';
import {
renderCommunicationError,
@@ -15,12 +17,35 @@ import {
} from './helpdesk-communication.js';
import { renderEmptyState } from './helpdesk-empty-state.js';
const container = document.querySelector('.app-details-container[data-customer-no]');
if (container) {
init(container);
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-detail"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-detail"]');
};
export function initHelpdeskDetail(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
return init(container);
}
function init(container) {
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const customerNo = container.dataset.customerNo || '';
const customerName = container.dataset.customerName || '';
const supportDashboardUrl = container.dataset.supportDashboardUrl || '';
@@ -37,7 +62,9 @@ function init(container) {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
})();
if (!customerNo || !customerName) return;
if (!customerNo || !customerName) {
return EMPTY_API;
}
const i18nEl = document.getElementById('helpdesk-i18n');
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
@@ -69,8 +96,9 @@ function init(container) {
supportDashboardPromise = Promise.resolve({ ok: false, error: 'No support dashboard endpoint configured' });
} else {
const params = buildBaseParams();
supportDashboardPromise = fetch(supportDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
supportDashboardPromise = getJson(`${supportDashboardUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
@@ -84,8 +112,9 @@ function init(container) {
salesDashboardPromise = Promise.resolve({ ok: false, error: 'No sales dashboard endpoint configured' });
} else {
const params = buildBaseParams();
salesDashboardPromise = fetch(salesDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
salesDashboardPromise = getJson(`${salesDashboardUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
@@ -100,8 +129,9 @@ function init(container) {
} else {
const params = new URLSearchParams({ customerNo });
if (refreshFromUrl) params.set('refresh', '1');
meetingsPromise = fetch(meetingsUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
meetingsPromise = getJson(`${meetingsUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
@@ -111,8 +141,9 @@ function init(container) {
function fetchContacts() {
if (!contactsPromise) {
const params = buildBaseParams();
contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
contactsPromise = getJson(`${contactsUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
@@ -134,8 +165,9 @@ function init(container) {
url.searchParams.set('refresh', '1');
}
ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' })
.then(r => r.json())
ticketCategoriesPromise = getJson(url.toString(), {
signal: requestController.signal,
})
.catch(() => ({ ok: false, categories: [] }));
}
@@ -148,8 +180,9 @@ function init(container) {
debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
} else {
const params = buildBaseParams();
debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
debitorCommunicationPromise = getJson(`${communicationUrl}?${params.toString()}`, {
signal: requestController.signal,
})
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
@@ -258,7 +291,9 @@ function init(container) {
// Fetch type options from first data response
const typeOptionsUrl = new URL(gridDataUrl.toString());
typeOptionsUrl.searchParams.set('limit', '1');
const typeResp = await fetch(typeOptionsUrl.toString()).then((r) => r.json()).catch(() => null);
const typeResp = await getJson(typeOptionsUrl.toString(), {
signal: requestController.signal,
}).catch(() => null);
const typeOptions = Array.isArray(typeResp?.type_options) ? typeResp.type_options : [];
hydrateContactTypeFilter(contactsConfig, typeOptions);
@@ -556,7 +591,7 @@ function init(container) {
document.body.insertAdjacentHTML('beforeend', dialogHtml);
const dialog = document.getElementById('helpdesk-contract-dialog');
dialog.addEventListener('click', (e) => {
on(dialog, 'click', (e) => {
if (e.target.closest('[data-dialog-close]')) {
dialog.close();
dialog.remove();
@@ -568,12 +603,12 @@ function init(container) {
dialog.remove();
}
});
dialog.addEventListener('close', () => { dialog.remove(); });
on(dialog, 'close', () => { dialog.remove(); });
dialog.showModal();
}
// Delegate click on contract cards and timeline rows
container.addEventListener('click', (e) => {
on(container, 'click', (e) => {
const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]');
if (!target) return;
const index = Number(target.dataset.contractIndex);
@@ -583,7 +618,7 @@ function init(container) {
});
// KPI tile click → scroll to ticket list and apply filter or sort
container.addEventListener('click', (e) => {
on(container, 'click', (e) => {
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]');
if (!kpi) return;
@@ -662,7 +697,7 @@ function init(container) {
}
}
container.addEventListener('click', (e) => {
on(container, 'click', (e) => {
if (e.target.closest('a')) return;
const row = e.target.closest('.app-clickable-row[data-href]');
if (row) {
@@ -673,7 +708,7 @@ function init(container) {
}
});
container.addEventListener('keydown', (e) => {
on(container, 'keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = e.target.closest('.app-clickable-row[data-href]');
if (row) {
@@ -1280,8 +1315,7 @@ function init(container) {
params.set('periodDays', String(periodDays));
const url = controllingDashboardUrl + '?' + params.toString();
try {
const res = await fetch(url, { credentials: 'same-origin' });
return await res.json();
return await getJson(url, { signal: requestController.signal });
} catch {
return null;
}
@@ -1521,18 +1555,16 @@ function init(container) {
// Period selector (radio inputs)
const periodSelector = document.getElementById('controlling-period-selector');
if (periodSelector) {
periodSelector.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="controlling-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === controllingCurrentPeriod) return;
on(periodSelector, 'change', (e) => {
const radio = e.target.closest('input[name="controlling-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === controllingCurrentPeriod) return;
controllingCurrentPeriod = period;
controllingTabRendered = false;
renderControllingTab(period);
});
}
controllingCurrentPeriod = period;
controllingTabRendered = false;
void renderControllingTab(period);
});
const tabPanels = container.querySelectorAll('[data-tab-panel]');
const observer = new MutationObserver(() => {
@@ -1558,18 +1590,28 @@ function init(container) {
const refreshButton = container.querySelector('button[name="support-refresh"]')
|| document.getElementById('support-refresh-button');
if (refreshButton) {
refreshButton.addEventListener('click', () => {
refreshButton.setAttribute('aria-busy', 'true');
refreshButton.setAttribute('disabled', 'disabled');
on(refreshButton, 'click', () => {
refreshButton.setAttribute('aria-busy', 'true');
refreshButton.setAttribute('disabled', 'disabled');
const url = new URL(window.location.href);
url.searchParams.set('refresh', '1');
window.location.assign(url.toString());
});
}
const url = new URL(window.location.href);
url.searchParams.set('refresh', '1');
window.location.assign(url.toString());
});
renderSupportTab();
initTicketsGrid();
renderDebitorCommunicationAside();
void initTicketsGrid();
void renderDebitorCommunicationAside();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
observer.disconnect();
const dialog = document.getElementById('helpdesk-contract-dialog');
if (dialog) {
dialog.remove();
}
},
};
}

View File

@@ -8,6 +8,8 @@
* into innerHTML. This matches the pattern used in helpdesk-detail.js
* and other helpdesk JS modules in this codebase.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
import { renderEmptyState } from './helpdesk-empty-state.js';
function esc(str) {
@@ -30,12 +32,18 @@ const STATUS_LABEL_MAP = {
archived: 'Archived',
};
const container = document.querySelector('.app-details-container[data-domain-no]');
if (container) {
init(container);
}
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-domain-detail"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-domain-detail"]');
};
function init(container) {
const requestController = new AbortController();
const domainNo = container.dataset.domainNo || '';
const customerNo = container.dataset.customerNo || '';
const detailUrl = container.dataset.detailUrl || '';
@@ -58,8 +66,9 @@ function init(container) {
try {
const params = new URLSearchParams({ domainNo, customerNo });
const res = await fetch(detailUrl + '?' + params, { credentials: 'same-origin' });
const data = await res.json();
const data = await getJson(`${detailUrl}?${params.toString()}`, {
signal: requestController.signal,
});
if (!data.ok) {
showError(data.error || t('Network error — please try again'));
@@ -74,7 +83,10 @@ function init(container) {
if (loadingEl) loadingEl.hidden = true;
if (contentEl) contentEl.hidden = false;
} catch {
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showError(t('Network error — please try again'));
}
}
@@ -317,5 +329,18 @@ function init(container) {
// safe-html: all content built from esc()-escaped values
el.innerHTML = items;
}
return {
destroy: () => {
requestController.abort();
},
};
}
export function initHelpdeskDomainDetail(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
return init(container);
}

View File

@@ -1,9 +1,34 @@
/**
* Risk Radar — customer portfolio risk view.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-risk-radar"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-risk-radar"]');
};
export function initHelpdeskRiskRadar(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const container = document.querySelector('.app-details-container[data-risk-radar-url]');
if (container) {
const dataUrl = container.dataset.riskRadarUrl;
const debitorBaseUrl = container.dataset.debitorBaseUrl || '';
const d = (key, fallback) => container.dataset[key] || fallback;
@@ -22,35 +47,29 @@ if (container) {
const dialogClose = dialog ? dialog.querySelector('.close') : null;
let currentPeriod = 90;
let lastData = null;
function showState(state) {
const showState = (state) => {
if (elLoading) elLoading.hidden = state !== 'loading';
if (elError) elError.hidden = state !== 'error';
if (elEmpty) elEmpty.hidden = state !== 'empty';
if (elContent) elContent.hidden = state !== 'success';
}
};
function h(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
const h = (tag, cls, text) => {
const element = document.createElement(tag);
if (cls) element.className = cls;
if (text !== undefined) element.textContent = text;
return element;
};
function levelClass(level) {
return 'helpdesk-risk-level-' + level;
}
const levelClass = (level) => `helpdesk-risk-level-${level}`;
function formatAge(hours) {
if (hours < 24) return hours + 'h';
return Math.floor(hours / 24) + 'd';
}
const formatAge = (hours) => {
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
};
/**
* Dimension label lookup from data attributes.
*/
function dimLabel(id) {
const dimLabel = (id) => {
const map = {
'open_pressure': d('labelOpenPressure', 'Open pressure'),
'trend': d('labelTrend', 'Trend'),
@@ -58,57 +77,16 @@ if (container) {
'inactivity': d('labelInactivity', 'Inactivity'),
};
return map[id] || id;
}
};
/**
* Build a single customer risk card — accent border + score pill.
*/
function buildCard(customer) {
const card = h('article', 'helpdesk-risk-card ' + levelClass(customer.risk_level));
card.setAttribute('aria-label', customer.customer_name || customer.customer_no);
// Row 1: Name + Level on left, score pill on right
const header = h('div', 'helpdesk-risk-card-header');
const info = h('div', 'helpdesk-risk-card-info');
info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no));
const levelLabels = { high: d('labelHigh', 'High risk'), medium: d('labelMedium', 'Medium risk'), low: d('labelLow', 'Low risk') };
info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || ''));
header.appendChild(info);
const scoreEl = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score));
header.appendChild(scoreEl);
card.appendChild(header);
// Row 2: One-line key facts separated by ·
const kpis = customer.kpis || {};
const parts = [];
if (kpis.open > 0) parts.push(kpis.open + ' ' + d('labelOpen', 'open'));
if (kpis.critical > 0) parts.push(kpis.critical + ' ' + d('labelCritical', 'critical'));
if (kpis.sla_overdue > 0) parts.push(kpis.sla_overdue + ' SLA');
if (kpis.net_flow > 0) parts.push('↑' + kpis.net_flow);
else if (kpis.net_flow < 0) parts.push('↓' + Math.abs(kpis.net_flow));
if (parts.length > 0) {
card.appendChild(h('p', 'helpdesk-risk-card-facts', parts.join(' · ')));
const openDetail = (customer) => {
if (!dialog || !dialogTitle || !dialogBody) {
return;
}
// Click/keyboard to open detail
card.tabIndex = 0;
card.addEventListener('click', () => openDetail(customer));
card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(customer); } });
return card;
}
/**
* Open detail dialog for a customer.
*/
function openDetail(customer) {
if (!dialog || !dialogTitle || !dialogBody) return;
dialogTitle.textContent = (customer.customer_name || customer.customer_no) + ' — ' + customer.risk_score + '/100';
dialogTitle.textContent = `${customer.customer_name || customer.customer_no}${customer.risk_score}/100`;
dialogBody.replaceChildren();
// Dimensions as points breakdown: "18 / 35"
const dims = customer.dimensions || [];
const dimTable = h('table', 'helpdesk-risk-detail-tickets');
const dimHead = h('thead');
@@ -122,7 +100,7 @@ if (container) {
const tr = h('tr');
tr.appendChild(h('td', '', dimLabel(dim.id)));
const points = Math.round(dim.weighted_points || 0);
tr.appendChild(h('td', '', points + ' / ' + dim.weight));
tr.appendChild(h('td', '', `${points} / ${dim.weight}`));
dimBody.appendChild(tr);
}
const totalTr = h('tr');
@@ -132,16 +110,15 @@ if (container) {
totalTr.appendChild(totalLabel);
const totalVal = h('td', '');
totalVal.style.fontWeight = '700';
totalVal.textContent = customer.risk_score + ' / 100';
totalVal.textContent = `${customer.risk_score} / 100`;
totalTr.appendChild(totalVal);
dimBody.appendChild(totalTr);
dimTable.appendChild(dimBody);
dialogBody.appendChild(dimTable);
// Open tickets list
const tickets = customer.open_tickets || [];
if (tickets.length > 0) {
dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', d('labelTickets', 'Tickets') + ' (' + tickets.length + ')'));
dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', `${d('labelTickets', 'Tickets')} (${tickets.length})`));
const table = h('table', 'helpdesk-risk-detail-tickets');
const thead = h('thead');
const headRow = h('tr');
@@ -152,19 +129,18 @@ if (container) {
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = h('tbody');
for (const t of tickets) {
const tr = h('tr', t.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : '');
tr.appendChild(h('td', '', t.no));
tr.appendChild(h('td', '', t.escalation_code || '—'));
tr.appendChild(h('td', '', t.state));
tr.appendChild(h('td', '', formatAge(t.age_hours)));
for (const ticket of tickets) {
const tr = h('tr', ticket.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : '');
tr.appendChild(h('td', '', ticket.no));
tr.appendChild(h('td', '', ticket.escalation_code || '—'));
tr.appendChild(h('td', '', ticket.state));
tr.appendChild(h('td', '', formatAge(ticket.age_hours)));
tbody.appendChild(tr);
}
table.appendChild(tbody);
dialogBody.appendChild(table);
}
// Link to debitor
if (debitorBaseUrl && customer.customer_no) {
const link = h('a', 'helpdesk-risk-detail-link', customer.customer_name || customer.customer_no);
link.href = debitorBaseUrl + encodeURIComponent(customer.customer_no);
@@ -174,19 +150,70 @@ if (container) {
}
dialog.showModal();
}
};
// Dialog close handlers
if (dialogClose) dialogClose.addEventListener('click', () => dialog.close());
if (dialog) dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); });
const buildCard = (customer) => {
const card = h('article', `helpdesk-risk-card ${levelClass(customer.risk_level)}`);
card.setAttribute('aria-label', customer.customer_name || customer.customer_no);
function renderCards(customers) {
if (!elCards) return;
const header = h('div', 'helpdesk-risk-card-header');
const info = h('div', 'helpdesk-risk-card-info');
info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no));
const levelLabels = {
high: d('labelHigh', 'High risk'),
medium: d('labelMedium', 'Medium risk'),
low: d('labelLow', 'Low risk'),
};
info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || ''));
header.appendChild(info);
const scoreEl = h('span', `helpdesk-risk-card-score ${levelClass(customer.risk_level)}`, String(customer.risk_score));
header.appendChild(scoreEl);
card.appendChild(header);
const kpis = customer.kpis || {};
const parts = [];
if (kpis.open > 0) parts.push(`${kpis.open} ${d('labelOpen', 'open')}`);
if (kpis.critical > 0) parts.push(`${kpis.critical} ${d('labelCritical', 'critical')}`);
if (kpis.sla_overdue > 0) parts.push(`${kpis.sla_overdue} SLA`);
if (kpis.net_flow > 0) parts.push(`${kpis.net_flow}`);
else if (kpis.net_flow < 0) parts.push(`${Math.abs(kpis.net_flow)}`);
if (parts.length > 0) {
card.appendChild(h('p', 'helpdesk-risk-card-facts', parts.join(' · ')));
}
card.tabIndex = 0;
on(card, 'click', () => openDetail(customer));
on(card, 'keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openDetail(customer);
}
});
return card;
};
on(dialogClose, 'click', () => {
dialog?.close();
});
on(dialog, 'click', (event) => {
if (event.target === dialog) {
dialog.close();
}
});
const renderCards = (customers) => {
if (!elCards) {
return;
}
elCards.replaceChildren();
for (const c of customers) elCards.appendChild(buildCard(c));
}
for (const customer of customers) {
elCards.appendChild(buildCard(customer));
}
};
async function fetchData(refresh = false) {
const fetchData = async (refresh = false) => {
showState('loading');
if (elTruncated) elTruncated.hidden = true;
@@ -194,38 +221,48 @@ if (container) {
if (refresh) params.set('refresh', '1');
try {
const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' });
const json = await res.json();
const json = await getJson(`${dataUrl}?${params.toString()}`, {
signal: requestController.signal,
});
if (!json.ok) { showState('error'); return; }
if (!json.customers || json.customers.length === 0) { showState('empty'); return; }
lastData = json;
renderCards(json.customers);
showState('success');
if (json.meta && json.meta.truncated && elTruncated) {
elTruncated.hidden = false;
}
} catch {
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showState('error');
}
}
};
if (periodSelector) {
periodSelector.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="radar-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return;
currentPeriod = period;
fetchData();
});
}
on(periodSelector, 'change', (event) => {
const radio = event.target.closest('input[name="radar-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return;
currentPeriod = period;
void fetchData();
});
if (elRefresh) {
elRefresh.addEventListener('click', () => fetchData(true));
}
on(elRefresh, 'click', () => {
void fetchData(true);
});
fetchData();
void fetchData();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
if (dialog?.open) {
dialog.close();
}
},
};
}

View File

@@ -2,133 +2,143 @@
* Helpdesk settings page — auth mode toggle and connection test.
*/
import { showAsyncFlash } from '/js/components/app-async-flash.js';
import { postForm } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
const settingsForm = document.getElementById('helpdesk-settings-form');
const EMPTY_API = Object.freeze({ destroy: () => {} });
const getMessage = (key, fallback) => {
if (!settingsForm || !settingsForm.dataset) {
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-settings"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-settings"]');
};
const readMessage = (form, key, fallback) => {
if (!form || !form.dataset) {
return fallback;
}
return settingsForm.dataset[key] || fallback;
return form.dataset[key] || fallback;
};
// 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;
export function initHelpdeskSettings(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
const isOAuth2 = authModeSelect.value === 'oauth2';
basicFields.toggleAttribute('hidden', isOAuth2);
oauth2Fields.toggleAttribute('hidden', !isOAuth2);
};
const settingsForm = container.querySelector('#helpdesk-settings-form');
if (!settingsForm) {
return EMPTY_API;
}
if (authModeSelect) {
authModeSelect.addEventListener('change', toggleAuthFields);
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const authModeSelect = settingsForm.querySelector('#auth_mode');
const basicFields = settingsForm.querySelector('#helpdesk-basic-auth-fields');
const oauth2Fields = settingsForm.querySelector('#helpdesk-oauth2-fields');
const testButton = settingsForm.querySelector('#helpdesk-test-connection-button');
const testResult = settingsForm.querySelector('#helpdesk-connection-test-result');
const overrideToggle = settingsForm.querySelector('#helpdesk-override-toggle');
const tenantFields = settingsForm.querySelector('#helpdesk-tenant-fields');
const saveTargetInput = settingsForm.querySelector('#helpdesk-settings-save-target');
const tenantAuthModeSelect = settingsForm.querySelector('#tenant_auth_mode');
const tenantBasicFields = settingsForm.querySelector('#helpdesk-tenant-basic-auth-fields');
const tenantOauth2Fields = settingsForm.querySelector('#helpdesk-tenant-oauth2-fields');
const setTestResult = (variant, message) => {
if (!testResult) {
return;
}
testResult.textContent = message;
testResult.dataset.variant = variant;
testResult.hidden = !message;
};
const setButtonLoadingState = (isLoading) => {
if (!testButton) {
return;
}
const defaultLabel = testButton.dataset.labelDefault || readMessage(settingsForm, 'testDefaultLabel', 'Test connection');
const loadingLabel = testButton.dataset.labelLoading || readMessage(settingsForm, 'testLoadingLabel', 'Testing...');
testButton.disabled = isLoading;
testButton.toggleAttribute('aria-busy', isLoading);
testButton.textContent = isLoading ? loadingLabel : defaultLabel;
};
const toggleAuthFields = () => {
if (!authModeSelect || !basicFields || !oauth2Fields) {
return;
}
const isOAuth2 = authModeSelect.value === 'oauth2';
basicFields.toggleAttribute('hidden', isOAuth2);
oauth2Fields.toggleAttribute('hidden', !isOAuth2);
};
on(authModeSelect, 'change', toggleAuthFields);
toggleAuthFields();
}
// Connection test button
const testButton = document.getElementById('helpdesk-test-connection-button');
const testResult = document.getElementById('helpdesk-connection-test-result');
const setTestResult = (variant, message) => {
if (!testResult) {
return;
}
testResult.textContent = message;
testResult.dataset.variant = variant;
testResult.hidden = !message;
};
const setButtonLoadingState = (isLoading) => {
if (!testButton) {
return;
}
const defaultLabel = testButton.dataset.labelDefault || getMessage('testDefaultLabel', 'Test connection');
const loadingLabel = testButton.dataset.labelLoading || getMessage('testLoadingLabel', 'Testing...');
testButton.disabled = isLoading;
testButton.toggleAttribute('aria-busy', isLoading);
testButton.textContent = isLoading ? loadingLabel : defaultLabel;
};
const parseJsonSafely = async (response) => {
try {
return await response.json();
} catch {
return null;
}
};
// Tenant override toggle — show/hide tenant fields + set save_target
const overrideToggle = document.getElementById('helpdesk-override-toggle');
const tenantFields = document.getElementById('helpdesk-tenant-fields');
const saveTargetInput = document.getElementById('helpdesk-settings-save-target');
const tenantAuthModeSelect = document.getElementById('tenant_auth_mode');
const tenantBasicFields = document.getElementById('helpdesk-tenant-basic-auth-fields');
const tenantOauth2Fields = document.getElementById('helpdesk-tenant-oauth2-fields');
if (overrideToggle && tenantFields) {
overrideToggle.addEventListener('change', () => {
tenantFields.hidden = !overrideToggle.checked;
on(overrideToggle, 'change', () => {
if (tenantFields) {
tenantFields.hidden = !overrideToggle.checked;
}
});
}
// On form submit, set save_target based on active tab
if (settingsForm && saveTargetInput) {
settingsForm.addEventListener('submit', () => {
on(settingsForm, 'submit', () => {
if (!saveTargetInput) {
return;
}
const tenantPanel = settingsForm.querySelector('[data-tab-panel="tenant"]:not([hidden])');
saveTargetInput.value = tenantPanel ? 'tenant' : 'global';
});
}
// Tenant auth mode toggle
if (tenantAuthModeSelect && tenantBasicFields && tenantOauth2Fields) {
tenantAuthModeSelect.addEventListener('change', () => {
on(tenantAuthModeSelect, 'change', () => {
if (!tenantBasicFields || !tenantOauth2Fields || !tenantAuthModeSelect) {
return;
}
const isOAuth2 = tenantAuthModeSelect.value === 'oauth2';
tenantBasicFields.hidden = isOAuth2;
tenantOauth2Fields.hidden = !isOAuth2;
});
}
if (testButton) {
testButton.addEventListener('click', async () => {
on(testButton, 'click', async () => {
setButtonLoadingState(true);
setTestResult('info', getMessage('testLoadingLabel', 'Testing...'));
setTestResult('info', readMessage(settingsForm, 'testLoadingLabel', 'Testing...'));
try {
const csrfInput = settingsForm.querySelector('input[name="csrf_token"]');
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();
const body = new URLSearchParams();
if (csrfToken) {
formData.append('csrf_token', csrfToken);
body.set('csrf_token', csrfToken);
}
const response = await fetch(
const data = await postForm(
new URL('helpdesk/settings/test-connection-data', document.baseURI).href,
{
method: 'POST',
body: formData,
credentials: 'same-origin',
},
body,
{ signal: requestController.signal }
);
const data = await parseJsonSafely(response);
const successMessage = getMessage('testSuccessMessage', 'Connection successful');
const defaultErrorMessage = getMessage('testErrorMessage', 'Connection failed');
const successMessage = readMessage(settingsForm, 'testSuccessMessage', 'Connection successful');
const defaultErrorMessage = readMessage(settingsForm, 'testErrorMessage', 'Connection failed');
if (response.ok && data?.ok) {
if (data?.ok === true) {
const message = data.message || successMessage;
setTestResult('success', message);
showAsyncFlash('success', message);
@@ -136,23 +146,36 @@ if (testButton) {
let message = defaultErrorMessage;
if (data?.error) {
message = data.error;
} else if (!response.ok) {
message = `${defaultErrorMessage} (HTTP ${response.status})`;
}
if (data?.debug?.http_code) {
message += ` (HTTP ${data.debug.http_code})`;
}
setTestResult('warning', message);
showAsyncFlash('error', message);
}
} catch {
const message = getMessage('testErrorMessage', 'Connection failed');
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
const defaultErrorMessage = readMessage(settingsForm, 'testErrorMessage', 'Connection failed');
let message = defaultErrorMessage;
const status = error?.status ?? 0;
if (status > 0) {
message = `${defaultErrorMessage} (HTTP ${status})`;
}
setTestResult('warning', message);
showAsyncFlash('error', message);
} finally {
setButtonLoadingState(false);
}
});
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
}

View File

@@ -3,9 +3,34 @@
* Current tab: per-agent widget with section title + ticket table.
* Performance tab: compact agent rows with resolved counts.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-team"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-team"]');
};
export function initHelpdeskTeam(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const container = document.querySelector('.app-details-container[data-team-workload-url]');
if (container) {
const dataUrl = container.dataset.teamWorkloadUrl;
const labelQueue = container.dataset.labelQueue || 'Queue';
const labelTickets = container.dataset.labelTickets || 'Tickets';
@@ -202,7 +227,7 @@ if (container) {
for (const item of items) {
const li = h('li', 'helpdesk-team-perf-ranked-clickable');
li.addEventListener('click', () => {
on(li, 'click', () => {
if (activeItem === li) {
activeItem.classList.remove('active');
activeItem = null;
@@ -363,8 +388,9 @@ if (container) {
if (refresh) params.set('refresh', '1');
try {
const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' });
const json = await res.json();
const json = await getJson(`${dataUrl}?${params.toString()}`, {
signal: requestController.signal,
});
if (!json.ok) { showState('error'); return; }
if (!json.members || json.members.length === 0) { showState('empty'); return; }
@@ -372,25 +398,31 @@ if (container) {
renderCurrentTab(json.members);
renderPerformanceTab(json.members);
showState('success');
} catch {
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showState('error');
}
}
if (periodSelector) {
periodSelector.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="team-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return;
currentPeriod = period;
fetchData();
});
}
on(periodSelector, 'change', (e) => {
const radio = e.target.closest('input[name="team-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return;
currentPeriod = period;
void fetchData();
});
if (elRefresh) {
elRefresh.addEventListener('click', () => fetchData(true));
}
on(elRefresh, 'click', () => void fetchData(true));
fetchData();
void fetchData();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
}

View File

@@ -1,66 +1,82 @@
/**
* Helpdesk ticket detail page — async communication aside.
*
* Loads the ticket communication feed via fetch and renders
* it in the aside panel, matching the debitor detail pattern.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
import {
renderCommunicationError,
renderCommunicationFeed,
renderCommunicationHint,
} from './helpdesk-communication.js';
const container = document.querySelector('.app-details-container[data-ticket-no]');
if (container) {
init(container);
}
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-ticket-no]')) {
return host;
}
return host.querySelector('.app-details-container[data-ticket-no]');
};
export function initHelpdeskTicket(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
function init(container) {
const ticketNo = container.dataset.ticketNo || '';
const communicationUrl = container.dataset.ticketCommunicationUrl || '';
const fileDownloadUrl = container.dataset.fileDownloadUrl || '';
if (!ticketNo || !communicationUrl) {
return EMPTY_API;
}
if (!ticketNo || !communicationUrl) return;
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
// i18n
const i18nEl = document.getElementById('helpdesk-ticket-i18n');
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
const i18n = i18nEl ? JSON.parse(i18nEl.textContent || '{}') : {};
const t = (key) => i18n[key] || key;
function showLoading(id) {
const toggleHidden = (id, hidden) => {
const el = document.getElementById(id);
if (el) el.hidden = false;
}
if (el) {
el.hidden = hidden;
}
};
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;
}
async function loadCommunication(refresh = false) {
const loadCommunication = async (refresh = false) => {
const feedEl = document.getElementById('ticket-communication-feed');
const loadingEl = document.getElementById('ticket-communication-loading');
const contentEl = document.getElementById('ticket-communication-content');
if (!feedEl || !loadingEl || !contentEl) return;
if (!feedEl || !loadingEl || !contentEl) {
return;
}
if (contentEl) contentEl.hidden = true;
showLoading('ticket-communication-loading');
contentEl.hidden = true;
loadingEl.hidden = false;
try {
const params = new URLSearchParams({ ticketNo });
if (refresh) params.set('refresh', '1');
const response = await fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' });
const data = await response.json();
if (refresh) {
params.set('refresh', '1');
}
hideLoading('ticket-communication-loading');
showContent('ticket-communication-content');
const data = await getJson(`${communicationUrl}?${params.toString()}`, {
signal: requestController.signal,
});
if (!data.ok) {
toggleHidden('ticket-communication-loading', true);
toggleHidden('ticket-communication-content', false);
if (!data || data.ok !== true) {
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
return;
}
@@ -82,17 +98,30 @@ function init(container) {
});
feedEl.innerHTML = html;
} catch {
hideLoading('ticket-communication-loading');
showContent('ticket-communication-content');
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
toggleHidden('ticket-communication-loading', true);
toggleHidden('ticket-communication-content', false);
const feedEl = document.getElementById('ticket-communication-feed');
if (feedEl) {
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
}
}
}
};
const refreshButton = container.querySelector('button[name="ticket-refresh"]');
if (refreshButton) {
refreshButton.addEventListener('click', () => loadCommunication(true));
}
on(refreshButton, 'click', () => {
void loadCommunication(true);
});
loadCommunication();
void loadCommunication();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
}

View File

@@ -1,105 +1,97 @@
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 { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-domains');
if (config) {
const gridjs = window.gridjs;
if (!gridjs) {
warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed: Grid.js missing', { module: 'helpdesk-domains', component: 'grid' });
} else {
const appBase = getAppBase();
createListPageModule({
configId: 'helpdesk-domains',
moduleId: 'helpdesk-domains',
missingGridMessage: 'Helpdesk domains grid init failed: Grid.js missing',
initErrorMessage: 'Helpdesk domains grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const labels = config.labels || {};
const domainBaseUrl = config.domainBaseUrl || '';
const domainUrlIndex = 7;
const gridOptions = {
gridjs,
container: '#helpdesk-domains-grid',
dataUrl: config.dataUrl || 'helpdesk/domains-data',
appBase,
columns: [
{
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>`);
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#helpdesk-domains-grid',
dataUrl: config.dataUrl || 'helpdesk/domains-data',
appBase,
columns: [
{
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,
formatter: (cell) => {
const name = escapeHtml(cell?.name || '');
const detailUrl = String(cell?.url || '').trim();
if (detailUrl === '') {
return gridjs.html(name);
}
const href = escapeHtml(withCurrentListReturn(detailUrl));
return gridjs.html(`<a href="${href}">${name}</a>`);
{
name: labels.customerName || 'Customer Name',
sort: true,
formatter: (cell) => {
const name = escapeHtml(cell?.name || '');
const detailUrl = String(cell?.url || '').trim();
if (detailUrl === '') {
return gridjs.html(name);
}
const href = escapeHtml(withCurrentListReturn(detailUrl));
return gridjs.html(`<a href="${href}">${name}</a>`);
},
},
},
{ name: labels.url || 'URL', sort: true },
{ name: labels.contractType || 'Contract Type', sort: true },
{
name: labels.state || 'State',
sort: true,
formatter: (cell) => {
const state = escapeHtml(cell?.state || '');
const variant = cell?.variant || 'neutral';
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${state}</span>`);
{ name: labels.url || 'URL', sort: true },
{ name: labels.contractType || 'Contract Type', sort: true },
{
name: labels.state || 'State',
sort: true,
formatter: (cell) => {
const state = escapeHtml(cell?.state || '');
const variant = cell?.variant || 'neutral';
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${state}</span>`);
},
},
{ 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, null],
paginationLimit: 10,
language: config.gridLang || {},
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: 0 },
rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '',
},
{ 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, null],
paginationLimit: 10,
language: config.gridLang || {},
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: 0 },
rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '',
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-domains-search-input'],
},
});
}) || {};
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed', { module: 'helpdesk-domains', component: 'grid' });
}
}
}
return gridConfig;
},
});

View File

@@ -1,17 +1,15 @@
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 { createListPageModule } from '/js/core/app-list-page-module.js';
import { bindBulkVisibility } from '/js/components/app-bulk-selection.js';
import { confirmDialog } from '/js/core/app-confirm-dialog.js';
import { postForm } from '/js/core/app-http.js';
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-handovers');
if (config) {
const gridjs = window.gridjs;
if (!gridjs) {
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed: Grid.js missing', { module: 'helpdesk-handovers', component: 'grid' });
} else {
const appBase = getAppBase();
createListPageModule({
configId: 'helpdesk-handovers',
moduleId: 'helpdesk-handovers',
missingGridMessage: 'Helpdesk handovers grid init failed: Grid.js missing',
initErrorMessage: 'Helpdesk handovers grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const labels = config.labels || {};
const canManage = config.canManage || false;
@@ -100,7 +98,7 @@ if (config) {
};
}
const { gridConfig } = initStandardListPage({
const listResult = initListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
@@ -109,56 +107,51 @@ if (config) {
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed', { module: 'helpdesk-handovers', component: 'grid' });
const gridConfig = listResult?.gridConfig || null;
if (!canManage || !gridConfig) {
return gridConfig;
}
if (canManage && gridConfig) {
bindBulkVisibility({
containerSelector: '#helpdesk-handovers-grid',
buttonSelector: '[data-handovers-bulk]',
});
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 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();
}
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 data = await postForm(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), body);
if (data?.ok !== true) {
throw new Error('Bulk delete failed');
}
gridConfig.selection?.clear?.();
gridConfig.grid?.forceRender();
} catch {
window.location.reload();
}
});
}
}
}
});
return gridConfig;
},
});

View File

@@ -1,73 +1,66 @@
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 { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, 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();
createListPageModule({
configId: 'helpdesk-search',
moduleId: 'helpdesk-search',
missingGridMessage: 'Helpdesk search grid init failed: Grid.js missing',
initErrorMessage: 'Helpdesk search grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
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>`);
const { gridConfig } = initListPage({
grid: {
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 || '',
},
{ 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' });
}
}
}
return gridConfig;
},
});

View File

@@ -1,81 +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';
import { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-software-products');
if (config) {
const gridjs = window.gridjs;
if (!gridjs) {
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed: Grid.js missing', { module: 'helpdesk-software-products', component: 'grid' });
} else {
const appBase = getAppBase();
createListPageModule({
configId: 'helpdesk-software-products',
moduleId: 'helpdesk-software-products',
missingGridMessage: 'Helpdesk software products grid init failed: Grid.js missing',
initErrorMessage: 'Helpdesk software products grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const labels = config.labels || {};
const editUrlIndex = 4;
const gridOptions = {
gridjs,
container: '#helpdesk-software-products-grid',
dataUrl: config.dataUrl || 'helpdesk/software-products-data',
appBase,
columns: [
{
name: labels.code || 'Code',
sort: true,
formatter: (cell) => {
const code = escapeHtml(cell?.code || '');
const editUrl = String(cell?.url || '').trim();
if (editUrl === '') {
return gridjs.html(code);
}
const href = escapeHtml(withCurrentListReturn(editUrl));
return gridjs.html(`<a href="${href}">${code}</a>`);
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#helpdesk-software-products-grid',
dataUrl: config.dataUrl || 'helpdesk/software-products-data',
appBase,
columns: [
{
name: labels.code || 'Code',
sort: true,
formatter: (cell) => {
const code = escapeHtml(cell?.code || '');
const editUrl = String(cell?.url || '').trim();
if (editUrl === '') {
return gridjs.html(code);
}
const href = escapeHtml(withCurrentListReturn(editUrl));
return gridjs.html(`<a href="${href}">${code}</a>`);
},
},
},
{ name: labels.bcDescription || 'BC Description', sort: true },
{ name: labels.name || 'Product Name', sort: true },
{
name: labels.status || 'Status',
sort: true,
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.bcDescription || 'BC Description', sort: true },
{ name: labels.name || 'Product Name', sort: true },
{
name: labels.status || 'Status',
sort: true,
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: 'edit_url', hidden: true },
],
sortColumns: ['code', 'bc_description', 'name', 'active', null],
paginationLimit: 20,
language: config.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
{ code: row.code || '', url: row.edit_url || '' },
row.bc_description || '',
row.name || '',
{ label: row.active_label || '', variant: row.active_variant || 'neutral' },
row.edit_url || '',
]),
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
},
{ name: 'edit_url', hidden: true },
],
sortColumns: ['code', 'bc_description', 'name', 'active', null],
paginationLimit: 20,
language: config.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
{ code: row.code || '', url: row.edit_url || '' },
row.bc_description || '',
row.name || '',
{ label: row.active_label || '', variant: row.active_variant || 'neutral' },
row.edit_url || '',
]),
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-software-products-search-input'],
},
});
}) || {};
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed', { module: 'helpdesk-software-products', component: 'grid' });
}
}
}
return gridConfig;
},
});

View File

@@ -1,138 +1,132 @@
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';
import { showAsyncFlash } from '/js/components/app-async-flash.js';
import { getJson, postForm } from '/js/core/app-http.js';
import { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml } 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();
createListPageModule({
configId: 'helpdesk-updates',
moduleId: 'helpdesk-updates',
missingGridMessage: 'Helpdesk updates grid init failed: Grid.js missing',
initErrorMessage: 'Helpdesk updates grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
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' },
const listResult = initListPage({
grid: {
gridjs,
container: '#helpdesk-updates-grid',
dataUrl: config.dataUrl || 'helpdesk/updates-data',
appBase,
columns: [
{
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',
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>`);
},
},
{ label: row.category_label || '', variant: row.category_variant || 'neutral' },
{ name: row.debitor_name || '', url: row.debitor_url || '' },
row.domain_url || '',
row.created_on || '',
]);
{
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 = '';
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>`;
}
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 || [];
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,
},
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
@@ -140,28 +134,23 @@ if (config) {
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed', { module: 'helpdesk-updates', component: 'grid' });
const gridConfig = listResult?.gridConfig || null;
if (!gridConfig) {
return null;
}
// 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)
await getJson(refreshUrl.toString());
gridConfig?.grid?.forceRender();
} catch {
// Ignore — grid will still show last data
showAsyncFlash('error', labels.refreshError || labels.assignError || 'Refresh failed');
} finally {
refreshButton.disabled = false;
refreshButton.removeAttribute('aria-busy');
@@ -169,157 +158,155 @@ if (config) {
});
}
// 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');
}
});
}
if (!canManage) {
return gridConfig;
}
}
}
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 || !domainSelect || !domainUrlInput || !submitButton) {
return gridConfig;
}
const closeDialog = () => {
try { dialog.close(); } catch { /* no-op */ }
document.body.classList.remove('modal-is-open');
};
dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
closeDialog();
});
});
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
closeDialog();
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
closeDialog();
}
});
domainSelect.addEventListener('change', () => {
const selected = domainSelect.options[domainSelect.selectedIndex];
domainUrlInput.value = selected?.dataset?.url || '';
});
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 domain of domains) {
const opt = document.createElement('option');
opt.value = domain.value || '';
opt.textContent = domain.label || domain.value || '';
opt.dataset.url = domain.url || '';
if (preselectedNo && domain.value === preselectedNo) {
opt.selected = true;
domainUrlInput.value = domain.url || '';
}
domainSelect.appendChild(opt);
}
}
domainSelect.disabled = false;
};
document.addEventListener('click', async (event) => {
const button = event.target.closest('[data-update-assign]');
if (!button) {
return;
}
let data;
try {
data = JSON.parse(button.dataset.updateAssign);
} catch {
return;
}
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} - ${data.debitor_name} - ${categoryDisplay}`;
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 domains = await getJson(domainsUrl.toString());
populateDomainSelect(domains, data.domain_no);
} catch {
populateDomainSelect([], null);
}
});
form.addEventListener('submit', async (event) => {
event.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 result = await postForm(new URL(config.assignUrl, appBase).toString(), formData);
if (result?.ok === true) {
closeDialog();
gridConfig?.grid?.forceRender();
showAsyncFlash('success', labels.assignSuccess || labels.saved || 'Saved');
} else {
const errorMsg = result?.errors?.general || labels.assignError || 'Failed to assign';
showAsyncFlash('error', errorMsg);
}
} catch (error) {
const errorMsg = error?.payload?.errors?.general || labels.assignError || 'Failed to assign';
showAsyncFlash('error', errorMsg);
} finally {
submitButton.disabled = false;
submitButton.removeAttribute('aria-busy');
}
});
return gridConfig;
},
});