feat(js): add app-http contracts and migrate helpdesk runtime layer
This commit is contained in:
@@ -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' => [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user