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

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

101
bin/js-contract-check.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/.." && pwd)"
cd "${repo_root}"
if ! command -v rg >/dev/null 2>&1; then
echo "[ERROR] rg is required for js-contract-check." >&2
exit 2
fi
failures=0
ok() {
echo "[OK] $*"
}
fail() {
echo "[FAIL] $*" >&2
failures=$((failures + 1))
}
alert_hits="$(rg -n "window\\.alert\\s*\\(" web/js modules/helpdesk/web/js -g '*.js' || true)"
if [[ -n "${alert_hits}" ]]; then
fail "window.alert usage detected:"
echo "${alert_hits}" >&2
else
ok "No window.alert usage in web/js and modules/helpdesk/web/js"
fi
fetch_hits="$(rg -n "\\bfetch\\s*\\(" modules/helpdesk/web/js -g '*.js' || true)"
if [[ -n "${fetch_hits}" ]]; then
fail "Direct fetch(...) usage detected in helpdesk JS:"
echo "${fetch_hits}" >&2
else
ok "Helpdesk JS uses centralized app-http (no direct fetch)"
fi
runtime_files=(
"modules/helpdesk/web/js/helpdesk-detail.js"
"modules/helpdesk/web/js/helpdesk-domain-detail.js"
"modules/helpdesk/web/js/helpdesk-ticket.js"
"modules/helpdesk/web/js/helpdesk-team.js"
"modules/helpdesk/web/js/helpdesk-risk-radar.js"
"modules/helpdesk/web/js/helpdesk-settings.js"
"modules/helpdesk/web/js/handover-domain-select.js"
"modules/helpdesk/web/js/handover-schema-editor.js"
"web/js/components/app-lookup-field.js"
)
for file in "${runtime_files[@]}"; do
if [[ ! -f "${file}" ]]; then
fail "Missing runtime file: ${file}"
continue
fi
if rg -q "DOMContentLoaded|document\\.readyState" "${file}"; then
fail "${file}: lifecycle auto-init pattern found"
fi
if rg -q "const container = document\\.querySelector" "${file}"; then
fail "${file}: top-level container auto-init pattern found"
fi
done
ok "Runtime files are free of sideeffect auto-init patterns"
import_violations=0
while IFS= read -r line; do
file="${line%%:*}"
import_path="$(printf '%s' "${line}" | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/")"
if [[ "${import_path}" == /js/* ]]; then
continue
fi
if [[ "${import_path}" == ./* || "${import_path}" == ../* ]]; then
if printf '%s' "${import_path}" | rg -q "(^|/)js/(core|components|pages)/"; then
echo "${file}: relative core import forbidden -> ${import_path}" >&2
import_violations=$((import_violations + 1))
fi
continue
fi
echo "${file}: invalid import path -> ${import_path}" >&2
import_violations=$((import_violations + 1))
done < <(rg -n "^\\s*import\\s+(?:[^'\\\"]+\\s+from\\s+)?['\\\"][^'\\\"]+['\\\"]" modules/helpdesk/web/js -g '*.js')
if [[ "${import_violations}" -gt 0 ]]; then
fail "Helpdesk import policy violations: ${import_violations}"
else
ok "Helpdesk import policy valid (core imports absolute /js/...; local imports relative)"
fi
if [[ "${failures}" -gt 0 ]]; then
echo "[FAIL] JS contract check failed with ${failures} issue(s)." >&2
exit 1
fi
echo "[OK] JS contract check passed."

View File

@@ -44,6 +44,9 @@ run_step "QG-008 Docs drift contract" \
run_step "CSS contract check" \ run_step "CSS contract check" \
bin/css-contract-check.sh bin/css-contract-check.sh
run_step "JS contract check" \
bin/js-contract-check.sh
run_step "QG-009 Codex skills sync" \ run_step "QG-009 Codex skills sync" \
bin/codex-skills-sync.sh --check bin/codex-skills-sync.sh --check

View File

@@ -77,6 +77,96 @@ return [
'order' => 800, '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' => [ 'authorization_policies' => [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,34 @@
/** /**
* Risk Radar — customer portfolio risk view. * 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 dataUrl = container.dataset.riskRadarUrl;
const debitorBaseUrl = container.dataset.debitorBaseUrl || ''; const debitorBaseUrl = container.dataset.debitorBaseUrl || '';
const d = (key, fallback) => container.dataset[key] || fallback; const d = (key, fallback) => container.dataset[key] || fallback;
@@ -22,35 +47,29 @@ if (container) {
const dialogClose = dialog ? dialog.querySelector('.close') : null; const dialogClose = dialog ? dialog.querySelector('.close') : null;
let currentPeriod = 90; let currentPeriod = 90;
let lastData = null;
function showState(state) { const showState = (state) => {
if (elLoading) elLoading.hidden = state !== 'loading'; if (elLoading) elLoading.hidden = state !== 'loading';
if (elError) elError.hidden = state !== 'error'; if (elError) elError.hidden = state !== 'error';
if (elEmpty) elEmpty.hidden = state !== 'empty'; if (elEmpty) elEmpty.hidden = state !== 'empty';
if (elContent) elContent.hidden = state !== 'success'; if (elContent) elContent.hidden = state !== 'success';
} };
function h(tag, cls, text) { const h = (tag, cls, text) => {
const e = document.createElement(tag); const element = document.createElement(tag);
if (cls) e.className = cls; if (cls) element.className = cls;
if (text !== undefined) e.textContent = text; if (text !== undefined) element.textContent = text;
return e; return element;
} };
function levelClass(level) { const levelClass = (level) => `helpdesk-risk-level-${level}`;
return 'helpdesk-risk-level-' + level;
}
function formatAge(hours) { const formatAge = (hours) => {
if (hours < 24) return hours + 'h'; if (hours < 24) return `${hours}h`;
return Math.floor(hours / 24) + 'd'; return `${Math.floor(hours / 24)}d`;
} };
/** const dimLabel = (id) => {
* Dimension label lookup from data attributes.
*/
function dimLabel(id) {
const map = { const map = {
'open_pressure': d('labelOpenPressure', 'Open pressure'), 'open_pressure': d('labelOpenPressure', 'Open pressure'),
'trend': d('labelTrend', 'Trend'), 'trend': d('labelTrend', 'Trend'),
@@ -58,57 +77,16 @@ if (container) {
'inactivity': d('labelInactivity', 'Inactivity'), 'inactivity': d('labelInactivity', 'Inactivity'),
}; };
return map[id] || id; return map[id] || id;
};
const openDetail = (customer) => {
if (!dialog || !dialogTitle || !dialogBody) {
return;
} }
/** dialogTitle.textContent = `${customer.customer_name || customer.customer_no}${customer.risk_score}/100`;
* 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(' · ')));
}
// 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';
dialogBody.replaceChildren(); dialogBody.replaceChildren();
// Dimensions as points breakdown: "18 / 35"
const dims = customer.dimensions || []; const dims = customer.dimensions || [];
const dimTable = h('table', 'helpdesk-risk-detail-tickets'); const dimTable = h('table', 'helpdesk-risk-detail-tickets');
const dimHead = h('thead'); const dimHead = h('thead');
@@ -122,7 +100,7 @@ if (container) {
const tr = h('tr'); const tr = h('tr');
tr.appendChild(h('td', '', dimLabel(dim.id))); tr.appendChild(h('td', '', dimLabel(dim.id)));
const points = Math.round(dim.weighted_points || 0); 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); dimBody.appendChild(tr);
} }
const totalTr = h('tr'); const totalTr = h('tr');
@@ -132,16 +110,15 @@ if (container) {
totalTr.appendChild(totalLabel); totalTr.appendChild(totalLabel);
const totalVal = h('td', ''); const totalVal = h('td', '');
totalVal.style.fontWeight = '700'; totalVal.style.fontWeight = '700';
totalVal.textContent = customer.risk_score + ' / 100'; totalVal.textContent = `${customer.risk_score} / 100`;
totalTr.appendChild(totalVal); totalTr.appendChild(totalVal);
dimBody.appendChild(totalTr); dimBody.appendChild(totalTr);
dimTable.appendChild(dimBody); dimTable.appendChild(dimBody);
dialogBody.appendChild(dimTable); dialogBody.appendChild(dimTable);
// Open tickets list
const tickets = customer.open_tickets || []; const tickets = customer.open_tickets || [];
if (tickets.length > 0) { 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 table = h('table', 'helpdesk-risk-detail-tickets');
const thead = h('thead'); const thead = h('thead');
const headRow = h('tr'); const headRow = h('tr');
@@ -152,19 +129,18 @@ if (container) {
thead.appendChild(headRow); thead.appendChild(headRow);
table.appendChild(thead); table.appendChild(thead);
const tbody = h('tbody'); const tbody = h('tbody');
for (const t of tickets) { for (const ticket of tickets) {
const tr = h('tr', t.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : ''); const tr = h('tr', ticket.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : '');
tr.appendChild(h('td', '', t.no)); tr.appendChild(h('td', '', ticket.no));
tr.appendChild(h('td', '', t.escalation_code || '—')); tr.appendChild(h('td', '', ticket.escalation_code || '—'));
tr.appendChild(h('td', '', t.state)); tr.appendChild(h('td', '', ticket.state));
tr.appendChild(h('td', '', formatAge(t.age_hours))); tr.appendChild(h('td', '', formatAge(ticket.age_hours)));
tbody.appendChild(tr); tbody.appendChild(tr);
} }
table.appendChild(tbody); table.appendChild(tbody);
dialogBody.appendChild(table); dialogBody.appendChild(table);
} }
// Link to debitor
if (debitorBaseUrl && customer.customer_no) { if (debitorBaseUrl && customer.customer_no) {
const link = h('a', 'helpdesk-risk-detail-link', customer.customer_name || customer.customer_no); const link = h('a', 'helpdesk-risk-detail-link', customer.customer_name || customer.customer_no);
link.href = debitorBaseUrl + encodeURIComponent(customer.customer_no); link.href = debitorBaseUrl + encodeURIComponent(customer.customer_no);
@@ -174,19 +150,70 @@ if (container) {
} }
dialog.showModal(); dialog.showModal();
};
const buildCard = (customer) => {
const card = h('article', `helpdesk-risk-card ${levelClass(customer.risk_level)}`);
card.setAttribute('aria-label', customer.customer_name || customer.customer_no);
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(' · ')));
} }
// Dialog close handlers card.tabIndex = 0;
if (dialogClose) dialogClose.addEventListener('click', () => dialog.close()); on(card, 'click', () => openDetail(customer));
if (dialog) dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }); on(card, 'keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openDetail(customer);
}
});
function renderCards(customers) { return card;
if (!elCards) return; };
on(dialogClose, 'click', () => {
dialog?.close();
});
on(dialog, 'click', (event) => {
if (event.target === dialog) {
dialog.close();
}
});
const renderCards = (customers) => {
if (!elCards) {
return;
}
elCards.replaceChildren(); 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'); showState('loading');
if (elTruncated) elTruncated.hidden = true; if (elTruncated) elTruncated.hidden = true;
@@ -194,38 +221,48 @@ if (container) {
if (refresh) params.set('refresh', '1'); if (refresh) params.set('refresh', '1');
try { try {
const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' }); const json = await getJson(`${dataUrl}?${params.toString()}`, {
const json = await res.json(); signal: requestController.signal,
});
if (!json.ok) { showState('error'); return; } if (!json.ok) { showState('error'); return; }
if (!json.customers || json.customers.length === 0) { showState('empty'); return; } if (!json.customers || json.customers.length === 0) { showState('empty'); return; }
lastData = json;
renderCards(json.customers); renderCards(json.customers);
showState('success'); showState('success');
if (json.meta && json.meta.truncated && elTruncated) { if (json.meta && json.meta.truncated && elTruncated) {
elTruncated.hidden = false; elTruncated.hidden = false;
} }
} catch { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showState('error'); showState('error');
} }
} };
if (periodSelector) { on(periodSelector, 'change', (event) => {
periodSelector.addEventListener('change', (e) => { const radio = event.target.closest('input[name="radar-period"]');
const radio = e.target.closest('input[name="radar-period"]');
if (!radio) return; if (!radio) return;
const period = parseInt(radio.value, 10); const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return; if (!period || period === currentPeriod) return;
currentPeriod = period; currentPeriod = period;
fetchData(); void fetchData();
}); });
}
if (elRefresh) { on(elRefresh, 'click', () => {
elRefresh.addEventListener('click', () => fetchData(true)); void fetchData(true);
} });
fetchData(); void fetchData();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
if (dialog?.open) {
dialog.close();
}
},
};
} }

View File

@@ -2,21 +2,82 @@
* Helpdesk settings page — auth mode toggle and connection test. * Helpdesk settings page — auth mode toggle and connection test.
*/ */
import { showAsyncFlash } from '/js/components/app-async-flash.js'; 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) => { const resolveContainer = (root) => {
if (!settingsForm || !settingsForm.dataset) { const host = resolveHost(root);
return fallback; 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"]');
return settingsForm.dataset[key] || fallback;
}; };
// Toggle OAuth2 vs Basic Auth field visibility const readMessage = (form, key, fallback) => {
const authModeSelect = document.getElementById('auth_mode'); if (!form || !form.dataset) {
const basicFields = document.getElementById('helpdesk-basic-auth-fields'); return fallback;
const oauth2Fields = document.getElementById('helpdesk-oauth2-fields'); }
return form.dataset[key] || fallback;
};
export function initHelpdeskSettings(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
const settingsForm = container.querySelector('#helpdesk-settings-form');
if (!settingsForm) {
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 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 = () => { const toggleAuthFields = () => {
if (!authModeSelect || !basicFields || !oauth2Fields) { if (!authModeSelect || !basicFields || !oauth2Fields) {
@@ -28,107 +89,56 @@ const toggleAuthFields = () => {
oauth2Fields.toggleAttribute('hidden', !isOAuth2); oauth2Fields.toggleAttribute('hidden', !isOAuth2);
}; };
if (authModeSelect) { on(authModeSelect, 'change', toggleAuthFields);
authModeSelect.addEventListener('change', toggleAuthFields);
toggleAuthFields(); toggleAuthFields();
}
// Connection test button on(overrideToggle, 'change', () => {
const testButton = document.getElementById('helpdesk-test-connection-button'); if (tenantFields) {
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; tenantFields.hidden = !overrideToggle.checked;
});
} }
});
// On form submit, set save_target based on active tab on(settingsForm, 'submit', () => {
if (settingsForm && saveTargetInput) { if (!saveTargetInput) {
settingsForm.addEventListener('submit', () => { return;
}
const tenantPanel = settingsForm.querySelector('[data-tab-panel="tenant"]:not([hidden])'); const tenantPanel = settingsForm.querySelector('[data-tab-panel="tenant"]:not([hidden])');
saveTargetInput.value = tenantPanel ? 'tenant' : 'global'; saveTargetInput.value = tenantPanel ? 'tenant' : 'global';
}); });
}
// Tenant auth mode toggle on(tenantAuthModeSelect, 'change', () => {
if (tenantAuthModeSelect && tenantBasicFields && tenantOauth2Fields) { if (!tenantBasicFields || !tenantOauth2Fields || !tenantAuthModeSelect) {
tenantAuthModeSelect.addEventListener('change', () => { return;
}
const isOAuth2 = tenantAuthModeSelect.value === 'oauth2'; const isOAuth2 = tenantAuthModeSelect.value === 'oauth2';
tenantBasicFields.hidden = isOAuth2; tenantBasicFields.hidden = isOAuth2;
tenantOauth2Fields.hidden = !isOAuth2; tenantOauth2Fields.hidden = !isOAuth2;
}); });
}
if (testButton) { on(testButton, 'click', async () => {
testButton.addEventListener('click', async () => {
setButtonLoadingState(true); setButtonLoadingState(true);
setTestResult('info', getMessage('testLoadingLabel', 'Testing...')); setTestResult('info', readMessage(settingsForm, 'testLoadingLabel', 'Testing...'));
try { try {
const csrfInput = settingsForm.querySelector('input[name="csrf_token"]');
const csrfMeta = document.querySelector('meta[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 csrfToken = csrfInput?.value || csrfMeta?.content || '';
const formData = new FormData(); const body = new URLSearchParams();
if (csrfToken) { 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, new URL('helpdesk/settings/test-connection-data', document.baseURI).href,
{ body,
method: 'POST', { signal: requestController.signal }
body: formData,
credentials: 'same-origin',
},
); );
const data = await parseJsonSafely(response); const successMessage = readMessage(settingsForm, 'testSuccessMessage', 'Connection successful');
const successMessage = getMessage('testSuccessMessage', 'Connection successful'); const defaultErrorMessage = readMessage(settingsForm, 'testErrorMessage', 'Connection failed');
const defaultErrorMessage = getMessage('testErrorMessage', 'Connection failed');
if (response.ok && data?.ok) { if (data?.ok === true) {
const message = data.message || successMessage; const message = data.message || successMessage;
setTestResult('success', message); setTestResult('success', message);
showAsyncFlash('success', message); showAsyncFlash('success', message);
@@ -136,23 +146,36 @@ if (testButton) {
let message = defaultErrorMessage; let message = defaultErrorMessage;
if (data?.error) { if (data?.error) {
message = data.error; message = data.error;
} else if (!response.ok) {
message = `${defaultErrorMessage} (HTTP ${response.status})`;
} }
if (data?.debug?.http_code) { if (data?.debug?.http_code) {
message += ` (HTTP ${data.debug.http_code})`; message += ` (HTTP ${data.debug.http_code})`;
} }
setTestResult('warning', message); setTestResult('warning', message);
showAsyncFlash('error', message); showAsyncFlash('error', message);
} }
} catch { } catch (error) {
const message = getMessage('testErrorMessage', 'Connection failed'); 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); setTestResult('warning', message);
showAsyncFlash('error', message); showAsyncFlash('error', message);
} finally { } finally {
setButtonLoadingState(false); setButtonLoadingState(false);
} }
}); });
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
} }

View File

@@ -3,9 +3,34 @@
* Current tab: per-agent widget with section title + ticket table. * Current tab: per-agent widget with section title + ticket table.
* Performance tab: compact agent rows with resolved counts. * 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 dataUrl = container.dataset.teamWorkloadUrl;
const labelQueue = container.dataset.labelQueue || 'Queue'; const labelQueue = container.dataset.labelQueue || 'Queue';
const labelTickets = container.dataset.labelTickets || 'Tickets'; const labelTickets = container.dataset.labelTickets || 'Tickets';
@@ -202,7 +227,7 @@ if (container) {
for (const item of items) { for (const item of items) {
const li = h('li', 'helpdesk-team-perf-ranked-clickable'); const li = h('li', 'helpdesk-team-perf-ranked-clickable');
li.addEventListener('click', () => { on(li, 'click', () => {
if (activeItem === li) { if (activeItem === li) {
activeItem.classList.remove('active'); activeItem.classList.remove('active');
activeItem = null; activeItem = null;
@@ -363,8 +388,9 @@ if (container) {
if (refresh) params.set('refresh', '1'); if (refresh) params.set('refresh', '1');
try { try {
const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' }); const json = await getJson(`${dataUrl}?${params.toString()}`, {
const json = await res.json(); signal: requestController.signal,
});
if (!json.ok) { showState('error'); return; } if (!json.ok) { showState('error'); return; }
if (!json.members || json.members.length === 0) { showState('empty'); return; } if (!json.members || json.members.length === 0) { showState('empty'); return; }
@@ -372,25 +398,31 @@ if (container) {
renderCurrentTab(json.members); renderCurrentTab(json.members);
renderPerformanceTab(json.members); renderPerformanceTab(json.members);
showState('success'); showState('success');
} catch { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showState('error'); showState('error');
} }
} }
if (periodSelector) { on(periodSelector, 'change', (e) => {
periodSelector.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="team-period"]'); const radio = e.target.closest('input[name="team-period"]');
if (!radio) return; if (!radio) return;
const period = parseInt(radio.value, 10); const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return; if (!period || period === currentPeriod) return;
currentPeriod = period; currentPeriod = period;
fetchData(); void fetchData();
}); });
}
if (elRefresh) { on(elRefresh, 'click', () => void fetchData(true));
elRefresh.addEventListener('click', () => fetchData(true));
}
fetchData(); void fetchData();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
} }

View File

@@ -1,66 +1,82 @@
/** /**
* Helpdesk ticket detail page — async communication aside. * 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 { import {
renderCommunicationError, renderCommunicationError,
renderCommunicationFeed, renderCommunicationFeed,
renderCommunicationHint, renderCommunicationHint,
} from './helpdesk-communication.js'; } from './helpdesk-communication.js';
const container = document.querySelector('.app-details-container[data-ticket-no]'); const EMPTY_API = Object.freeze({ destroy: () => {} });
if (container) {
init(container); 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 ticketNo = container.dataset.ticketNo || '';
const communicationUrl = container.dataset.ticketCommunicationUrl || ''; const communicationUrl = container.dataset.ticketCommunicationUrl || '';
const fileDownloadUrl = container.dataset.fileDownloadUrl || ''; 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 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; const t = (key) => i18n[key] || key;
function showLoading(id) { const toggleHidden = (id, hidden) => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.hidden = false; if (el) {
el.hidden = hidden;
} }
};
function hideLoading(id) { const loadCommunication = async (refresh = false) => {
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 feedEl = document.getElementById('ticket-communication-feed'); const feedEl = document.getElementById('ticket-communication-feed');
const loadingEl = document.getElementById('ticket-communication-loading'); const loadingEl = document.getElementById('ticket-communication-loading');
const contentEl = document.getElementById('ticket-communication-content'); const contentEl = document.getElementById('ticket-communication-content');
if (!feedEl || !loadingEl || !contentEl) return; if (!feedEl || !loadingEl || !contentEl) {
return;
}
if (contentEl) contentEl.hidden = true; contentEl.hidden = true;
showLoading('ticket-communication-loading'); loadingEl.hidden = false;
try { try {
const params = new URLSearchParams({ ticketNo }); const params = new URLSearchParams({ ticketNo });
if (refresh) params.set('refresh', '1'); if (refresh) {
const response = await fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' }); params.set('refresh', '1');
const data = await response.json(); }
hideLoading('ticket-communication-loading'); const data = await getJson(`${communicationUrl}?${params.toString()}`, {
showContent('ticket-communication-content'); 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.')); feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
return; return;
} }
@@ -82,17 +98,30 @@ function init(container) {
}); });
feedEl.innerHTML = html; feedEl.innerHTML = html;
} catch { } catch (error) {
hideLoading('ticket-communication-loading'); if (error instanceof Error && error.name === 'AbortError') {
showContent('ticket-communication-content'); 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.')); feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
} }
} }
};
const refreshButton = container.querySelector('button[name="ticket-refresh"]'); const refreshButton = container.querySelector('button[name="ticket-refresh"]');
if (refreshButton) { on(refreshButton, 'click', () => {
refreshButton.addEventListener('click', () => loadCommunication(true)); void loadCommunication(true);
} });
loadCommunication(); void loadCommunication();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
} }

View File

@@ -1,21 +1,18 @@
import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { createListPageModule } from '/js/core/app-list-page-module.js';
import { readPageConfig } from '/js/core/app-page-config.js'; import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
import { warnOnce } from '/js/core/app-dom.js';
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-domains'); createListPageModule({
if (config) { configId: 'helpdesk-domains',
const gridjs = window.gridjs; moduleId: 'helpdesk-domains',
if (!gridjs) { missingGridMessage: 'Helpdesk domains grid init failed: Grid.js missing',
warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed: Grid.js missing', { module: 'helpdesk-domains', component: 'grid' }); initErrorMessage: 'Helpdesk domains grid init failed',
} else { setup: ({ config, gridjs, appBase, initListPage }) => {
const appBase = getAppBase();
const labels = config.labels || {}; const labels = config.labels || {};
const domainBaseUrl = config.domainBaseUrl || ''; const domainBaseUrl = config.domainBaseUrl || '';
const domainUrlIndex = 7; const domainUrlIndex = 7;
const gridOptions = { const { gridConfig } = initListPage({
grid: {
gridjs, gridjs,
container: '#helpdesk-domains-grid', container: '#helpdesk-domains-grid',
dataUrl: config.dataUrl || 'helpdesk/domains-data', dataUrl: config.dataUrl || 'helpdesk/domains-data',
@@ -87,19 +84,14 @@ if (config) {
rowDblClick: { rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '', getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '',
}, },
}; },
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: { filters: {
mode: 'drawer', mode: 'drawer',
chipMeta: config.filterChipMeta || {}, chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-domains-search-input'], watchInputs: ['#helpdesk-domains-search-input'],
}, },
}); }) || {};
if (!gridConfig || !gridConfig.grid) { return gridConfig;
warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed', { module: 'helpdesk-domains', component: 'grid' }); },
} });
}
}

View File

@@ -1,17 +1,15 @@
import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { createListPageModule } from '/js/core/app-list-page-module.js';
import { readPageConfig } from '/js/core/app-page-config.js';
import { warnOnce } from '/js/core/app-dom.js';
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
import { bindBulkVisibility } from '/js/components/app-bulk-selection.js'; import { bindBulkVisibility } from '/js/components/app-bulk-selection.js';
import { confirmDialog } from '/js/core/app-confirm-dialog.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'); createListPageModule({
if (config) { configId: 'helpdesk-handovers',
const gridjs = window.gridjs; moduleId: 'helpdesk-handovers',
if (!gridjs) { missingGridMessage: 'Helpdesk handovers grid init failed: Grid.js missing',
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed: Grid.js missing', { module: 'helpdesk-handovers', component: 'grid' }); initErrorMessage: 'Helpdesk handovers grid init failed',
} else { setup: ({ config, gridjs, appBase, initListPage }) => {
const appBase = getAppBase();
const labels = config.labels || {}; const labels = config.labels || {};
const canManage = config.canManage || false; const canManage = config.canManage || false;
@@ -100,7 +98,7 @@ if (config) {
}; };
} }
const { gridConfig } = initStandardListPage({ const listResult = initListPage({
grid: gridOptions, grid: gridOptions,
filters: { filters: {
mode: 'drawer', mode: 'drawer',
@@ -109,11 +107,11 @@ if (config) {
}, },
}); });
if (!gridConfig || !gridConfig.grid) { const gridConfig = listResult?.gridConfig || null;
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed', { module: 'helpdesk-handovers', component: 'grid' }); if (!canManage || !gridConfig) {
return gridConfig;
} }
if (canManage && gridConfig) {
bindBulkVisibility({ bindBulkVisibility({
containerSelector: '#helpdesk-handovers-grid', containerSelector: '#helpdesk-handovers-grid',
buttonSelector: '[data-handovers-bulk]', buttonSelector: '[data-handovers-bulk]',
@@ -124,13 +122,17 @@ if (config) {
button.addEventListener('click', async () => { button.addEventListener('click', async () => {
const action = button.dataset.handoversBulk || ''; const action = button.dataset.handoversBulk || '';
const ids = gridConfig.selection?.getSelectedIds?.() || []; const ids = gridConfig.selection?.getSelectedIds?.() || [];
if (!ids.length || action !== 'delete') return; if (!ids.length || action !== 'delete') {
return;
}
const confirmed = await confirmDialog.confirm({ const confirmed = await confirmDialog.confirm({
message: labels.bulkDeleteConfirm || 'Delete selected handovers?', message: labels.bulkDeleteConfirm || 'Delete selected handovers?',
actionKind: 'delete', actionKind: 'delete',
}); });
if (!confirmed) return; if (!confirmed) {
return;
}
const body = new URLSearchParams({ const body = new URLSearchParams({
ids: ids.join(','), ids: ids.join(','),
@@ -138,27 +140,18 @@ if (config) {
}); });
try { try {
const res = await fetch(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), { const data = await postForm(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), body);
method: 'POST', if (data?.ok !== true) {
headers: { throw new Error('Bulk delete failed');
'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.selection?.clear?.();
gridConfig.grid?.forceRender(); gridConfig.grid?.forceRender();
} else {
window.location.reload();
}
} catch { } catch {
window.location.reload(); window.location.reload();
} }
}); });
}); });
}
} return gridConfig;
} },
});

View File

@@ -1,18 +1,16 @@
import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { createListPageModule } from '/js/core/app-list-page-module.js';
import { readPageConfig } from '/js/core/app-page-config.js'; import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
import { warnOnce } from '/js/core/app-dom.js';
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-search'); createListPageModule({
if (config) { configId: 'helpdesk-search',
const gridjs = window.gridjs; moduleId: 'helpdesk-search',
if (!gridjs) { missingGridMessage: 'Helpdesk search grid init failed: Grid.js missing',
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed: Grid.js missing', { module: 'helpdesk-search', component: 'grid' }); initErrorMessage: 'Helpdesk search grid init failed',
} else { setup: ({ config, gridjs, appBase, initListPage }) => {
const appBase = getAppBase();
const labels = config.labels || {}; const labels = config.labels || {};
const gridOptions = { const { gridConfig } = initListPage({
grid: {
gridjs, gridjs,
container: '#helpdesk-search-grid', container: '#helpdesk-search-grid',
dataUrl: config.dataUrl || 'helpdesk/search-data', dataUrl: config.dataUrl || 'helpdesk/search-data',
@@ -55,19 +53,14 @@ if (config) {
rowDblClick: { rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[5]?.data || '', getUrl: (rowData) => rowData?.cells?.[5]?.data || '',
}, },
}; },
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: { filters: {
mode: 'drawer', mode: 'drawer',
chipMeta: config.filterChipMeta || {}, chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-search-input'], watchInputs: ['#helpdesk-search-input'],
}, },
}); }) || {};
if (!gridConfig || !gridConfig.grid) { return gridConfig;
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed', { module: 'helpdesk-search', component: 'grid' }); },
} });
}
}

View File

@@ -1,20 +1,17 @@
import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { createListPageModule } from '/js/core/app-list-page-module.js';
import { readPageConfig } from '/js/core/app-page-config.js'; import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
import { warnOnce } from '/js/core/app-dom.js';
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-software-products'); createListPageModule({
if (config) { configId: 'helpdesk-software-products',
const gridjs = window.gridjs; moduleId: 'helpdesk-software-products',
if (!gridjs) { missingGridMessage: 'Helpdesk software products grid init failed: Grid.js missing',
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed: Grid.js missing', { module: 'helpdesk-software-products', component: 'grid' }); initErrorMessage: 'Helpdesk software products grid init failed',
} else { setup: ({ config, gridjs, appBase, initListPage }) => {
const appBase = getAppBase();
const labels = config.labels || {}; const labels = config.labels || {};
const editUrlIndex = 4; const editUrlIndex = 4;
const gridOptions = { const { gridConfig } = initListPage({
grid: {
gridjs, gridjs,
container: '#helpdesk-software-products-grid', container: '#helpdesk-software-products-grid',
dataUrl: config.dataUrl || 'helpdesk/software-products-data', dataUrl: config.dataUrl || 'helpdesk/software-products-data',
@@ -63,19 +60,14 @@ if (config) {
rowDblClick: { rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '', getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
}, },
}; },
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: { filters: {
mode: 'drawer', mode: 'drawer',
chipMeta: config.filterChipMeta || {}, chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-software-products-search-input'], watchInputs: ['#helpdesk-software-products-search-input'],
}, },
}); }) || {};
if (!gridConfig || !gridConfig.grid) { return gridConfig;
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed', { module: 'helpdesk-software-products', component: 'grid' }); },
} });
}
}

View File

@@ -1,19 +1,19 @@
import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { showAsyncFlash } from '/js/components/app-async-flash.js';
import { readPageConfig } from '/js/core/app-page-config.js'; import { getJson, postForm } from '/js/core/app-http.js';
import { warnOnce } from '/js/core/app-dom.js'; import { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, getAppBase } from '/js/pages/app-list-utils.js'; import { escapeHtml } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-updates'); createListPageModule({
if (config) { configId: 'helpdesk-updates',
const gridjs = window.gridjs; moduleId: 'helpdesk-updates',
if (!gridjs) { missingGridMessage: 'Helpdesk updates grid init failed: Grid.js missing',
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed: Grid.js missing', { module: 'helpdesk-updates', component: 'grid' }); initErrorMessage: 'Helpdesk updates grid init failed',
} else { setup: ({ config, gridjs, appBase, initListPage }) => {
const appBase = getAppBase();
const labels = config.labels || {}; const labels = config.labels || {};
const canManage = config.canManage || false; const canManage = config.canManage || false;
const gridOptions = { const listResult = initListPage({
grid: {
gridjs, gridjs,
container: '#helpdesk-updates-grid', container: '#helpdesk-updates-grid',
dataUrl: config.dataUrl || 'helpdesk/updates-data', dataUrl: config.dataUrl || 'helpdesk/updates-data',
@@ -56,7 +56,6 @@ if (config) {
formatter: (cell) => { formatter: (cell) => {
if (!cell) return ''; if (!cell) return '';
let buttons = ''; let buttons = '';
// Assign/Edit button (only if user can manage)
if (canManage) { if (canManage) {
// raw-html-ok: all values passed through escapeHtml, cell data is from our own API // raw-html-ok: all values passed through escapeHtml, cell data is from our own API
const data = escapeHtml(JSON.stringify(cell)); const data = escapeHtml(JSON.stringify(cell));
@@ -66,7 +65,6 @@ if (config) {
const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil'; 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>`; 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(); const giteaUrl = String(cell.gitea_path || '').trim();
if (giteaUrl !== '') { if (giteaUrl !== '') {
const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea'); const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea');
@@ -104,7 +102,6 @@ if (config) {
language: config.gridLang || {}, language: config.gridLang || {},
mapData: (data) => { mapData: (data) => {
const rows = data.rows || data.data || []; const rows = data.rows || data.data || [];
// Column order: Ticket | Ticket-Status | Assignment | Actions | Type | Customer | Domain | Created
return rows.map((row) => [ return rows.map((row) => [
{ no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' }, { 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.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' },
@@ -129,10 +126,7 @@ if (config) {
search: config.gridSearch || null, search: config.gridSearch || null,
filterSchema: config.filterSchema || [], filterSchema: config.filterSchema || [],
urlSync: true, urlSync: true,
}; },
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: { filters: {
mode: 'drawer', mode: 'drawer',
chipMeta: config.filterChipMeta || {}, chipMeta: config.filterChipMeta || {},
@@ -140,28 +134,23 @@ if (config) {
}, },
}); });
if (!gridConfig || !gridConfig.grid) { const gridConfig = listResult?.gridConfig || null;
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed', { module: 'helpdesk-updates', component: 'grid' }); if (!gridConfig) {
return null;
} }
// Refresh button — bypasses session cache and reloads grid data from BC
const refreshButton = document.getElementById('updates-refresh-button'); const refreshButton = document.getElementById('updates-refresh-button');
if (refreshButton) { if (refreshButton) {
refreshButton.addEventListener('click', async () => { refreshButton.addEventListener('click', async () => {
refreshButton.disabled = true; refreshButton.disabled = true;
refreshButton.setAttribute('aria-busy', 'true'); refreshButton.setAttribute('aria-busy', 'true');
try { try {
// Ping the data endpoint with refresh=1 to invalidate the cache
const refreshUrl = new URL(config.dataUrl, appBase); const refreshUrl = new URL(config.dataUrl, appBase);
refreshUrl.searchParams.set('refresh', '1'); refreshUrl.searchParams.set('refresh', '1');
await fetch(refreshUrl.toString(), { await getJson(refreshUrl.toString());
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
});
// Now force the grid to re-render (next fetch will get fresh data)
gridConfig?.grid?.forceRender(); gridConfig?.grid?.forceRender();
} catch { } catch {
// Ignore — grid will still show last data showAsyncFlash('error', labels.refreshError || labels.assignError || 'Refresh failed');
} finally { } finally {
refreshButton.disabled = false; refreshButton.disabled = false;
refreshButton.removeAttribute('aria-busy'); refreshButton.removeAttribute('aria-busy');
@@ -169,8 +158,10 @@ if (config) {
}); });
} }
// Assignment dialog logic if (!canManage) {
if (canManage) { return gridConfig;
}
const dialog = document.getElementById('update-assign-dialog'); const dialog = document.getElementById('update-assign-dialog');
const form = document.getElementById('update-assign-form'); const form = document.getElementById('update-assign-form');
const contextEl = document.getElementById('update-assign-context'); const contextEl = document.getElementById('update-assign-context');
@@ -184,29 +175,36 @@ if (config) {
const domainUrlInput = document.getElementById('update-assign-domain-url'); const domainUrlInput = document.getElementById('update-assign-domain-url');
const submitButton = document.getElementById('update-assign-submit'); const submitButton = document.getElementById('update-assign-submit');
if (dialog && form) { if (!dialog || !form || !domainSelect || !domainUrlInput || !submitButton) {
return gridConfig;
}
const closeDialog = () => { const closeDialog = () => {
try { dialog.close(); } catch { /* no-op */ } try { dialog.close(); } catch { /* no-op */ }
document.body.classList.remove('modal-is-open'); document.body.classList.remove('modal-is-open');
}; };
// Close buttons
dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => { dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => {
button.addEventListener('click', (e) => { e.preventDefault(); closeDialog(); }); button.addEventListener('click', (event) => {
event.preventDefault();
closeDialog();
});
});
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
closeDialog();
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
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', () => { domainSelect.addEventListener('change', () => {
const selected = domainSelect.options[domainSelect.selectedIndex]; const selected = domainSelect.options[domainSelect.selectedIndex];
domainUrlInput.value = selected?.dataset?.url || ''; 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) => { const populateDomainSelect = (domains, preselectedNo) => {
domainSelect.textContent = ''; domainSelect.textContent = '';
if (!Array.isArray(domains) || domains.length === 0) { if (!Array.isArray(domains) || domains.length === 0) {
@@ -219,14 +217,14 @@ if (config) {
placeholder.value = ''; placeholder.value = '';
placeholder.textContent = labels.selectDomain || 'Select domain...'; placeholder.textContent = labels.selectDomain || 'Select domain...';
domainSelect.appendChild(placeholder); domainSelect.appendChild(placeholder);
for (const d of domains) { for (const domain of domains) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.value || ''; opt.value = domain.value || '';
opt.textContent = d.label || d.value || ''; opt.textContent = domain.label || domain.value || '';
opt.dataset.url = d.url || ''; opt.dataset.url = domain.url || '';
if (preselectedNo && d.value === preselectedNo) { if (preselectedNo && domain.value === preselectedNo) {
opt.selected = true; opt.selected = true;
domainUrlInput.value = d.url || ''; domainUrlInput.value = domain.url || '';
} }
domainSelect.appendChild(opt); domainSelect.appendChild(opt);
} }
@@ -234,15 +232,19 @@ if (config) {
domainSelect.disabled = false; domainSelect.disabled = false;
}; };
// Open dialog on assign button click (delegated) document.addEventListener('click', async (event) => {
document.addEventListener('click', async (e) => { const button = event.target.closest('[data-update-assign]');
const button = e.target.closest('[data-update-assign]'); if (!button) {
if (!button) return; return;
}
let data; let data;
try { data = JSON.parse(button.dataset.updateAssign); } catch { return; } try {
data = JSON.parse(button.dataset.updateAssign);
} catch {
return;
}
// Populate form
ticketNoInput.value = data.ticket_no || ''; ticketNoInput.value = data.ticket_no || '';
debitorNoInput.value = data.debitor_no || ''; debitorNoInput.value = data.debitor_no || '';
debitorNameInput.value = data.debitor_name || ''; debitorNameInput.value = data.debitor_name || '';
@@ -254,9 +256,8 @@ if (config) {
const categoryDisplay = data.category_code === 'UPDATE-HF' const categoryDisplay = data.category_code === 'UPDATE-HF'
? (labels.hotfix || 'Hotfix') ? (labels.hotfix || 'Hotfix')
: (labels.update || 'Update'); : (labels.update || 'Update');
contextEl.textContent = `${data.ticket_no} \u00b7 ${data.debitor_name} \u00b7 ${categoryDisplay}`; contextEl.textContent = `${data.ticket_no} - ${data.debitor_name} - ${categoryDisplay}`;
// Show loading state in domain select
domainSelect.textContent = ''; domainSelect.textContent = '';
const loadingOpt = document.createElement('option'); const loadingOpt = document.createElement('option');
loadingOpt.value = ''; loadingOpt.value = '';
@@ -270,20 +271,15 @@ if (config) {
try { try {
const domainsUrl = new URL(config.domainsDataUrl, appBase); const domainsUrl = new URL(config.domainsDataUrl, appBase);
domainsUrl.searchParams.set('debitor_no', data.debitor_no); domainsUrl.searchParams.set('debitor_no', data.debitor_no);
const res = await fetch(domainsUrl.toString(), { const domains = await getJson(domainsUrl.toString());
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
});
const domains = await res.json();
populateDomainSelect(domains, data.domain_no); populateDomainSelect(domains, data.domain_no);
} catch { } catch {
populateDomainSelect([], null); populateDomainSelect([], null);
} }
}); });
// Form submit form.addEventListener('submit', async (event) => {
form.addEventListener('submit', async (e) => { event.preventDefault();
e.preventDefault();
submitButton.disabled = true; submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true'); submitButton.setAttribute('aria-busy', 'true');
@@ -293,33 +289,24 @@ if (config) {
} }
try { try {
const res = await fetch(new URL(config.assignUrl, appBase).toString(), { const result = await postForm(new URL(config.assignUrl, appBase).toString(), formData);
method: 'POST', if (result?.ok === true) {
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(); closeDialog();
gridConfig?.grid?.forceRender(); gridConfig?.grid?.forceRender();
showAsyncFlash('success', labels.assignSuccess || labels.saved || 'Saved');
} else { } else {
const errorMsg = result?.errors?.general || labels.assignError || 'Failed to assign'; const errorMsg = result?.errors?.general || labels.assignError || 'Failed to assign';
window.alert(errorMsg); showAsyncFlash('error', errorMsg);
} }
} catch { } catch (error) {
window.alert(labels.assignError || 'Failed to assign'); const errorMsg = error?.payload?.errors?.general || labels.assignError || 'Failed to assign';
showAsyncFlash('error', errorMsg);
} finally { } finally {
submitButton.disabled = false; submitButton.disabled = false;
submitButton.removeAttribute('aria-busy'); submitButton.removeAttribute('aria-busy');
} }
}); });
}
} return gridConfig;
} },
} });

View File

@@ -0,0 +1,116 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class FrontendJsContractsTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testNoWindowAlertCallsInCoreAndHelpdeskJs(): void
{
$violations = array_merge(
$this->findPatternMatchesInFiles('web/js', '/window\.alert\s*\(/', ['js']),
$this->findPatternMatchesInFiles('modules/helpdesk/web/js', '/window\.alert\s*\(/', ['js'])
);
$violations = array_values(array_unique($violations));
sort($violations);
$this->assertSame([], $violations, "window.alert usage found in JS scope:\n" . implode("\n", $violations));
}
public function testHelpdeskRuntimeComponentsDoNotAutoInitialize(): void
{
foreach ($this->helpdeskRuntimeComponentFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString('DOMContentLoaded', $content, $file);
$this->assertStringNotContainsString("if (document.readyState === 'loading')", $content, $file);
$this->assertStringNotContainsString('const container = document.querySelector', $content, $file);
}
}
public function testHelpdeskRuntimeComponentsExposeDestroyLifecycle(): void
{
foreach ($this->helpdeskRuntimeComponentFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('destroy:', $content, $file);
}
}
public function testHelpdeskJsUsesCentralHttpLayer(): void
{
$violations = $this->findPatternMatchesInFiles('modules/helpdesk/web/js', '/\bfetch\s*\(/', ['js']);
$this->assertSame([], $violations, "Direct fetch(...) found in helpdesk JS:\n" . implode("\n", $violations));
}
public function testHelpdeskImportPolicyForCoreAndSiblingModules(): void
{
$root = $this->projectRootPath();
$base = $root . '/modules/helpdesk/web/js';
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
$violations = [];
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (!$file->isFile() || $file->getExtension() !== 'js') {
continue;
}
$content = file_get_contents($file->getPathname());
$this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname());
$relativePath = str_replace($root . '/', '', $file->getPathname());
preg_match_all('/^\s*import\s+(?:[^\"\']+\s+from\s+)?[\"\']([^\"\']+)[\"\']/m', $content, $matches);
foreach ($matches[1] as $importPathRaw) {
$importPath = (string) $importPathRaw;
if (str_starts_with($importPath, '/js/')) {
continue;
}
if (str_starts_with($importPath, './') || str_starts_with($importPath, '../')) {
if (preg_match('#(?:^|/)js/(core|components|pages)/#', $importPath) === 1) {
$violations[] = $relativePath . ' => relative core import: ' . $importPath;
}
continue;
}
$violations[] = $relativePath . ' => invalid import path: ' . $importPath;
}
}
$violations = array_values(array_unique($violations));
sort($violations);
$this->assertSame([], $violations, "Helpdesk import policy violations:\n" . implode("\n", $violations));
}
public function testLookupFieldComponentExportsRuntimeInitializerWithoutAutoInit(): void
{
$content = $this->readProjectFile('web/js/components/app-lookup-field.js');
$this->assertStringContainsString('export function initLookupFields', $content);
$this->assertStringContainsString('destroy:', $content);
$this->assertStringNotContainsString('DOMContentLoaded', $content);
}
/**
* @return list<string>
*/
private function helpdeskRuntimeComponentFiles(): array
{
return [
'modules/helpdesk/web/js/helpdesk-detail.js',
'modules/helpdesk/web/js/helpdesk-domain-detail.js',
'modules/helpdesk/web/js/helpdesk-ticket.js',
'modules/helpdesk/web/js/helpdesk-team.js',
'modules/helpdesk/web/js/helpdesk-risk-radar.js',
'modules/helpdesk/web/js/helpdesk-settings.js',
'modules/helpdesk/web/js/handover-domain-select.js',
'modules/helpdesk/web/js/handover-schema-editor.js',
];
}
}

View File

@@ -32,8 +32,17 @@ trait FrontendRuntimeContractSupport
'web/js/components/app-auto-submit.js', 'web/js/components/app-auto-submit.js',
'modules/bookmarks/web/js/components/app-bookmark-save.js', 'modules/bookmarks/web/js/components/app-bookmark-save.js',
'modules/bookmarks/web/js/components/app-bookmark-panel.js', 'modules/bookmarks/web/js/components/app-bookmark-panel.js',
'web/js/components/app-lookup-field.js',
'web/js/components/app-password-toggle.js', 'web/js/components/app-password-toggle.js',
'web/js/components/app-page-editor.js', 'web/js/components/app-page-editor.js',
'modules/helpdesk/web/js/helpdesk-detail.js',
'modules/helpdesk/web/js/helpdesk-domain-detail.js',
'modules/helpdesk/web/js/helpdesk-ticket.js',
'modules/helpdesk/web/js/helpdesk-team.js',
'modules/helpdesk/web/js/helpdesk-risk-radar.js',
'modules/helpdesk/web/js/helpdesk-settings.js',
'modules/helpdesk/web/js/handover-domain-select.js',
'modules/helpdesk/web/js/handover-schema-editor.js',
]; ];
} }
} }

View File

@@ -19,7 +19,11 @@ class ListTemplateContractTest extends TestCase
$usesPartial || str_contains($content, 'renderGridFilterToolbar('), $usesPartial || str_contains($content, 'renderGridFilterToolbar('),
$file . ' does not render toolbar directly and does not include shared list-filters partial.' $file . ' does not render toolbar directly and does not include shared list-filters partial.'
); );
$this->assertStringContainsString('initStandardListPage(', $moduleContent, $file); $this->assertTrue(
str_contains($moduleContent, 'initStandardListPage(')
|| str_contains($moduleContent, 'createListPageModule('),
$file . ' does not initialize list module via initStandardListPage/createListPageModule.'
);
$this->assertStringNotContainsString('gridFiltersFromSchema(', $content, $file); $this->assertStringNotContainsString('gridFiltersFromSchema(', $content, $file);
$this->assertSame(0, preg_match('/<select id=\"[^\"]*-filter\"/', $content), $file); $this->assertSame(0, preg_match('/<select id=\"[^\"]*-filter\"/', $content), $file);
$this->assertSame(0, preg_match('/<input[^>]*id=\"[^\"]*-filter\"/', $content), $file); $this->assertSame(0, preg_match('/<input[^>]*id=\"[^\"]*-filter\"/', $content), $file);

View File

@@ -25,6 +25,7 @@ import { initTabs } from './components/app-tabs.js';
import { initSessionWarning } from './components/app-session-warning.js'; import { initSessionWarning } from './components/app-session-warning.js';
import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js'; import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js';
import { initFileUpload } from './components/app-file-upload.js'; import { initFileUpload } from './components/app-file-upload.js';
import { initLookupFields } from './components/app-lookup-field.js';
const runtime = createComponentRuntime({ const runtime = createComponentRuntime({
root: document, root: document,
@@ -193,6 +194,13 @@ const bootRuntime = async () => {
selector: '[data-app-component="file-upload"]', selector: '[data-app-component="file-upload"]',
configPath: 'components.fileUpload', configPath: 'components.fileUpload',
}); });
runtime.register('lookup-field', initLookupFields, {
scope: 'global',
configPath: 'components.lookupField',
defaultConfig: {
selector: '[data-app-lookup]',
},
});
await mountModuleComponentsByPhase('early'); await mountModuleComponentsByPhase('early');
// Phase B: structure-defining components. // Phase B: structure-defining components.

View File

@@ -1,24 +1,19 @@
/** /**
* Generic autocomplete lookup field. * Generic autocomplete lookup field.
*
* Usage:
* <div data-app-lookup
* data-lookup-url="/helpdesk/debitor-lookup-data"
* data-lookup-min-chars="2"
* data-lookup-debounce="300"
* data-lookup-value-key="value"
* data-lookup-display-key="label"
* data-lookup-placeholder="Search...">
* <input type="hidden" name="debitor_no" data-lookup-value>
* <input type="hidden" name="debitor_name" data-lookup-display-value>
* </div>
*
* The component creates a visible text input, a dropdown, and indicator icons.
* On selection, it sets the hidden input values and fires a 'lookup:select' CustomEvent.
*/ */
import { getJson } from '../core/app-http.js';
import { resolveHost } from '../core/app-dom.js';
/** @param {HTMLElement} container */ const EMPTY_API = Object.freeze({ destroy: () => {} });
/**
* @param {HTMLElement} container
*/
export function initLookupField(container) { export function initLookupField(container) {
if (!(container instanceof HTMLElement)) {
return EMPTY_API;
}
const url = container.dataset.lookupUrl || ''; const url = container.dataset.lookupUrl || '';
const minChars = parseInt(container.dataset.lookupMinChars || '2', 10); const minChars = parseInt(container.dataset.lookupMinChars || '2', 10);
const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10); const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10);
@@ -28,10 +23,28 @@ export function initLookupField(container) {
const initialDisplay = container.dataset.lookupInitialDisplay || ''; const initialDisplay = container.dataset.lookupInitialDisplay || '';
const initialValue = container.dataset.lookupInitialValue || ''; const initialValue = container.dataset.lookupInitialValue || '';
if (!url) {
return EMPTY_API;
}
const hiddenValue = container.querySelector('[data-lookup-value]'); const hiddenValue = container.querySelector('[data-lookup-value]');
const hiddenDisplay = container.querySelector('[data-lookup-display-value]'); const hiddenDisplay = container.querySelector('[data-lookup-display-value]');
// Build DOM 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 cleanupNodes = [];
const clearNode = (el) => {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
};
container.classList.add('app-lookup-field'); container.classList.add('app-lookup-field');
const wrap = document.createElement('div'); const wrap = document.createElement('div');
@@ -59,15 +72,15 @@ export function initLookupField(container) {
container.appendChild(wrap); container.appendChild(wrap);
container.appendChild(dropdown); container.appendChild(dropdown);
cleanupNodes.push(wrap, dropdown);
// State
let results = []; let results = [];
let activeIndex = -1; let activeIndex = -1;
let debounceTimer = null; let debounceTimer = null;
let abortController = null; let blurTimer = null;
let selected = false; let selected = false;
let activeRequestController = null;
// Restore initial state
if (initialValue && initialDisplay) { if (initialValue && initialDisplay) {
input.value = initialDisplay; input.value = initialDisplay;
selected = true; selected = true;
@@ -75,34 +88,30 @@ export function initLookupField(container) {
showClear(); showClear();
} }
// ── Indicator helpers ──────────────────────────────────────────────
function showSpinner() { function showSpinner() {
clearElement(indicator); clearNode(indicator);
const spinner = document.createElement('div'); const spinner = document.createElement('div');
spinner.className = 'app-lookup-field-spinner'; spinner.className = 'app-lookup-field-spinner';
indicator.appendChild(spinner); indicator.appendChild(spinner);
} }
function showClear() { function showClear() {
clearElement(indicator); clearNode(indicator);
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.className = 'app-lookup-field-clear'; button.className = 'app-lookup-field-clear';
button.setAttribute('aria-label', 'Clear'); button.setAttribute('aria-label', 'Clear');
button.addEventListener('click', clearSelection);
const icon = document.createElement('i'); const icon = document.createElement('i');
icon.className = 'bi bi-x-lg'; icon.className = 'bi bi-x-lg';
button.appendChild(icon); button.appendChild(icon);
on(button, 'click', clearSelection);
indicator.appendChild(button); indicator.appendChild(button);
} }
function hideIndicator() { function hideIndicator() {
clearElement(indicator); clearNode(indicator);
} }
// ── Dropdown helpers ───────────────────────────────────────────────
function openDropdown() { function openDropdown() {
dropdown.hidden = false; dropdown.hidden = false;
input.setAttribute('aria-expanded', 'true'); input.setAttribute('aria-expanded', 'true');
@@ -114,67 +123,6 @@ export function initLookupField(container) {
activeIndex = -1; activeIndex = -1;
} }
function clearElement(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function renderResults(items) {
results = items;
activeIndex = -1;
clearElement(dropdown);
if (items.length === 0) {
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupEmptyText || 'No results';
dropdown.appendChild(msg);
openDropdown();
return;
}
items.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'app-lookup-field-option';
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.dataset.index = index;
const label = document.createElement('span');
label.className = 'app-lookup-field-option-label';
label.textContent = item[displayKey] || '';
li.appendChild(label);
if (item.meta) {
const meta = document.createElement('span');
meta.className = 'app-lookup-field-option-meta';
meta.textContent = item.meta;
li.appendChild(meta);
}
li.addEventListener('mousedown', (e) => {
e.preventDefault();
selectItem(index);
});
li.addEventListener('mouseenter', () => {
setActive(index);
});
dropdown.appendChild(li);
});
openDropdown();
}
function renderError() {
clearElement(dropdown);
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
dropdown.appendChild(msg);
openDropdown();
}
function setActive(index) { function setActive(index) {
const options = dropdown.querySelectorAll('.app-lookup-field-option'); const options = dropdown.querySelectorAll('.app-lookup-field-option');
options.forEach((opt, i) => { options.forEach((opt, i) => {
@@ -182,18 +130,17 @@ export function initLookupField(container) {
}); });
activeIndex = index; activeIndex = index;
// Scroll active into view
const active = options[index]; const active = options[index];
if (active) { if (active) {
active.scrollIntoView({ block: 'nearest' }); active.scrollIntoView({ block: 'nearest' });
} }
} }
// ── Selection ──────────────────────────────────────────────────────
function selectItem(index) { function selectItem(index) {
const item = results[index]; const item = results[index];
if (!item) return; if (!item) {
return;
}
input.value = item[displayKey] || ''; input.value = item[displayKey] || '';
input.dataset.lookupSelected = ''; input.dataset.lookupSelected = '';
@@ -225,38 +172,88 @@ export function initLookupField(container) {
container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true })); container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true }));
} }
// ── Fetch ────────────────────────────────────────────────────────── function renderResults(items) {
results = items;
activeIndex = -1;
clearNode(dropdown);
function fetchResults(query) { if (items.length === 0) {
if (abortController) abortController.abort(); const msg = document.createElement('li');
abortController = new AbortController(); msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupEmptyText || 'No results';
dropdown.appendChild(msg);
openDropdown();
return;
}
items.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'app-lookup-field-option';
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.dataset.index = String(index);
const label = document.createElement('span');
label.className = 'app-lookup-field-option-label';
label.textContent = item[displayKey] || '';
li.appendChild(label);
if (item.meta) {
const meta = document.createElement('span');
meta.className = 'app-lookup-field-option-meta';
meta.textContent = item.meta;
li.appendChild(meta);
}
on(li, 'mousedown', (event) => {
event.preventDefault();
selectItem(index);
});
on(li, 'mouseenter', () => {
setActive(index);
});
dropdown.appendChild(li);
});
openDropdown();
}
function renderError() {
clearNode(dropdown);
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
dropdown.appendChild(msg);
openDropdown();
}
async function fetchResults(query) {
if (activeRequestController) {
activeRequestController.abort();
}
activeRequestController = new AbortController();
showSpinner(); showSpinner();
try {
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
fetch(url + separator + 'q=' + encodeURIComponent(query), { const data = await getJson(`${url}${separator}q=${encodeURIComponent(query)}`, {
signal: abortController.signal, signal: activeRequestController.signal,
headers: { 'Accept': 'application/json' }, });
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => {
hideIndicator(); hideIndicator();
const items = Array.isArray(data) ? data : (data.data || []); const items = Array.isArray(data) ? data : (data?.data || []);
renderResults(items); renderResults(items);
}) } catch (error) {
.catch((err) => { if (error instanceof Error && error.name === 'AbortError') {
if (err.name === 'AbortError') return; return;
}
hideIndicator(); hideIndicator();
renderError(); renderError();
}); }
} }
// ── Input events ─────────────────────────────────────────────────── on(input, 'input', () => {
input.addEventListener('input', () => {
if (selected) { if (selected) {
selected = false; selected = false;
delete input.dataset.lookupSelected; delete input.dataset.lookupSelected;
@@ -271,59 +268,91 @@ export function initLookupField(container) {
return; return;
} }
clearTimeout(debounceTimer); window.clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchResults(query), debounceMs); debounceTimer = window.setTimeout(() => {
void fetchResults(query);
}, debounceMs);
}); });
input.addEventListener('keydown', (e) => { on(input, 'keydown', (event) => {
if (dropdown.hidden) return; if (dropdown.hidden) return;
const options = dropdown.querySelectorAll('.app-lookup-field-option'); const options = dropdown.querySelectorAll('.app-lookup-field-option');
if (!options.length) return; if (!options.length) return;
if (e.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
e.preventDefault(); event.preventDefault();
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0); setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
} else if (e.key === 'ArrowUp') { } else if (event.key === 'ArrowUp') {
e.preventDefault(); event.preventDefault();
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1); setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
} else if (e.key === 'Enter') { } else if (event.key === 'Enter') {
e.preventDefault(); event.preventDefault();
if (activeIndex >= 0) { if (activeIndex >= 0) {
selectItem(activeIndex); selectItem(activeIndex);
} }
} else if (e.key === 'Escape') { } else if (event.key === 'Escape') {
e.preventDefault(); event.preventDefault();
closeDropdown(); closeDropdown();
} }
}); });
input.addEventListener('focus', () => { on(input, 'focus', () => {
if (input.value.trim().length >= minChars && !selected && results.length > 0) { if (input.value.trim().length >= minChars && !selected && results.length > 0) {
openDropdown(); openDropdown();
} }
}); });
input.addEventListener('blur', () => { on(input, 'blur', () => {
// Delay to allow mousedown on option to fire first window.clearTimeout(blurTimer);
setTimeout(() => closeDropdown(), 150); blurTimer = window.setTimeout(() => closeDropdown(), 150);
}); });
return { input, clearSelection }; return {
input,
clearSelection,
destroy: () => {
listenerController.abort();
if (activeRequestController) {
activeRequestController.abort();
}
window.clearTimeout(debounceTimer);
window.clearTimeout(blurTimer);
cleanupNodes.forEach((node) => {
if (node.parentNode === container) {
container.removeChild(node);
}
});
container.classList.remove('app-lookup-field');
delete container.dataset.lookupInitialized;
},
};
} }
// ── Auto-init ────────────────────────────────────────────────────── export function initLookupFields(root = document, options = {}) {
const host = resolveHost(root);
const selector = String(options.selector || '[data-app-lookup]').trim() || '[data-app-lookup]';
const elements = Array.from(host.querySelectorAll(selector));
if (elements.length === 0) {
return EMPTY_API;
}
function initAll() { const instances = [];
document.querySelectorAll('[data-app-lookup]').forEach((el) => { elements.forEach((el) => {
if (el.dataset.lookupInitialized) return; if (el.dataset.lookupInitialized) {
return;
}
el.dataset.lookupInitialized = '1'; el.dataset.lookupInitialized = '1';
initLookupField(el); instances.push(initLookupField(el));
}); });
}
if (document.readyState === 'loading') { return {
document.addEventListener('DOMContentLoaded', initAll); destroy: () => {
} else { instances.forEach((instance) => {
initAll(); if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
},
};
} }

266
web/js/core/app-http.js Normal file
View File

@@ -0,0 +1,266 @@
import { telemetry } from './app-telemetry.js';
const JSON_ACCEPT = 'application/json';
const DEFAULT_HEADERS = Object.freeze({
'Accept': JSON_ACCEPT,
'X-Requested-With': 'fetch',
});
const toObject = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value;
};
const normalizeUrl = (value) => {
try {
return new URL(String(value || ''), window.location.href).toString();
} catch {
return String(value || '');
}
};
const normalizeRoute = (url) => {
try {
return new URL(url, window.location.href).pathname || '/';
} catch {
return '/';
}
};
const mergeHeaders = (baseHeaders, inputHeaders) => {
const headers = new Headers();
Object.entries(baseHeaders).forEach(([key, value]) => {
headers.set(key, value);
});
if (!inputHeaders) {
return headers;
}
if (inputHeaders instanceof Headers) {
inputHeaders.forEach((value, key) => headers.set(key, value));
return headers;
}
if (Array.isArray(inputHeaders)) {
inputHeaders.forEach((entry) => {
if (Array.isArray(entry) && entry.length >= 2) {
headers.set(String(entry[0]), String(entry[1]));
}
});
return headers;
}
if (typeof inputHeaders === 'object') {
Object.entries(inputHeaders).forEach(([key, value]) => {
if (value === null || value === undefined) {
return;
}
headers.set(String(key), String(value));
});
}
return headers;
};
const readJsonSafe = async (response) => {
const raw = await response.text();
if (raw.trim() === '') {
return null;
}
return JSON.parse(raw);
};
const messageFromPayload = (payload, fallback) => {
if (!payload || typeof payload !== 'object') {
return fallback;
}
if (typeof payload.message === 'string' && payload.message.trim() !== '') {
return payload.message.trim();
}
if (typeof payload.error === 'string' && payload.error.trim() !== '') {
return payload.error.trim();
}
if (payload.errors && typeof payload.errors === 'object') {
const general = payload.errors.general;
if (typeof general === 'string' && general.trim() !== '') {
return general.trim();
}
}
return fallback;
};
const normalizeMethod = (value, fallback = 'GET') => {
const method = String(value || fallback).trim().toUpperCase();
return method === '' ? fallback : method;
};
const buildHttpErrorMessage = (method, status, message) => {
if (status > 0) {
return `[${method}] HTTP ${status}: ${message}`;
}
return `[${method}] ${message}`;
};
export class HttpError extends Error {
constructor(details = {}) {
const method = normalizeMethod(details.method || 'GET');
const status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0;
const message = String(details.message || 'Request failed').trim() || 'Request failed';
super(buildHttpErrorMessage(method, status, message));
this.name = 'HttpError';
this.status = status;
this.method = method;
this.url = normalizeUrl(details.url || '');
this.payload = details.payload ?? null;
this.cause = details.cause ?? null;
}
}
const emitHttpTelemetry = (error, source) => {
if (!(error instanceof HttpError)) {
return;
}
telemetry.capture('ajax_error', {
severity: error.status >= 500 || error.status === 0 ? 'error' : 'warning',
message: error.message,
meta: {
source,
module: 'app-http',
http_method: error.method,
http_status: error.status,
request_path: normalizeRoute(error.url),
error_code: error.status === 0 ? 'http_error_status_0' : `http_${error.status}`,
},
});
};
const toFormBody = (data) => {
if (data instanceof URLSearchParams) {
return data;
}
if (typeof FormData !== 'undefined' && data instanceof FormData) {
return new URLSearchParams(data);
}
if (data && typeof data === 'object') {
const body = new URLSearchParams();
Object.entries(data).forEach(([key, value]) => {
if (value === null || value === undefined) {
body.append(key, '');
} else {
body.append(key, String(value));
}
});
return body;
}
return new URLSearchParams();
};
const performRequest = async (url, options = {}) => {
const normalizedUrl = normalizeUrl(url);
const method = normalizeMethod(options.method || 'GET');
const headers = mergeHeaders(DEFAULT_HEADERS, options.headers || null);
const requestInit = {
...options,
method,
credentials: options.credentials || 'same-origin',
headers,
};
let response;
try {
response = await fetch(normalizedUrl, requestInit);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw error;
}
const httpError = new HttpError({
status: 0,
method,
url: normalizedUrl,
message: 'Network error',
payload: null,
cause: error,
});
emitHttpTelemetry(httpError, 'http.network');
throw httpError;
}
let payload = null;
try {
payload = await readJsonSafe(response);
} catch (error) {
const httpError = new HttpError({
status: 0,
method,
url: normalizedUrl,
message: 'Invalid JSON response',
payload: null,
cause: error,
});
emitHttpTelemetry(httpError, 'http.parse');
throw httpError;
}
if (!response.ok) {
const message = messageFromPayload(payload, response.statusText || 'Request failed');
const httpError = new HttpError({
status: response.status,
method,
url: normalizedUrl,
message,
payload,
});
emitHttpTelemetry(httpError, 'http.response');
throw httpError;
}
return payload;
};
export const getJson = (url, options = {}) => performRequest(url, {
...options,
method: 'GET',
});
export const postForm = (url, data, options = {}) => {
const body = toFormBody(data);
const headers = mergeHeaders({
...DEFAULT_HEADERS,
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
}, options.headers || null);
return performRequest(url, {
...options,
method: 'POST',
headers,
body,
});
};
export const postJson = (url, payload, options = {}) => {
const headers = mergeHeaders({
...DEFAULT_HEADERS,
'Content-Type': 'application/json;charset=UTF-8',
}, options.headers || null);
return performRequest(url, {
...options,
method: 'POST',
headers,
body: JSON.stringify(payload ?? {}),
});
};

View File

@@ -0,0 +1,76 @@
import { initStandardListPage } from './app-grid-factory.js';
import { warnOnce } from './app-dom.js';
import { readPageConfig } from './app-page-config.js';
import { getAppBase } from '../pages/app-list-utils.js';
const noop = () => {};
const defaultErrorMessage = (moduleId) => `${moduleId} grid init failed`;
export function createListPageModule(options = {}) {
const {
configId,
moduleId,
setup,
missingGridMessage = '',
initErrorMessage = '',
} = options;
const safeModuleId = String(moduleId || '').trim() || 'list-page';
const safeConfigId = String(configId || '').trim();
if (safeConfigId === '' || typeof setup !== 'function') {
warnOnce('UI_CONFIG_INVALID', 'createListPageModule requires configId and setup()', {
module: safeModuleId,
component: 'list-page-module',
});
return null;
}
const config = readPageConfig(safeConfigId);
if (!config) {
return null;
}
const gridjs = window.gridjs || null;
if (!gridjs) {
const fallbackMessage = missingGridMessage || `${safeModuleId} grid init failed: Grid.js missing`;
warnOnce('UI_INIT_FAIL', fallbackMessage, { module: safeModuleId, component: 'grid' });
return null;
}
const failInit = (error = null) => {
const message = initErrorMessage || defaultErrorMessage(safeModuleId);
warnOnce('UI_INIT_FAIL', message, {
module: safeModuleId,
component: 'grid',
error,
});
};
const initListPage = (pageOptions = {}) => {
const result = initStandardListPage(pageOptions);
const gridConfig = result?.gridConfig || null;
if (!gridConfig || !gridConfig.grid) {
failInit();
return null;
}
return {
gridConfig,
filterPanel: result?.filterPanel || null,
};
};
try {
return setup({
config,
gridjs,
appBase: getAppBase(),
initListPage,
failInit,
noop,
});
} catch (error) {
failInit(error);
return null;
}
}

View File

@@ -1,3 +1,5 @@
import { postForm as postFormRequest } from '../core/app-http.js';
export const buildUrl = (appBase, path) => new URL(path, appBase).toString(); export const buildUrl = (appBase, path) => new URL(path, appBase).toString();
export const getAppBase = () => { export const getAppBase = () => {
@@ -125,15 +127,7 @@ export const buildBadgeList = (items, options = {}) => {
export const postAction = async (url, csrf, data = {}) => { export const postAction = async (url, csrf, data = {}) => {
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token }); const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
return fetch(url, { return postFormRequest(url, body);
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'X-Requested-With': 'fetch'
},
body
});
}; };
export const badgeHtml = (gridjs, value = '') => { export const badgeHtml = (gridjs, value = '') => {

View File

@@ -1,5 +1,6 @@
import { confirmDialog } from '../core/app-confirm-dialog.js'; import { confirmDialog } from '../core/app-confirm-dialog.js';
import { warnOnce } from '../core/app-dom.js'; import { warnOnce } from '../core/app-dom.js';
import { postForm } from '../core/app-http.js';
export function initUsersListPage(options = {}) { export function initUsersListPage(options = {}) {
const { const {
@@ -29,15 +30,7 @@ export function initUsersListPage(options = {}) {
const postAction = async (url, data = {}) => { const postAction = async (url, data = {}) => {
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token }); const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
return fetch(url, { return postForm(url, body);
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'X-Requested-With': 'fetch'
},
body
});
}; };
const exportButton = document.querySelector(exportSelector); const exportButton = document.querySelector(exportSelector);
@@ -118,9 +111,11 @@ export function initUsersListPage(options = {}) {
const executeBulk = async () => { const executeBulk = async () => {
const url = resolveBulkUrl(action); const url = resolveBulkUrl(action);
const response = await postAction(url, { uuids: ids.join(',') }); try {
const data = await response.json().catch(() => null); const data = await postAction(url, { uuids: ids.join(',') });
if (response.ok && data && data.ok) { if (!data || data.ok !== true) {
throw new Error('Bulk action failed');
}
gridConfig.selection?.clear?.(); gridConfig.selection?.clear?.();
gridConfig.grid?.forceRender(); gridConfig.grid?.forceRender();
@@ -136,7 +131,7 @@ export function initUsersListPage(options = {}) {
showFlash('success', text); showFlash('success', text);
} }
} }
} else { } catch {
if (showFlash && labels.bulkMessages) { if (showFlash && labels.bulkMessages) {
const msg = labels.bulkMessages[action]; const msg = labels.bulkMessages[action];
if (msg?.error) { if (msg?.error) {