feat(js): add app-http contracts and migrate helpdesk runtime layer
This commit is contained in:
101
bin/js-contract-check.sh
Executable file
101
bin/js-contract-check.sh
Executable 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."
|
||||
@@ -44,6 +44,9 @@ run_step "QG-008 Docs drift contract" \
|
||||
run_step "CSS contract check" \
|
||||
bin/css-contract-check.sh
|
||||
|
||||
run_step "JS contract check" \
|
||||
bin/js-contract-check.sh
|
||||
|
||||
run_step "QG-009 Codex skills sync" \
|
||||
bin/codex-skills-sync.sh --check
|
||||
|
||||
|
||||
@@ -77,6 +77,96 @@ return [
|
||||
'order' => 800,
|
||||
],
|
||||
],
|
||||
'runtime.component' => [
|
||||
[
|
||||
'key' => 'helpdesk-detail',
|
||||
'script' => 'modules/helpdesk/js/helpdesk-detail.js',
|
||||
'export' => 'initHelpdeskDetail',
|
||||
'selector' => '[data-app-component="helpdesk-detail"]',
|
||||
'config_path' => 'components.helpdesk.detail',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.access',
|
||||
'order' => 801,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk-domain-detail',
|
||||
'script' => 'modules/helpdesk/js/helpdesk-domain-detail.js',
|
||||
'export' => 'initHelpdeskDomainDetail',
|
||||
'selector' => '[data-app-component="helpdesk-domain-detail"]',
|
||||
'config_path' => 'components.helpdesk.domainDetail',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.access',
|
||||
'order' => 802,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk-ticket',
|
||||
'script' => 'modules/helpdesk/js/helpdesk-ticket.js',
|
||||
'export' => 'initHelpdeskTicket',
|
||||
'selector' => '[data-app-component="helpdesk-ticket"]',
|
||||
'config_path' => 'components.helpdesk.ticket',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.access',
|
||||
'order' => 803,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk-team',
|
||||
'script' => 'modules/helpdesk/js/helpdesk-team.js',
|
||||
'export' => 'initHelpdeskTeam',
|
||||
'selector' => '[data-app-component="helpdesk-team"]',
|
||||
'config_path' => 'components.helpdesk.team',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.team-workload.view',
|
||||
'order' => 804,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk-risk-radar',
|
||||
'script' => 'modules/helpdesk/js/helpdesk-risk-radar.js',
|
||||
'export' => 'initHelpdeskRiskRadar',
|
||||
'selector' => '[data-app-component="helpdesk-risk-radar"]',
|
||||
'config_path' => 'components.helpdesk.riskRadar',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.risk-radar.view',
|
||||
'order' => 805,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk-settings',
|
||||
'script' => 'modules/helpdesk/js/helpdesk-settings.js',
|
||||
'export' => 'initHelpdeskSettings',
|
||||
'selector' => '[data-app-component="helpdesk-settings"]',
|
||||
'config_path' => 'components.helpdesk.settings',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.settings.manage',
|
||||
'order' => 806,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk-handover-domain-select',
|
||||
'script' => 'modules/helpdesk/js/handover-domain-select.js',
|
||||
'export' => 'initHandoverDomainSelect',
|
||||
'selector' => '#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]',
|
||||
'config_path' => 'components.helpdesk.handoverDomainSelect',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.handovers.create',
|
||||
'order' => 807,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk-handover-schema-editor',
|
||||
'script' => 'modules/helpdesk/js/handover-schema-editor.js',
|
||||
'export' => 'initHandoverSchemaEditor',
|
||||
'selector' => '#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]',
|
||||
'config_path' => 'components.helpdesk.handoverSchemaEditor',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'helpdesk.software-products.manage',
|
||||
'order' => 808,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'authorization_policies' => [
|
||||
|
||||
@@ -26,6 +26,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-detail"
|
||||
data-customer-no="<?php e($customerNo); ?>"
|
||||
data-customer-name="<?php e($customerName); ?>"
|
||||
data-support-dashboard-url="<?php e(lurl('helpdesk/debitor-support-dashboard-data')); ?>"
|
||||
@@ -522,4 +523,3 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
]); ?></script>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-detail.js')); ?>"></script>
|
||||
|
||||
@@ -32,6 +32,7 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral';
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-domain-detail"
|
||||
data-domain-no="<?php e($domainNo); ?>"
|
||||
data-customer-no="<?php e($customerNo); ?>"
|
||||
data-customer-name="<?php e($customerName); ?>"
|
||||
@@ -230,5 +231,4 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral';
|
||||
'Hotfix' => t('Hotfix'),
|
||||
]); ?></script>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-domain-detail.js')); ?>"></script>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -71,7 +71,7 @@ $steps = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-wizard-field-group" id="wizard-domain-group"
|
||||
<div class="app-wizard-field-group" id="wizard-domain-group" data-app-component="helpdesk-handover-domain-select"
|
||||
data-domains-url="<?php e(lurl('helpdesk/handover-domains-data')); ?>"
|
||||
data-text-initial="<?php e(t('Select a customer first...')); ?>"
|
||||
data-text-loading="<?php e(t('Loading domains...')); ?>"
|
||||
@@ -145,6 +145,3 @@ $steps = [
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('js/components/app-lookup-field.js')); ?>"></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/handover-domain-select.js')); ?>"></script>
|
||||
|
||||
@@ -13,6 +13,7 @@ $periodLabels = [
|
||||
];
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-risk-radar"
|
||||
data-risk-radar-url="<?php e(lurl('helpdesk/risk-radar-data')); ?>"
|
||||
data-debitor-base-url="<?php e(lurl('helpdesk/debitor/')); ?>"
|
||||
data-label-high="<?php e(t('High risk')); ?>"
|
||||
@@ -119,5 +120,3 @@ $periodLabels = [
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-risk-radar.js')); ?>"></script>
|
||||
|
||||
@@ -63,7 +63,7 @@ $tenantHasOAuthSecret = (bool) ($tenantHasOAuthSecret ?? false);
|
||||
$tenantAuthMode = trim((string) ($tenantRow['auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC));
|
||||
$tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<div class="app-details-container" data-app-component="helpdesk-settings">
|
||||
<section>
|
||||
<?php
|
||||
$titlebar = [
|
||||
@@ -488,5 +488,3 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-settings.js')); ?>"></script>
|
||||
|
||||
@@ -27,6 +27,7 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
|
||||
<div class="handover-schema-split-editor">
|
||||
<div
|
||||
id="handover-schema-editor"
|
||||
data-app-component="helpdesk-handover-schema-editor"
|
||||
data-initial-fields="<?php e(json_encode($handoverSchemaFields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); ?>"
|
||||
data-translations="<?php e(json_encode([
|
||||
'add_field' => t('Add field'),
|
||||
|
||||
@@ -87,4 +87,3 @@ $active = (int) ($values['active'] ?? 1);
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/handover-schema-editor.js')); ?>"></script>
|
||||
|
||||
@@ -15,6 +15,7 @@ $periodLabels = [
|
||||
];
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-team"
|
||||
data-team-workload-url="<?php e(lurl('helpdesk/team-workload-data')); ?>"
|
||||
data-label-not-assigned="<?php e(t('Not assigned')); ?>"
|
||||
data-label-queue="<?php e(t('Queue')); ?>"
|
||||
@@ -111,5 +112,3 @@ $periodLabels = [
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-team.js')); ?>"></script>
|
||||
|
||||
@@ -22,6 +22,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-ticket"
|
||||
data-ticket-no="<?php e($ticketNo); ?>"
|
||||
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
|
||||
data-file-download-url="<?php e(lurl('helpdesk/ticket-file-data')); ?>"
|
||||
@@ -158,5 +159,3 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
|
||||
'logged an activity' => t('logged an activity'),
|
||||
'on' => t('on'),
|
||||
]); ?></script>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-ticket.js')); ?>"></script>
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
/**
|
||||
* Dynamic domain select for handover wizard step 1.
|
||||
*
|
||||
* Reads config from data-* attributes on #wizard-domain-group:
|
||||
* data-domains-url, data-text-initial, data-text-loading,
|
||||
* data-text-select, data-text-empty, data-text-error,
|
||||
* data-initial-debitor, data-initial-domain
|
||||
*/
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
|
||||
const group = document.getElementById('wizard-domain-group');
|
||||
const lookupContainer = document.querySelector('[data-app-lookup]');
|
||||
const domainSelect = document.getElementById('wizard-domain');
|
||||
const domainUrlInput = document.getElementById('wizard-domain-url');
|
||||
const feedback = document.getElementById('wizard-domain-feedback');
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveGroup = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]');
|
||||
};
|
||||
|
||||
export function initHandoverDomainSelect(root = document) {
|
||||
const group = resolveGroup(root);
|
||||
if (!group) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const scope = group.closest('form') || document;
|
||||
const lookupContainer = scope.querySelector('[data-app-lookup]');
|
||||
const domainSelect = group.querySelector('#wizard-domain');
|
||||
const domainUrlInput = group.querySelector('#wizard-domain-url');
|
||||
const feedback = group.querySelector('#wizard-domain-feedback');
|
||||
if (!lookupContainer || !domainSelect) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
if (group && lookupContainer && domainSelect) {
|
||||
const domainsUrl = group.dataset.domainsUrl || '';
|
||||
const textInitial = group.dataset.textInitial || '';
|
||||
const textLoading = group.dataset.textLoading || '';
|
||||
@@ -21,35 +36,48 @@ if (group && lookupContainer && domainSelect) {
|
||||
const textEmpty = group.dataset.textEmpty || '';
|
||||
const textError = group.dataset.textError || '';
|
||||
|
||||
function clearOptions() {
|
||||
const listenerController = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const clearOptions = () => {
|
||||
while (domainSelect.options.length > 0) {
|
||||
domainSelect.remove(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function addPlaceholder(text) {
|
||||
const addPlaceholder = (text) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = text;
|
||||
domainSelect.appendChild(opt);
|
||||
}
|
||||
};
|
||||
|
||||
function setFeedback(text, variant) {
|
||||
if (!feedback) return;
|
||||
const setFeedback = (text, variant) => {
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
feedback.textContent = text;
|
||||
feedback.hidden = !text;
|
||||
feedback.dataset.variant = variant || '';
|
||||
}
|
||||
};
|
||||
|
||||
function resetDomain() {
|
||||
const resetDomain = () => {
|
||||
clearOptions();
|
||||
addPlaceholder(textInitial);
|
||||
domainSelect.disabled = true;
|
||||
if (domainUrlInput) domainUrlInput.value = '';
|
||||
if (domainUrlInput) {
|
||||
domainUrlInput.value = '';
|
||||
}
|
||||
setFeedback('', '');
|
||||
}
|
||||
};
|
||||
|
||||
async function loadDomains(debitorNo) {
|
||||
const loadDomains = async (debitorNo) => {
|
||||
clearOptions();
|
||||
addPlaceholder(textLoading);
|
||||
domainSelect.disabled = true;
|
||||
@@ -57,10 +85,9 @@ if (group && lookupContainer && domainSelect) {
|
||||
setFeedback('', '');
|
||||
|
||||
try {
|
||||
const res = await fetch(domainsUrl + '?debitor_no=' + encodeURIComponent(debitorNo), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
const data = await getJson(`${domainsUrl}?debitor_no=${encodeURIComponent(debitorNo)}`, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
const data = await res.json();
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
|
||||
clearOptions();
|
||||
@@ -72,7 +99,7 @@ if (group && lookupContainer && domainSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(function (item) {
|
||||
items.forEach((item) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.value || '';
|
||||
opt.textContent = item.label || item.value || '';
|
||||
@@ -80,45 +107,53 @@ if (group && lookupContainer && domainSelect) {
|
||||
domainSelect.appendChild(opt);
|
||||
});
|
||||
domainSelect.disabled = false;
|
||||
} catch {
|
||||
clearOptions();
|
||||
addPlaceholder(textSelect);
|
||||
domainSelect.disabled = true;
|
||||
setFeedback(textError, 'error');
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.name === 'AbortError')) {
|
||||
clearOptions();
|
||||
addPlaceholder(textSelect);
|
||||
domainSelect.disabled = true;
|
||||
setFeedback(textError, 'error');
|
||||
}
|
||||
} finally {
|
||||
group.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
domainSelect.addEventListener('change', function () {
|
||||
on(domainSelect, 'change', () => {
|
||||
const selected = domainSelect.options[domainSelect.selectedIndex];
|
||||
if (domainUrlInput) {
|
||||
domainUrlInput.value = selected?.dataset.url || '';
|
||||
}
|
||||
});
|
||||
|
||||
lookupContainer.addEventListener('lookup:select', function (e) {
|
||||
const debitorNo = e.detail?.value || '';
|
||||
on(lookupContainer, 'lookup:select', (event) => {
|
||||
const debitorNo = event.detail?.value || '';
|
||||
if (debitorNo) {
|
||||
loadDomains(debitorNo);
|
||||
void loadDomains(debitorNo);
|
||||
} else {
|
||||
resetDomain();
|
||||
}
|
||||
});
|
||||
|
||||
lookupContainer.addEventListener('lookup:clear', function () {
|
||||
on(lookupContainer, 'lookup:clear', () => {
|
||||
resetDomain();
|
||||
});
|
||||
|
||||
// Restore selection if returning to step 1 with session data
|
||||
const initialDebitor = group.dataset.initialDebitor || '';
|
||||
const initialDomain = group.dataset.initialDomain || '';
|
||||
if (initialDebitor) {
|
||||
loadDomains(initialDebitor).then(function () {
|
||||
void loadDomains(initialDebitor).then(() => {
|
||||
if (initialDomain && !domainSelect.disabled) {
|
||||
domainSelect.value = initialDomain;
|
||||
domainSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
requestController.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,15 +4,19 @@
|
||||
* Renders a structured field list from JSON and serializes changes
|
||||
* back to a hidden input on form submit.
|
||||
*/
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
|
||||
const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
|
||||
const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
const container = document.getElementById('handover-schema-editor');
|
||||
if (container) {
|
||||
init(container);
|
||||
}
|
||||
const resolveEditor = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]');
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove all child nodes from an element (safe alternative to setting markup).
|
||||
@@ -25,10 +29,19 @@ function clearElement(el) {
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
const listenerController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const t = JSON.parse(root.dataset.translations || '{}');
|
||||
const initialFields = JSON.parse(root.dataset.initialFields || '[]');
|
||||
const hiddenInput = document.getElementById('handover-schema-json');
|
||||
const previewContainer = document.getElementById('handover-schema-preview');
|
||||
const scope = root.closest('.app-details-container') || document;
|
||||
const hiddenInput = scope.querySelector('#handover-schema-json');
|
||||
const previewContainer = scope.querySelector('#handover-schema-preview');
|
||||
const form = hiddenInput?.closest('form');
|
||||
|
||||
/** @type {Array<object>} */
|
||||
@@ -38,9 +51,7 @@ function init(root) {
|
||||
renderPreview();
|
||||
syncHiddenInput();
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', () => syncHiddenInput());
|
||||
}
|
||||
on(form, 'submit', () => syncHiddenInput());
|
||||
|
||||
function render() {
|
||||
clearElement(root);
|
||||
@@ -56,7 +67,7 @@ function init(root) {
|
||||
addButton.className = 'secondary outline';
|
||||
addButton.appendChild(createIcon('bi-plus-lg'));
|
||||
addButton.appendChild(document.createTextNode(' ' + (t.add_field || 'Add field')));
|
||||
addButton.addEventListener('click', () => {
|
||||
on(addButton, 'click', () => {
|
||||
fields.push({ type: 'text', key: '', label: '', required: false, options: [] });
|
||||
render();
|
||||
notifyChange();
|
||||
@@ -86,7 +97,7 @@ function init(root) {
|
||||
button.setAttribute('data-tooltip', t.add_field || 'Add field');
|
||||
button.setAttribute('aria-label', t.add_field || 'Add field');
|
||||
button.appendChild(createIcon('bi-plus'));
|
||||
button.addEventListener('click', () => {
|
||||
on(button, 'click', () => {
|
||||
fields.splice(insertIndex, 0, { type: 'text', key: '', label: '', required: false, options: [] });
|
||||
render();
|
||||
notifyChange();
|
||||
@@ -102,8 +113,8 @@ function init(root) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'handover-schema-row';
|
||||
row.dataset.fieldIndex = index;
|
||||
row.addEventListener('mouseenter', () => highlightPreview(index, true));
|
||||
row.addEventListener('mouseleave', () => highlightPreview(index, false));
|
||||
on(row, 'mouseenter', () => highlightPreview(index, true));
|
||||
on(row, 'mouseleave', () => highlightPreview(index, false));
|
||||
|
||||
// --- Header: "Feld #N" title + action icons ---
|
||||
const header = document.createElement('div');
|
||||
@@ -162,7 +173,7 @@ function init(root) {
|
||||
if (ft === field.type) opt.selected = true;
|
||||
typeSelect.appendChild(opt);
|
||||
});
|
||||
typeSelect.addEventListener('change', () => {
|
||||
on(typeSelect, 'change', () => {
|
||||
field.type = typeSelect.value;
|
||||
if (DISPLAY_ONLY_TYPES.includes(field.type)) {
|
||||
delete field.key;
|
||||
@@ -194,7 +205,7 @@ function init(root) {
|
||||
}
|
||||
labelInput.value = field.label || '';
|
||||
labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1));
|
||||
labelInput.addEventListener('input', () => { field.label = labelInput.value; notifyChange(); });
|
||||
on(labelInput, 'input', () => { field.label = labelInput.value; notifyChange(); });
|
||||
labelGroup.appendChild(labelInput);
|
||||
body.appendChild(labelGroup);
|
||||
|
||||
@@ -209,7 +220,7 @@ function init(root) {
|
||||
const reqInput = document.createElement('input');
|
||||
reqInput.type = 'checkbox';
|
||||
reqInput.checked = !!field.required;
|
||||
reqInput.addEventListener('change', () => { field.required = reqInput.checked; notifyChange(); });
|
||||
on(reqInput, 'change', () => { field.required = reqInput.checked; notifyChange(); });
|
||||
reqLabel.appendChild(reqInput);
|
||||
reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required')));
|
||||
reqRow.appendChild(reqLabel);
|
||||
@@ -233,7 +244,7 @@ function init(root) {
|
||||
addOptButton.setAttribute('data-tooltip', t.add_option || 'Add option');
|
||||
addOptButton.setAttribute('aria-label', t.add_option || 'Add option');
|
||||
addOptButton.appendChild(createIcon('bi-plus-lg'));
|
||||
addOptButton.addEventListener('click', () => {
|
||||
on(addOptButton, 'click', () => {
|
||||
field.options.push({ value: '', label: '' });
|
||||
render();
|
||||
notifyChange();
|
||||
@@ -252,7 +263,7 @@ function init(root) {
|
||||
lblInput.value = opt.label || '';
|
||||
lblInput.placeholder = (t.option_label || 'Label') + ' ' + (optIndex + 1);
|
||||
lblInput.setAttribute('aria-label', (t.option_label || 'Label') + ' ' + (optIndex + 1));
|
||||
lblInput.addEventListener('input', () => { opt.label = lblInput.value; notifyChange(); });
|
||||
on(lblInput, 'input', () => { opt.label = lblInput.value; notifyChange(); });
|
||||
optRow.appendChild(lblInput);
|
||||
|
||||
optRow.appendChild(createIconButton(
|
||||
@@ -294,7 +305,7 @@ function init(root) {
|
||||
button.className = buttonClass;
|
||||
button.setAttribute('aria-label', ariaLabel);
|
||||
button.appendChild(createIcon(iconClass));
|
||||
button.addEventListener('click', onClick);
|
||||
on(button, 'click', onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -337,7 +348,7 @@ function init(root) {
|
||||
if (field.type === 'checkbox') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.addEventListener('click', (e) => e.preventDefault());
|
||||
on(input, 'click', (e) => e.preventDefault());
|
||||
group.appendChild(input);
|
||||
const label = document.createElement('label');
|
||||
label.textContent = field.label || '';
|
||||
@@ -408,4 +419,19 @@ function init(root) {
|
||||
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function initHandoverSchemaEditor(root = document) {
|
||||
const container = resolveEditor(root);
|
||||
if (!container) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
return init(container);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* - Aside: cross-ticket communication feed
|
||||
*/
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import {
|
||||
renderCommunicationError,
|
||||
@@ -15,12 +17,35 @@ import {
|
||||
} from './helpdesk-communication.js';
|
||||
import { renderEmptyState } from './helpdesk-empty-state.js';
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-customer-no]');
|
||||
if (container) {
|
||||
init(container);
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveContainer = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-detail"]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('.app-details-container[data-app-component="helpdesk-detail"]');
|
||||
};
|
||||
|
||||
export function initHelpdeskDetail(root = document) {
|
||||
const container = resolveContainer(root);
|
||||
if (!container) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
return init(container);
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
const listenerController = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const customerNo = container.dataset.customerNo || '';
|
||||
const customerName = container.dataset.customerName || '';
|
||||
const supportDashboardUrl = container.dataset.supportDashboardUrl || '';
|
||||
@@ -37,7 +62,9 @@ function init(container) {
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
})();
|
||||
|
||||
if (!customerNo || !customerName) return;
|
||||
if (!customerNo || !customerName) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const i18nEl = document.getElementById('helpdesk-i18n');
|
||||
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
|
||||
@@ -69,8 +96,9 @@ function init(container) {
|
||||
supportDashboardPromise = Promise.resolve({ ok: false, error: 'No support dashboard endpoint configured' });
|
||||
} else {
|
||||
const params = buildBaseParams();
|
||||
supportDashboardPromise = fetch(supportDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
supportDashboardPromise = getJson(`${supportDashboardUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
})
|
||||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||||
}
|
||||
}
|
||||
@@ -84,8 +112,9 @@ function init(container) {
|
||||
salesDashboardPromise = Promise.resolve({ ok: false, error: 'No sales dashboard endpoint configured' });
|
||||
} else {
|
||||
const params = buildBaseParams();
|
||||
salesDashboardPromise = fetch(salesDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
salesDashboardPromise = getJson(`${salesDashboardUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
})
|
||||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||||
}
|
||||
}
|
||||
@@ -100,8 +129,9 @@ function init(container) {
|
||||
} else {
|
||||
const params = new URLSearchParams({ customerNo });
|
||||
if (refreshFromUrl) params.set('refresh', '1');
|
||||
meetingsPromise = fetch(meetingsUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
meetingsPromise = getJson(`${meetingsUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
})
|
||||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||||
}
|
||||
}
|
||||
@@ -111,8 +141,9 @@ function init(container) {
|
||||
function fetchContacts() {
|
||||
if (!contactsPromise) {
|
||||
const params = buildBaseParams();
|
||||
contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
contactsPromise = getJson(`${contactsUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
})
|
||||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||||
}
|
||||
|
||||
@@ -134,8 +165,9 @@ function init(container) {
|
||||
url.searchParams.set('refresh', '1');
|
||||
}
|
||||
|
||||
ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
ticketCategoriesPromise = getJson(url.toString(), {
|
||||
signal: requestController.signal,
|
||||
})
|
||||
.catch(() => ({ ok: false, categories: [] }));
|
||||
}
|
||||
|
||||
@@ -148,8 +180,9 @@ function init(container) {
|
||||
debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
|
||||
} else {
|
||||
const params = buildBaseParams();
|
||||
debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
debitorCommunicationPromise = getJson(`${communicationUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
})
|
||||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||||
}
|
||||
}
|
||||
@@ -258,7 +291,9 @@ function init(container) {
|
||||
// Fetch type options from first data response
|
||||
const typeOptionsUrl = new URL(gridDataUrl.toString());
|
||||
typeOptionsUrl.searchParams.set('limit', '1');
|
||||
const typeResp = await fetch(typeOptionsUrl.toString()).then((r) => r.json()).catch(() => null);
|
||||
const typeResp = await getJson(typeOptionsUrl.toString(), {
|
||||
signal: requestController.signal,
|
||||
}).catch(() => null);
|
||||
const typeOptions = Array.isArray(typeResp?.type_options) ? typeResp.type_options : [];
|
||||
hydrateContactTypeFilter(contactsConfig, typeOptions);
|
||||
|
||||
@@ -556,7 +591,7 @@ function init(container) {
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', dialogHtml);
|
||||
const dialog = document.getElementById('helpdesk-contract-dialog');
|
||||
dialog.addEventListener('click', (e) => {
|
||||
on(dialog, 'click', (e) => {
|
||||
if (e.target.closest('[data-dialog-close]')) {
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
@@ -568,12 +603,12 @@ function init(container) {
|
||||
dialog.remove();
|
||||
}
|
||||
});
|
||||
dialog.addEventListener('close', () => { dialog.remove(); });
|
||||
on(dialog, 'close', () => { dialog.remove(); });
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// Delegate click on contract cards and timeline rows
|
||||
container.addEventListener('click', (e) => {
|
||||
on(container, 'click', (e) => {
|
||||
const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]');
|
||||
if (!target) return;
|
||||
const index = Number(target.dataset.contractIndex);
|
||||
@@ -583,7 +618,7 @@ function init(container) {
|
||||
});
|
||||
|
||||
// KPI tile click → scroll to ticket list and apply filter or sort
|
||||
container.addEventListener('click', (e) => {
|
||||
on(container, 'click', (e) => {
|
||||
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]');
|
||||
if (!kpi) return;
|
||||
|
||||
@@ -662,7 +697,7 @@ function init(container) {
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
on(container, 'click', (e) => {
|
||||
if (e.target.closest('a')) return;
|
||||
const row = e.target.closest('.app-clickable-row[data-href]');
|
||||
if (row) {
|
||||
@@ -673,7 +708,7 @@ function init(container) {
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('keydown', (e) => {
|
||||
on(container, 'keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const row = e.target.closest('.app-clickable-row[data-href]');
|
||||
if (row) {
|
||||
@@ -1280,8 +1315,7 @@ function init(container) {
|
||||
params.set('periodDays', String(periodDays));
|
||||
const url = controllingDashboardUrl + '?' + params.toString();
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
return await res.json();
|
||||
return await getJson(url, { signal: requestController.signal });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -1521,18 +1555,16 @@ function init(container) {
|
||||
|
||||
// Period selector (radio inputs)
|
||||
const periodSelector = document.getElementById('controlling-period-selector');
|
||||
if (periodSelector) {
|
||||
periodSelector.addEventListener('change', (e) => {
|
||||
const radio = e.target.closest('input[name="controlling-period"]');
|
||||
if (!radio) return;
|
||||
const period = parseInt(radio.value, 10);
|
||||
if (!period || period === controllingCurrentPeriod) return;
|
||||
on(periodSelector, 'change', (e) => {
|
||||
const radio = e.target.closest('input[name="controlling-period"]');
|
||||
if (!radio) return;
|
||||
const period = parseInt(radio.value, 10);
|
||||
if (!period || period === controllingCurrentPeriod) return;
|
||||
|
||||
controllingCurrentPeriod = period;
|
||||
controllingTabRendered = false;
|
||||
renderControllingTab(period);
|
||||
});
|
||||
}
|
||||
controllingCurrentPeriod = period;
|
||||
controllingTabRendered = false;
|
||||
void renderControllingTab(period);
|
||||
});
|
||||
|
||||
const tabPanels = container.querySelectorAll('[data-tab-panel]');
|
||||
const observer = new MutationObserver(() => {
|
||||
@@ -1558,18 +1590,28 @@ function init(container) {
|
||||
|
||||
const refreshButton = container.querySelector('button[name="support-refresh"]')
|
||||
|| document.getElementById('support-refresh-button');
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', () => {
|
||||
refreshButton.setAttribute('aria-busy', 'true');
|
||||
refreshButton.setAttribute('disabled', 'disabled');
|
||||
on(refreshButton, 'click', () => {
|
||||
refreshButton.setAttribute('aria-busy', 'true');
|
||||
refreshButton.setAttribute('disabled', 'disabled');
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('refresh', '1');
|
||||
window.location.assign(url.toString());
|
||||
});
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('refresh', '1');
|
||||
window.location.assign(url.toString());
|
||||
});
|
||||
|
||||
renderSupportTab();
|
||||
initTicketsGrid();
|
||||
renderDebitorCommunicationAside();
|
||||
void initTicketsGrid();
|
||||
void renderDebitorCommunicationAside();
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
requestController.abort();
|
||||
observer.disconnect();
|
||||
const dialog = document.getElementById('helpdesk-contract-dialog');
|
||||
if (dialog) {
|
||||
dialog.remove();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* into innerHTML. This matches the pattern used in helpdesk-detail.js
|
||||
* and other helpdesk JS modules in this codebase.
|
||||
*/
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
import { renderEmptyState } from './helpdesk-empty-state.js';
|
||||
|
||||
function esc(str) {
|
||||
@@ -30,12 +32,18 @@ const STATUS_LABEL_MAP = {
|
||||
archived: 'Archived',
|
||||
};
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-domain-no]');
|
||||
if (container) {
|
||||
init(container);
|
||||
}
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveContainer = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-domain-detail"]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('.app-details-container[data-app-component="helpdesk-domain-detail"]');
|
||||
};
|
||||
|
||||
function init(container) {
|
||||
const requestController = new AbortController();
|
||||
const domainNo = container.dataset.domainNo || '';
|
||||
const customerNo = container.dataset.customerNo || '';
|
||||
const detailUrl = container.dataset.detailUrl || '';
|
||||
@@ -58,8 +66,9 @@ function init(container) {
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ domainNo, customerNo });
|
||||
const res = await fetch(detailUrl + '?' + params, { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const data = await getJson(`${detailUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!data.ok) {
|
||||
showError(data.error || t('Network error — please try again'));
|
||||
@@ -74,7 +83,10 @@ function init(container) {
|
||||
|
||||
if (loadingEl) loadingEl.hidden = true;
|
||||
if (contentEl) contentEl.hidden = false;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
showError(t('Network error — please try again'));
|
||||
}
|
||||
}
|
||||
@@ -317,5 +329,18 @@ function init(container) {
|
||||
// safe-html: all content built from esc()-escaped values
|
||||
el.innerHTML = items;
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
requestController.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function initHelpdeskDomainDetail(root = document) {
|
||||
const container = resolveContainer(root);
|
||||
if (!container) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
return init(container);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
/**
|
||||
* Risk Radar — customer portfolio risk view.
|
||||
*/
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveContainer = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-risk-radar"]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('.app-details-container[data-app-component="helpdesk-risk-radar"]');
|
||||
};
|
||||
|
||||
export function initHelpdeskRiskRadar(root = document) {
|
||||
const container = resolveContainer(root);
|
||||
if (!container) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const listenerController = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-risk-radar-url]');
|
||||
if (container) {
|
||||
const dataUrl = container.dataset.riskRadarUrl;
|
||||
const debitorBaseUrl = container.dataset.debitorBaseUrl || '';
|
||||
const d = (key, fallback) => container.dataset[key] || fallback;
|
||||
@@ -22,35 +47,29 @@ if (container) {
|
||||
const dialogClose = dialog ? dialog.querySelector('.close') : null;
|
||||
|
||||
let currentPeriod = 90;
|
||||
let lastData = null;
|
||||
|
||||
function showState(state) {
|
||||
const showState = (state) => {
|
||||
if (elLoading) elLoading.hidden = state !== 'loading';
|
||||
if (elError) elError.hidden = state !== 'error';
|
||||
if (elEmpty) elEmpty.hidden = state !== 'empty';
|
||||
if (elContent) elContent.hidden = state !== 'success';
|
||||
}
|
||||
};
|
||||
|
||||
function h(tag, cls, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text !== undefined) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
const h = (tag, cls, text) => {
|
||||
const element = document.createElement(tag);
|
||||
if (cls) element.className = cls;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
};
|
||||
|
||||
function levelClass(level) {
|
||||
return 'helpdesk-risk-level-' + level;
|
||||
}
|
||||
const levelClass = (level) => `helpdesk-risk-level-${level}`;
|
||||
|
||||
function formatAge(hours) {
|
||||
if (hours < 24) return hours + 'h';
|
||||
return Math.floor(hours / 24) + 'd';
|
||||
}
|
||||
const formatAge = (hours) => {
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dimension label lookup from data attributes.
|
||||
*/
|
||||
function dimLabel(id) {
|
||||
const dimLabel = (id) => {
|
||||
const map = {
|
||||
'open_pressure': d('labelOpenPressure', 'Open pressure'),
|
||||
'trend': d('labelTrend', 'Trend'),
|
||||
@@ -58,57 +77,16 @@ if (container) {
|
||||
'inactivity': d('labelInactivity', 'Inactivity'),
|
||||
};
|
||||
return map[id] || id;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a single customer risk card — accent border + score pill.
|
||||
*/
|
||||
function buildCard(customer) {
|
||||
const card = h('article', 'helpdesk-risk-card ' + levelClass(customer.risk_level));
|
||||
card.setAttribute('aria-label', customer.customer_name || customer.customer_no);
|
||||
|
||||
// Row 1: Name + Level on left, score pill on right
|
||||
const header = h('div', 'helpdesk-risk-card-header');
|
||||
const info = h('div', 'helpdesk-risk-card-info');
|
||||
info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no));
|
||||
const levelLabels = { high: d('labelHigh', 'High risk'), medium: d('labelMedium', 'Medium risk'), low: d('labelLow', 'Low risk') };
|
||||
info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || ''));
|
||||
header.appendChild(info);
|
||||
const scoreEl = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score));
|
||||
header.appendChild(scoreEl);
|
||||
card.appendChild(header);
|
||||
|
||||
// Row 2: One-line key facts separated by ·
|
||||
const kpis = customer.kpis || {};
|
||||
const parts = [];
|
||||
if (kpis.open > 0) parts.push(kpis.open + ' ' + d('labelOpen', 'open'));
|
||||
if (kpis.critical > 0) parts.push(kpis.critical + ' ' + d('labelCritical', 'critical'));
|
||||
if (kpis.sla_overdue > 0) parts.push(kpis.sla_overdue + ' SLA');
|
||||
if (kpis.net_flow > 0) parts.push('↑' + kpis.net_flow);
|
||||
else if (kpis.net_flow < 0) parts.push('↓' + Math.abs(kpis.net_flow));
|
||||
|
||||
if (parts.length > 0) {
|
||||
card.appendChild(h('p', 'helpdesk-risk-card-facts', parts.join(' · ')));
|
||||
const openDetail = (customer) => {
|
||||
if (!dialog || !dialogTitle || !dialogBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Click/keyboard to open detail
|
||||
card.tabIndex = 0;
|
||||
card.addEventListener('click', () => openDetail(customer));
|
||||
card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(customer); } });
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open detail dialog for a customer.
|
||||
*/
|
||||
function openDetail(customer) {
|
||||
if (!dialog || !dialogTitle || !dialogBody) return;
|
||||
|
||||
dialogTitle.textContent = (customer.customer_name || customer.customer_no) + ' — ' + customer.risk_score + '/100';
|
||||
dialogTitle.textContent = `${customer.customer_name || customer.customer_no} — ${customer.risk_score}/100`;
|
||||
dialogBody.replaceChildren();
|
||||
|
||||
// Dimensions as points breakdown: "18 / 35"
|
||||
const dims = customer.dimensions || [];
|
||||
const dimTable = h('table', 'helpdesk-risk-detail-tickets');
|
||||
const dimHead = h('thead');
|
||||
@@ -122,7 +100,7 @@ if (container) {
|
||||
const tr = h('tr');
|
||||
tr.appendChild(h('td', '', dimLabel(dim.id)));
|
||||
const points = Math.round(dim.weighted_points || 0);
|
||||
tr.appendChild(h('td', '', points + ' / ' + dim.weight));
|
||||
tr.appendChild(h('td', '', `${points} / ${dim.weight}`));
|
||||
dimBody.appendChild(tr);
|
||||
}
|
||||
const totalTr = h('tr');
|
||||
@@ -132,16 +110,15 @@ if (container) {
|
||||
totalTr.appendChild(totalLabel);
|
||||
const totalVal = h('td', '');
|
||||
totalVal.style.fontWeight = '700';
|
||||
totalVal.textContent = customer.risk_score + ' / 100';
|
||||
totalVal.textContent = `${customer.risk_score} / 100`;
|
||||
totalTr.appendChild(totalVal);
|
||||
dimBody.appendChild(totalTr);
|
||||
dimTable.appendChild(dimBody);
|
||||
dialogBody.appendChild(dimTable);
|
||||
|
||||
// Open tickets list
|
||||
const tickets = customer.open_tickets || [];
|
||||
if (tickets.length > 0) {
|
||||
dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', d('labelTickets', 'Tickets') + ' (' + tickets.length + ')'));
|
||||
dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', `${d('labelTickets', 'Tickets')} (${tickets.length})`));
|
||||
const table = h('table', 'helpdesk-risk-detail-tickets');
|
||||
const thead = h('thead');
|
||||
const headRow = h('tr');
|
||||
@@ -152,19 +129,18 @@ if (container) {
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
const tbody = h('tbody');
|
||||
for (const t of tickets) {
|
||||
const tr = h('tr', t.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : '');
|
||||
tr.appendChild(h('td', '', t.no));
|
||||
tr.appendChild(h('td', '', t.escalation_code || '—'));
|
||||
tr.appendChild(h('td', '', t.state));
|
||||
tr.appendChild(h('td', '', formatAge(t.age_hours)));
|
||||
for (const ticket of tickets) {
|
||||
const tr = h('tr', ticket.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : '');
|
||||
tr.appendChild(h('td', '', ticket.no));
|
||||
tr.appendChild(h('td', '', ticket.escalation_code || '—'));
|
||||
tr.appendChild(h('td', '', ticket.state));
|
||||
tr.appendChild(h('td', '', formatAge(ticket.age_hours)));
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
dialogBody.appendChild(table);
|
||||
}
|
||||
|
||||
// Link to debitor
|
||||
if (debitorBaseUrl && customer.customer_no) {
|
||||
const link = h('a', 'helpdesk-risk-detail-link', customer.customer_name || customer.customer_no);
|
||||
link.href = debitorBaseUrl + encodeURIComponent(customer.customer_no);
|
||||
@@ -174,19 +150,70 @@ if (container) {
|
||||
}
|
||||
|
||||
dialog.showModal();
|
||||
}
|
||||
};
|
||||
|
||||
// Dialog close handlers
|
||||
if (dialogClose) dialogClose.addEventListener('click', () => dialog.close());
|
||||
if (dialog) dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); });
|
||||
const buildCard = (customer) => {
|
||||
const card = h('article', `helpdesk-risk-card ${levelClass(customer.risk_level)}`);
|
||||
card.setAttribute('aria-label', customer.customer_name || customer.customer_no);
|
||||
|
||||
function renderCards(customers) {
|
||||
if (!elCards) return;
|
||||
const header = h('div', 'helpdesk-risk-card-header');
|
||||
const info = h('div', 'helpdesk-risk-card-info');
|
||||
info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no));
|
||||
const levelLabels = {
|
||||
high: d('labelHigh', 'High risk'),
|
||||
medium: d('labelMedium', 'Medium risk'),
|
||||
low: d('labelLow', 'Low risk'),
|
||||
};
|
||||
info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || ''));
|
||||
header.appendChild(info);
|
||||
const scoreEl = h('span', `helpdesk-risk-card-score ${levelClass(customer.risk_level)}`, String(customer.risk_score));
|
||||
header.appendChild(scoreEl);
|
||||
card.appendChild(header);
|
||||
|
||||
const kpis = customer.kpis || {};
|
||||
const parts = [];
|
||||
if (kpis.open > 0) parts.push(`${kpis.open} ${d('labelOpen', 'open')}`);
|
||||
if (kpis.critical > 0) parts.push(`${kpis.critical} ${d('labelCritical', 'critical')}`);
|
||||
if (kpis.sla_overdue > 0) parts.push(`${kpis.sla_overdue} SLA`);
|
||||
if (kpis.net_flow > 0) parts.push(`↑${kpis.net_flow}`);
|
||||
else if (kpis.net_flow < 0) parts.push(`↓${Math.abs(kpis.net_flow)}`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
card.appendChild(h('p', 'helpdesk-risk-card-facts', parts.join(' · ')));
|
||||
}
|
||||
|
||||
card.tabIndex = 0;
|
||||
on(card, 'click', () => openDetail(customer));
|
||||
on(card, 'keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openDetail(customer);
|
||||
}
|
||||
});
|
||||
|
||||
return card;
|
||||
};
|
||||
|
||||
on(dialogClose, 'click', () => {
|
||||
dialog?.close();
|
||||
});
|
||||
on(dialog, 'click', (event) => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
const renderCards = (customers) => {
|
||||
if (!elCards) {
|
||||
return;
|
||||
}
|
||||
elCards.replaceChildren();
|
||||
for (const c of customers) elCards.appendChild(buildCard(c));
|
||||
}
|
||||
for (const customer of customers) {
|
||||
elCards.appendChild(buildCard(customer));
|
||||
}
|
||||
};
|
||||
|
||||
async function fetchData(refresh = false) {
|
||||
const fetchData = async (refresh = false) => {
|
||||
showState('loading');
|
||||
if (elTruncated) elTruncated.hidden = true;
|
||||
|
||||
@@ -194,38 +221,48 @@ if (container) {
|
||||
if (refresh) params.set('refresh', '1');
|
||||
|
||||
try {
|
||||
const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' });
|
||||
const json = await res.json();
|
||||
|
||||
const json = await getJson(`${dataUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
if (!json.ok) { showState('error'); return; }
|
||||
if (!json.customers || json.customers.length === 0) { showState('empty'); return; }
|
||||
|
||||
lastData = json;
|
||||
renderCards(json.customers);
|
||||
showState('success');
|
||||
|
||||
if (json.meta && json.meta.truncated && elTruncated) {
|
||||
elTruncated.hidden = false;
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
showState('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (periodSelector) {
|
||||
periodSelector.addEventListener('change', (e) => {
|
||||
const radio = e.target.closest('input[name="radar-period"]');
|
||||
if (!radio) return;
|
||||
const period = parseInt(radio.value, 10);
|
||||
if (!period || period === currentPeriod) return;
|
||||
currentPeriod = period;
|
||||
fetchData();
|
||||
});
|
||||
}
|
||||
on(periodSelector, 'change', (event) => {
|
||||
const radio = event.target.closest('input[name="radar-period"]');
|
||||
if (!radio) return;
|
||||
const period = parseInt(radio.value, 10);
|
||||
if (!period || period === currentPeriod) return;
|
||||
currentPeriod = period;
|
||||
void fetchData();
|
||||
});
|
||||
|
||||
if (elRefresh) {
|
||||
elRefresh.addEventListener('click', () => fetchData(true));
|
||||
}
|
||||
on(elRefresh, 'click', () => {
|
||||
void fetchData(true);
|
||||
});
|
||||
|
||||
fetchData();
|
||||
void fetchData();
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
requestController.abort();
|
||||
if (dialog?.open) {
|
||||
dialog.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,133 +2,143 @@
|
||||
* Helpdesk settings page — auth mode toggle and connection test.
|
||||
*/
|
||||
import { showAsyncFlash } from '/js/components/app-async-flash.js';
|
||||
import { postForm } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
|
||||
const settingsForm = document.getElementById('helpdesk-settings-form');
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const getMessage = (key, fallback) => {
|
||||
if (!settingsForm || !settingsForm.dataset) {
|
||||
const resolveContainer = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-settings"]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('.app-details-container[data-app-component="helpdesk-settings"]');
|
||||
};
|
||||
|
||||
const readMessage = (form, key, fallback) => {
|
||||
if (!form || !form.dataset) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return settingsForm.dataset[key] || fallback;
|
||||
return form.dataset[key] || fallback;
|
||||
};
|
||||
|
||||
// Toggle OAuth2 vs Basic Auth field visibility
|
||||
const authModeSelect = document.getElementById('auth_mode');
|
||||
const basicFields = document.getElementById('helpdesk-basic-auth-fields');
|
||||
const oauth2Fields = document.getElementById('helpdesk-oauth2-fields');
|
||||
|
||||
const toggleAuthFields = () => {
|
||||
if (!authModeSelect || !basicFields || !oauth2Fields) {
|
||||
return;
|
||||
export function initHelpdeskSettings(root = document) {
|
||||
const container = resolveContainer(root);
|
||||
if (!container) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const isOAuth2 = authModeSelect.value === 'oauth2';
|
||||
basicFields.toggleAttribute('hidden', isOAuth2);
|
||||
oauth2Fields.toggleAttribute('hidden', !isOAuth2);
|
||||
};
|
||||
const settingsForm = container.querySelector('#helpdesk-settings-form');
|
||||
if (!settingsForm) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
if (authModeSelect) {
|
||||
authModeSelect.addEventListener('change', toggleAuthFields);
|
||||
const listenerController = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const authModeSelect = settingsForm.querySelector('#auth_mode');
|
||||
const basicFields = settingsForm.querySelector('#helpdesk-basic-auth-fields');
|
||||
const oauth2Fields = settingsForm.querySelector('#helpdesk-oauth2-fields');
|
||||
|
||||
const testButton = settingsForm.querySelector('#helpdesk-test-connection-button');
|
||||
const testResult = settingsForm.querySelector('#helpdesk-connection-test-result');
|
||||
|
||||
const overrideToggle = settingsForm.querySelector('#helpdesk-override-toggle');
|
||||
const tenantFields = settingsForm.querySelector('#helpdesk-tenant-fields');
|
||||
const saveTargetInput = settingsForm.querySelector('#helpdesk-settings-save-target');
|
||||
|
||||
const tenantAuthModeSelect = settingsForm.querySelector('#tenant_auth_mode');
|
||||
const tenantBasicFields = settingsForm.querySelector('#helpdesk-tenant-basic-auth-fields');
|
||||
const tenantOauth2Fields = settingsForm.querySelector('#helpdesk-tenant-oauth2-fields');
|
||||
|
||||
const setTestResult = (variant, message) => {
|
||||
if (!testResult) {
|
||||
return;
|
||||
}
|
||||
testResult.textContent = message;
|
||||
testResult.dataset.variant = variant;
|
||||
testResult.hidden = !message;
|
||||
};
|
||||
|
||||
const setButtonLoadingState = (isLoading) => {
|
||||
if (!testButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultLabel = testButton.dataset.labelDefault || readMessage(settingsForm, 'testDefaultLabel', 'Test connection');
|
||||
const loadingLabel = testButton.dataset.labelLoading || readMessage(settingsForm, 'testLoadingLabel', 'Testing...');
|
||||
|
||||
testButton.disabled = isLoading;
|
||||
testButton.toggleAttribute('aria-busy', isLoading);
|
||||
testButton.textContent = isLoading ? loadingLabel : defaultLabel;
|
||||
};
|
||||
|
||||
const toggleAuthFields = () => {
|
||||
if (!authModeSelect || !basicFields || !oauth2Fields) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOAuth2 = authModeSelect.value === 'oauth2';
|
||||
basicFields.toggleAttribute('hidden', isOAuth2);
|
||||
oauth2Fields.toggleAttribute('hidden', !isOAuth2);
|
||||
};
|
||||
|
||||
on(authModeSelect, 'change', toggleAuthFields);
|
||||
toggleAuthFields();
|
||||
}
|
||||
|
||||
// Connection test button
|
||||
const testButton = document.getElementById('helpdesk-test-connection-button');
|
||||
const testResult = document.getElementById('helpdesk-connection-test-result');
|
||||
|
||||
const setTestResult = (variant, message) => {
|
||||
if (!testResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
testResult.textContent = message;
|
||||
testResult.dataset.variant = variant;
|
||||
testResult.hidden = !message;
|
||||
};
|
||||
|
||||
const setButtonLoadingState = (isLoading) => {
|
||||
if (!testButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultLabel = testButton.dataset.labelDefault || getMessage('testDefaultLabel', 'Test connection');
|
||||
const loadingLabel = testButton.dataset.labelLoading || getMessage('testLoadingLabel', 'Testing...');
|
||||
|
||||
testButton.disabled = isLoading;
|
||||
testButton.toggleAttribute('aria-busy', isLoading);
|
||||
testButton.textContent = isLoading ? loadingLabel : defaultLabel;
|
||||
};
|
||||
|
||||
const parseJsonSafely = async (response) => {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Tenant override toggle — show/hide tenant fields + set save_target
|
||||
const overrideToggle = document.getElementById('helpdesk-override-toggle');
|
||||
const tenantFields = document.getElementById('helpdesk-tenant-fields');
|
||||
const saveTargetInput = document.getElementById('helpdesk-settings-save-target');
|
||||
|
||||
const tenantAuthModeSelect = document.getElementById('tenant_auth_mode');
|
||||
const tenantBasicFields = document.getElementById('helpdesk-tenant-basic-auth-fields');
|
||||
const tenantOauth2Fields = document.getElementById('helpdesk-tenant-oauth2-fields');
|
||||
|
||||
if (overrideToggle && tenantFields) {
|
||||
overrideToggle.addEventListener('change', () => {
|
||||
tenantFields.hidden = !overrideToggle.checked;
|
||||
on(overrideToggle, 'change', () => {
|
||||
if (tenantFields) {
|
||||
tenantFields.hidden = !overrideToggle.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// On form submit, set save_target based on active tab
|
||||
if (settingsForm && saveTargetInput) {
|
||||
settingsForm.addEventListener('submit', () => {
|
||||
on(settingsForm, 'submit', () => {
|
||||
if (!saveTargetInput) {
|
||||
return;
|
||||
}
|
||||
const tenantPanel = settingsForm.querySelector('[data-tab-panel="tenant"]:not([hidden])');
|
||||
saveTargetInput.value = tenantPanel ? 'tenant' : 'global';
|
||||
});
|
||||
}
|
||||
|
||||
// Tenant auth mode toggle
|
||||
if (tenantAuthModeSelect && tenantBasicFields && tenantOauth2Fields) {
|
||||
tenantAuthModeSelect.addEventListener('change', () => {
|
||||
on(tenantAuthModeSelect, 'change', () => {
|
||||
if (!tenantBasicFields || !tenantOauth2Fields || !tenantAuthModeSelect) {
|
||||
return;
|
||||
}
|
||||
const isOAuth2 = tenantAuthModeSelect.value === 'oauth2';
|
||||
tenantBasicFields.hidden = isOAuth2;
|
||||
tenantOauth2Fields.hidden = !isOAuth2;
|
||||
});
|
||||
}
|
||||
|
||||
if (testButton) {
|
||||
testButton.addEventListener('click', async () => {
|
||||
on(testButton, 'click', async () => {
|
||||
setButtonLoadingState(true);
|
||||
setTestResult('info', getMessage('testLoadingLabel', 'Testing...'));
|
||||
setTestResult('info', readMessage(settingsForm, 'testLoadingLabel', 'Testing...'));
|
||||
|
||||
try {
|
||||
const csrfInput = settingsForm.querySelector('input[name="csrf_token"]');
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const csrfInput = document.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfInput?.value || csrfMeta?.content || '';
|
||||
|
||||
const formData = new FormData();
|
||||
const body = new URLSearchParams();
|
||||
if (csrfToken) {
|
||||
formData.append('csrf_token', csrfToken);
|
||||
body.set('csrf_token', csrfToken);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const data = await postForm(
|
||||
new URL('helpdesk/settings/test-connection-data', document.baseURI).href,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
},
|
||||
body,
|
||||
{ signal: requestController.signal }
|
||||
);
|
||||
|
||||
const data = await parseJsonSafely(response);
|
||||
const successMessage = getMessage('testSuccessMessage', 'Connection successful');
|
||||
const defaultErrorMessage = getMessage('testErrorMessage', 'Connection failed');
|
||||
const successMessage = readMessage(settingsForm, 'testSuccessMessage', 'Connection successful');
|
||||
const defaultErrorMessage = readMessage(settingsForm, 'testErrorMessage', 'Connection failed');
|
||||
|
||||
if (response.ok && data?.ok) {
|
||||
if (data?.ok === true) {
|
||||
const message = data.message || successMessage;
|
||||
setTestResult('success', message);
|
||||
showAsyncFlash('success', message);
|
||||
@@ -136,23 +146,36 @@ if (testButton) {
|
||||
let message = defaultErrorMessage;
|
||||
if (data?.error) {
|
||||
message = data.error;
|
||||
} else if (!response.ok) {
|
||||
message = `${defaultErrorMessage} (HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
if (data?.debug?.http_code) {
|
||||
message += ` (HTTP ${data.debug.http_code})`;
|
||||
}
|
||||
|
||||
setTestResult('warning', message);
|
||||
showAsyncFlash('error', message);
|
||||
}
|
||||
} catch {
|
||||
const message = getMessage('testErrorMessage', 'Connection failed');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultErrorMessage = readMessage(settingsForm, 'testErrorMessage', 'Connection failed');
|
||||
let message = defaultErrorMessage;
|
||||
const status = error?.status ?? 0;
|
||||
if (status > 0) {
|
||||
message = `${defaultErrorMessage} (HTTP ${status})`;
|
||||
}
|
||||
|
||||
setTestResult('warning', message);
|
||||
showAsyncFlash('error', message);
|
||||
} finally {
|
||||
setButtonLoadingState(false);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
requestController.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,34 @@
|
||||
* Current tab: per-agent widget with section title + ticket table.
|
||||
* Performance tab: compact agent rows with resolved counts.
|
||||
*/
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveContainer = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-team"]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('.app-details-container[data-app-component="helpdesk-team"]');
|
||||
};
|
||||
|
||||
export function initHelpdeskTeam(root = document) {
|
||||
const container = resolveContainer(root);
|
||||
if (!container) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const listenerController = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-team-workload-url]');
|
||||
if (container) {
|
||||
const dataUrl = container.dataset.teamWorkloadUrl;
|
||||
const labelQueue = container.dataset.labelQueue || 'Queue';
|
||||
const labelTickets = container.dataset.labelTickets || 'Tickets';
|
||||
@@ -202,7 +227,7 @@ if (container) {
|
||||
for (const item of items) {
|
||||
const li = h('li', 'helpdesk-team-perf-ranked-clickable');
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
on(li, 'click', () => {
|
||||
if (activeItem === li) {
|
||||
activeItem.classList.remove('active');
|
||||
activeItem = null;
|
||||
@@ -363,8 +388,9 @@ if (container) {
|
||||
if (refresh) params.set('refresh', '1');
|
||||
|
||||
try {
|
||||
const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' });
|
||||
const json = await res.json();
|
||||
const json = await getJson(`${dataUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!json.ok) { showState('error'); return; }
|
||||
if (!json.members || json.members.length === 0) { showState('empty'); return; }
|
||||
@@ -372,25 +398,31 @@ if (container) {
|
||||
renderCurrentTab(json.members);
|
||||
renderPerformanceTab(json.members);
|
||||
showState('success');
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
showState('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (periodSelector) {
|
||||
periodSelector.addEventListener('change', (e) => {
|
||||
const radio = e.target.closest('input[name="team-period"]');
|
||||
if (!radio) return;
|
||||
const period = parseInt(radio.value, 10);
|
||||
if (!period || period === currentPeriod) return;
|
||||
currentPeriod = period;
|
||||
fetchData();
|
||||
});
|
||||
}
|
||||
on(periodSelector, 'change', (e) => {
|
||||
const radio = e.target.closest('input[name="team-period"]');
|
||||
if (!radio) return;
|
||||
const period = parseInt(radio.value, 10);
|
||||
if (!period || period === currentPeriod) return;
|
||||
currentPeriod = period;
|
||||
void fetchData();
|
||||
});
|
||||
|
||||
if (elRefresh) {
|
||||
elRefresh.addEventListener('click', () => fetchData(true));
|
||||
}
|
||||
on(elRefresh, 'click', () => void fetchData(true));
|
||||
|
||||
fetchData();
|
||||
void fetchData();
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
requestController.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,66 +1,82 @@
|
||||
/**
|
||||
* Helpdesk ticket detail page — async communication aside.
|
||||
*
|
||||
* Loads the ticket communication feed via fetch and renders
|
||||
* it in the aside panel, matching the debitor detail pattern.
|
||||
*/
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
import {
|
||||
renderCommunicationError,
|
||||
renderCommunicationFeed,
|
||||
renderCommunicationHint,
|
||||
} from './helpdesk-communication.js';
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-ticket-no]');
|
||||
if (container) {
|
||||
init(container);
|
||||
}
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveContainer = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches('.app-details-container[data-ticket-no]')) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector('.app-details-container[data-ticket-no]');
|
||||
};
|
||||
|
||||
export function initHelpdeskTicket(root = document) {
|
||||
const container = resolveContainer(root);
|
||||
if (!container) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
const ticketNo = container.dataset.ticketNo || '';
|
||||
const communicationUrl = container.dataset.ticketCommunicationUrl || '';
|
||||
const fileDownloadUrl = container.dataset.fileDownloadUrl || '';
|
||||
if (!ticketNo || !communicationUrl) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
if (!ticketNo || !communicationUrl) return;
|
||||
const listenerController = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
// i18n
|
||||
const i18nEl = document.getElementById('helpdesk-ticket-i18n');
|
||||
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
|
||||
const i18n = i18nEl ? JSON.parse(i18nEl.textContent || '{}') : {};
|
||||
const t = (key) => i18n[key] || key;
|
||||
|
||||
function showLoading(id) {
|
||||
const toggleHidden = (id, hidden) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.hidden = false;
|
||||
}
|
||||
if (el) {
|
||||
el.hidden = hidden;
|
||||
}
|
||||
};
|
||||
|
||||
function hideLoading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.hidden = true;
|
||||
}
|
||||
|
||||
function showContent(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.hidden = false;
|
||||
}
|
||||
|
||||
async function loadCommunication(refresh = false) {
|
||||
const loadCommunication = async (refresh = false) => {
|
||||
const feedEl = document.getElementById('ticket-communication-feed');
|
||||
const loadingEl = document.getElementById('ticket-communication-loading');
|
||||
const contentEl = document.getElementById('ticket-communication-content');
|
||||
if (!feedEl || !loadingEl || !contentEl) return;
|
||||
if (!feedEl || !loadingEl || !contentEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentEl) contentEl.hidden = true;
|
||||
showLoading('ticket-communication-loading');
|
||||
contentEl.hidden = true;
|
||||
loadingEl.hidden = false;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ ticketNo });
|
||||
if (refresh) params.set('refresh', '1');
|
||||
const response = await fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (refresh) {
|
||||
params.set('refresh', '1');
|
||||
}
|
||||
|
||||
hideLoading('ticket-communication-loading');
|
||||
showContent('ticket-communication-content');
|
||||
const data = await getJson(`${communicationUrl}?${params.toString()}`, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!data.ok) {
|
||||
toggleHidden('ticket-communication-loading', true);
|
||||
toggleHidden('ticket-communication-content', false);
|
||||
|
||||
if (!data || data.ok !== true) {
|
||||
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
|
||||
return;
|
||||
}
|
||||
@@ -82,17 +98,30 @@ function init(container) {
|
||||
});
|
||||
|
||||
feedEl.innerHTML = html;
|
||||
} catch {
|
||||
hideLoading('ticket-communication-loading');
|
||||
showContent('ticket-communication-content');
|
||||
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
toggleHidden('ticket-communication-loading', true);
|
||||
toggleHidden('ticket-communication-content', false);
|
||||
const feedEl = document.getElementById('ticket-communication-feed');
|
||||
if (feedEl) {
|
||||
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshButton = container.querySelector('button[name="ticket-refresh"]');
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', () => loadCommunication(true));
|
||||
}
|
||||
on(refreshButton, 'click', () => {
|
||||
void loadCommunication(true);
|
||||
});
|
||||
|
||||
loadCommunication();
|
||||
void loadCommunication();
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
requestController.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,105 +1,97 @@
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { warnOnce } from '/js/core/app-dom.js';
|
||||
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-domains');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed: Grid.js missing', { module: 'helpdesk-domains', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
createListPageModule({
|
||||
configId: 'helpdesk-domains',
|
||||
moduleId: 'helpdesk-domains',
|
||||
missingGridMessage: 'Helpdesk domains grid init failed: Grid.js missing',
|
||||
initErrorMessage: 'Helpdesk domains grid init failed',
|
||||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||||
const labels = config.labels || {};
|
||||
|
||||
const domainBaseUrl = config.domainBaseUrl || '';
|
||||
const domainUrlIndex = 7;
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-domains-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/domains-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.no || 'No.',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const no = escapeHtml(cell?.no || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(no);
|
||||
}
|
||||
const href = escapeHtml(detailUrl);
|
||||
return gridjs.html(`<a href="${href}">${no}</a>`);
|
||||
const { gridConfig } = initListPage({
|
||||
grid: {
|
||||
gridjs,
|
||||
container: '#helpdesk-domains-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/domains-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.no || 'No.',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const no = escapeHtml(cell?.no || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(no);
|
||||
}
|
||||
const href = escapeHtml(detailUrl);
|
||||
return gridjs.html(`<a href="${href}">${no}</a>`);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.customerName || 'Customer Name',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const name = escapeHtml(cell?.name || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(name);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(detailUrl));
|
||||
return gridjs.html(`<a href="${href}">${name}</a>`);
|
||||
{
|
||||
name: labels.customerName || 'Customer Name',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const name = escapeHtml(cell?.name || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(name);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(detailUrl));
|
||||
return gridjs.html(`<a href="${href}">${name}</a>`);
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: labels.url || 'URL', sort: true },
|
||||
{ name: labels.contractType || 'Contract Type', sort: true },
|
||||
{
|
||||
name: labels.state || 'State',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const state = escapeHtml(cell?.state || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${state}</span>`);
|
||||
{ name: labels.url || 'URL', sort: true },
|
||||
{ name: labels.contractType || 'Contract Type', sort: true },
|
||||
{
|
||||
name: labels.state || 'State',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const state = escapeHtml(cell?.state || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${state}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.administration || 'Administration', sort: true },
|
||||
{ name: 'debitor_url', hidden: true },
|
||||
{ name: 'domain_detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => {
|
||||
const domainDetailUrl = domainBaseUrl && row.No
|
||||
? domainBaseUrl + encodeURIComponent(row.No)
|
||||
: '';
|
||||
return [
|
||||
{ no: row.No || '', url: domainDetailUrl },
|
||||
{ name: row.Customer_Name || '', url: row.debitor_url || '' },
|
||||
row.URL || '',
|
||||
row.contract_type || '',
|
||||
{ state: row.State || '', variant: row.state_variant || 'neutral' },
|
||||
row.Administration || '',
|
||||
row.debitor_url || '',
|
||||
domainDetailUrl,
|
||||
];
|
||||
}),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '',
|
||||
},
|
||||
{ name: labels.administration || 'Administration', sort: true },
|
||||
{ name: 'debitor_url', hidden: true },
|
||||
{ name: 'domain_detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => {
|
||||
const domainDetailUrl = domainBaseUrl && row.No
|
||||
? domainBaseUrl + encodeURIComponent(row.No)
|
||||
: '';
|
||||
return [
|
||||
{ no: row.No || '', url: domainDetailUrl },
|
||||
{ name: row.Customer_Name || '', url: row.debitor_url || '' },
|
||||
row.URL || '',
|
||||
row.contract_type || '',
|
||||
{ state: row.State || '', variant: row.state_variant || 'neutral' },
|
||||
row.Administration || '',
|
||||
row.debitor_url || '',
|
||||
domainDetailUrl,
|
||||
];
|
||||
}),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#helpdesk-domains-search-input'],
|
||||
},
|
||||
});
|
||||
}) || {};
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed', { module: 'helpdesk-domains', component: 'grid' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return gridConfig;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { warnOnce } from '/js/core/app-dom.js';
|
||||
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { bindBulkVisibility } from '/js/components/app-bulk-selection.js';
|
||||
import { confirmDialog } from '/js/core/app-confirm-dialog.js';
|
||||
import { postForm } from '/js/core/app-http.js';
|
||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-handovers');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed: Grid.js missing', { module: 'helpdesk-handovers', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
createListPageModule({
|
||||
configId: 'helpdesk-handovers',
|
||||
moduleId: 'helpdesk-handovers',
|
||||
missingGridMessage: 'Helpdesk handovers grid init failed: Grid.js missing',
|
||||
initErrorMessage: 'Helpdesk handovers grid init failed',
|
||||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||||
const labels = config.labels || {};
|
||||
const canManage = config.canManage || false;
|
||||
|
||||
@@ -100,7 +98,7 @@ if (config) {
|
||||
};
|
||||
}
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
const listResult = initListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
@@ -109,56 +107,51 @@ if (config) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed', { module: 'helpdesk-handovers', component: 'grid' });
|
||||
const gridConfig = listResult?.gridConfig || null;
|
||||
if (!canManage || !gridConfig) {
|
||||
return gridConfig;
|
||||
}
|
||||
|
||||
if (canManage && gridConfig) {
|
||||
bindBulkVisibility({
|
||||
containerSelector: '#helpdesk-handovers-grid',
|
||||
buttonSelector: '[data-handovers-bulk]',
|
||||
});
|
||||
bindBulkVisibility({
|
||||
containerSelector: '#helpdesk-handovers-grid',
|
||||
buttonSelector: '[data-handovers-bulk]',
|
||||
});
|
||||
|
||||
const bulkButtons = Array.from(document.querySelectorAll('[data-handovers-bulk]'));
|
||||
bulkButtons.forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const action = button.dataset.handoversBulk || '';
|
||||
const ids = gridConfig.selection?.getSelectedIds?.() || [];
|
||||
if (!ids.length || action !== 'delete') return;
|
||||
const bulkButtons = Array.from(document.querySelectorAll('[data-handovers-bulk]'));
|
||||
bulkButtons.forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const action = button.dataset.handoversBulk || '';
|
||||
const ids = gridConfig.selection?.getSelectedIds?.() || [];
|
||||
if (!ids.length || action !== 'delete') {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog.confirm({
|
||||
message: labels.bulkDeleteConfirm || 'Delete selected handovers?',
|
||||
actionKind: 'delete',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
ids: ids.join(','),
|
||||
[config.csrfKey]: config.csrfToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch',
|
||||
},
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.ok) {
|
||||
gridConfig.selection?.clear?.();
|
||||
gridConfig.grid?.forceRender();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
window.location.reload();
|
||||
}
|
||||
const confirmed = await confirmDialog.confirm({
|
||||
message: labels.bulkDeleteConfirm || 'Delete selected handovers?',
|
||||
actionKind: 'delete',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
ids: ids.join(','),
|
||||
[config.csrfKey]: config.csrfToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await postForm(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), body);
|
||||
if (data?.ok !== true) {
|
||||
throw new Error('Bulk delete failed');
|
||||
}
|
||||
gridConfig.selection?.clear?.();
|
||||
gridConfig.grid?.forceRender();
|
||||
} catch {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return gridConfig;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,73 +1,66 @@
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { warnOnce } from '/js/core/app-dom.js';
|
||||
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-search');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed: Grid.js missing', { module: 'helpdesk-search', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
createListPageModule({
|
||||
configId: 'helpdesk-search',
|
||||
moduleId: 'helpdesk-search',
|
||||
missingGridMessage: 'Helpdesk search grid init failed: Grid.js missing',
|
||||
initErrorMessage: 'Helpdesk search grid init failed',
|
||||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||||
const labels = config.labels || {};
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-search-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/search-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.debtorNo || 'Debtor No.',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const debtorNo = escapeHtml(cell?.no || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(debtorNo);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(detailUrl));
|
||||
return gridjs.html(`<a href="${href}">${debtorNo}</a>`);
|
||||
const { gridConfig } = initListPage({
|
||||
grid: {
|
||||
gridjs,
|
||||
container: '#helpdesk-search-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/search-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.debtorNo || 'Debtor No.',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const debtorNo = escapeHtml(cell?.no || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(debtorNo);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(detailUrl));
|
||||
return gridjs.html(`<a href="${href}">${debtorNo}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.name || 'Name', sort: true },
|
||||
{ name: labels.city || 'City', sort: true },
|
||||
{ name: labels.phone || 'Phone', sort: false },
|
||||
{ name: labels.email || 'E-Mail', sort: false },
|
||||
{ name: 'detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Name', 'City', null, null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ no: row.No || '', url: row.detail_url || '' },
|
||||
row.Name || '',
|
||||
row.City || '',
|
||||
row.Phone_No || '',
|
||||
row.E_Mail || '',
|
||||
row.detail_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[5]?.data || '',
|
||||
},
|
||||
{ name: labels.name || 'Name', sort: true },
|
||||
{ name: labels.city || 'City', sort: true },
|
||||
{ name: labels.phone || 'Phone', sort: false },
|
||||
{ name: labels.email || 'E-Mail', sort: false },
|
||||
{ name: 'detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Name', 'City', null, null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ no: row.No || '', url: row.detail_url || '' },
|
||||
row.Name || '',
|
||||
row.City || '',
|
||||
row.Phone_No || '',
|
||||
row.E_Mail || '',
|
||||
row.detail_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[5]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#helpdesk-search-input'],
|
||||
},
|
||||
});
|
||||
}) || {};
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed', { module: 'helpdesk-search', component: 'grid' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return gridConfig;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,81 +1,73 @@
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { warnOnce } from '/js/core/app-dom.js';
|
||||
import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-software-products');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed: Grid.js missing', { module: 'helpdesk-software-products', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
createListPageModule({
|
||||
configId: 'helpdesk-software-products',
|
||||
moduleId: 'helpdesk-software-products',
|
||||
missingGridMessage: 'Helpdesk software products grid init failed: Grid.js missing',
|
||||
initErrorMessage: 'Helpdesk software products grid init failed',
|
||||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||||
const labels = config.labels || {};
|
||||
|
||||
const editUrlIndex = 4;
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-software-products-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/software-products-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.code || 'Code',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const code = escapeHtml(cell?.code || '');
|
||||
const editUrl = String(cell?.url || '').trim();
|
||||
if (editUrl === '') {
|
||||
return gridjs.html(code);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(editUrl));
|
||||
return gridjs.html(`<a href="${href}">${code}</a>`);
|
||||
const { gridConfig } = initListPage({
|
||||
grid: {
|
||||
gridjs,
|
||||
container: '#helpdesk-software-products-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/software-products-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.code || 'Code',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const code = escapeHtml(cell?.code || '');
|
||||
const editUrl = String(cell?.url || '').trim();
|
||||
if (editUrl === '') {
|
||||
return gridjs.html(code);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(editUrl));
|
||||
return gridjs.html(`<a href="${href}">${code}</a>`);
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: labels.bcDescription || 'BC Description', sort: true },
|
||||
{ name: labels.name || 'Product Name', sort: true },
|
||||
{
|
||||
name: labels.status || 'Status',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
{ name: labels.bcDescription || 'BC Description', sort: true },
|
||||
{ name: labels.name || 'Product Name', sort: true },
|
||||
{
|
||||
name: labels.status || 'Status',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: 'edit_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['code', 'bc_description', 'name', 'active', null],
|
||||
paginationLimit: 20,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ code: row.code || '', url: row.edit_url || '' },
|
||||
row.bc_description || '',
|
||||
row.name || '',
|
||||
{ label: row.active_label || '', variant: row.active_variant || 'neutral' },
|
||||
row.edit_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
|
||||
},
|
||||
{ name: 'edit_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['code', 'bc_description', 'name', 'active', null],
|
||||
paginationLimit: 20,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ code: row.code || '', url: row.edit_url || '' },
|
||||
row.bc_description || '',
|
||||
row.name || '',
|
||||
{ label: row.active_label || '', variant: row.active_variant || 'neutral' },
|
||||
row.edit_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#helpdesk-software-products-search-input'],
|
||||
},
|
||||
});
|
||||
}) || {};
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed', { module: 'helpdesk-software-products', component: 'grid' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return gridConfig;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,138 +1,132 @@
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { warnOnce } from '/js/core/app-dom.js';
|
||||
import { escapeHtml, getAppBase } from '/js/pages/app-list-utils.js';
|
||||
import { showAsyncFlash } from '/js/components/app-async-flash.js';
|
||||
import { getJson, postForm } from '/js/core/app-http.js';
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const config = readPageConfig('helpdesk-updates');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed: Grid.js missing', { module: 'helpdesk-updates', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
createListPageModule({
|
||||
configId: 'helpdesk-updates',
|
||||
moduleId: 'helpdesk-updates',
|
||||
missingGridMessage: 'Helpdesk updates grid init failed: Grid.js missing',
|
||||
initErrorMessage: 'Helpdesk updates grid init failed',
|
||||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||||
const labels = config.labels || {};
|
||||
const canManage = config.canManage || false;
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-updates-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/updates-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.ticketNo || 'Ticket',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const no = escapeHtml(String(cell?.no || ''));
|
||||
const url = String(cell?.url || '').trim();
|
||||
const desc = escapeHtml(String(cell?.description || ''));
|
||||
const tip = desc ? ` data-tooltip="${desc}" data-tooltip-pos="top"` : '';
|
||||
if (no === '') return gridjs.html('<span class="text-muted">—</span>');
|
||||
if (url === '') return gridjs.html(`<span${tip}>${no}</span>`);
|
||||
return gridjs.html(`<a href="${escapeHtml(url)}"${tip}>${no}</a>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.ticketState || 'Ticket status',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.assignment || 'Assignment',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.actions || '',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
if (!cell) return '';
|
||||
let buttons = '';
|
||||
// Assign/Edit button (only if user can manage)
|
||||
if (canManage) {
|
||||
// raw-html-ok: all values passed through escapeHtml, cell data is from our own API
|
||||
const data = escapeHtml(JSON.stringify(cell));
|
||||
const buttonLabel = cell.status === 'open'
|
||||
? escapeHtml(labels.assign || 'Assign')
|
||||
: escapeHtml(labels.edit || 'Edit');
|
||||
const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil';
|
||||
buttons += `<button type="button" class="secondary outline small" data-update-assign='${data}' data-tooltip="${buttonLabel}" data-tooltip-pos="top" aria-label="${buttonLabel}"><i class="bi ${icon}"></i></button>`;
|
||||
}
|
||||
// Gitea link button (if URL exists)
|
||||
const giteaUrl = String(cell.gitea_path || '').trim();
|
||||
if (giteaUrl !== '') {
|
||||
const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea');
|
||||
buttons += `<a href="${escapeHtml(giteaUrl)}" target="_blank" rel="noopener" role="button" class="secondary outline small" data-tooltip="${giteaLabel}" data-tooltip-pos="top" aria-label="${giteaLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`;
|
||||
}
|
||||
if (buttons === '') return '';
|
||||
return gridjs.html(`<span class="app-inline-actions">${buttons}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.category || 'Type',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.debitor || 'Customer',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const name = escapeHtml(String(cell?.name || ''));
|
||||
const url = String(cell?.url || '').trim();
|
||||
if (name === '') return gridjs.html('<span class="text-muted">—</span>');
|
||||
const style = 'display:inline-block;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom';
|
||||
if (url === '') return gridjs.html(`<span style="${style}" title="${name}">${name}</span>`);
|
||||
return gridjs.html(`<a href="${escapeHtml(url)}" style="${style}" title="${name}">${name}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.domain || 'Domain', sort: false },
|
||||
{ name: labels.createdOn || 'Created', sort: false },
|
||||
],
|
||||
paginationLimit: 50,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => {
|
||||
const rows = data.rows || data.data || [];
|
||||
// Column order: Ticket | Ticket-Status | Assignment | Actions | Type | Customer | Domain | Created
|
||||
return rows.map((row) => [
|
||||
{ no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' },
|
||||
{ label: row.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' },
|
||||
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
|
||||
const listResult = initListPage({
|
||||
grid: {
|
||||
gridjs,
|
||||
container: '#helpdesk-updates-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/updates-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
ticket_no: row.ticket_no || '',
|
||||
debitor_no: row.debitor_no || '',
|
||||
debitor_name: row.debitor_name || '',
|
||||
category_code: row.category_code || '',
|
||||
domain_no: row.domain_no || '',
|
||||
domain_url: row.domain_url || '',
|
||||
gitea_path: row.gitea_path || '',
|
||||
notes: row.notes || '',
|
||||
status: row.status || 'open',
|
||||
name: labels.ticketNo || 'Ticket',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const no = escapeHtml(String(cell?.no || ''));
|
||||
const url = String(cell?.url || '').trim();
|
||||
const desc = escapeHtml(String(cell?.description || ''));
|
||||
const tip = desc ? ` data-tooltip="${desc}" data-tooltip-pos="top"` : '';
|
||||
if (no === '') return gridjs.html('<span class="text-muted">—</span>');
|
||||
if (url === '') return gridjs.html(`<span${tip}>${no}</span>`);
|
||||
return gridjs.html(`<a href="${escapeHtml(url)}"${tip}>${no}</a>`);
|
||||
},
|
||||
},
|
||||
{ label: row.category_label || '', variant: row.category_variant || 'neutral' },
|
||||
{ name: row.debitor_name || '', url: row.debitor_url || '' },
|
||||
row.domain_url || '',
|
||||
row.created_on || '',
|
||||
]);
|
||||
{
|
||||
name: labels.ticketState || 'Ticket status',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.assignment || 'Assignment',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.actions || '',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
if (!cell) return '';
|
||||
let buttons = '';
|
||||
if (canManage) {
|
||||
// raw-html-ok: all values passed through escapeHtml, cell data is from our own API
|
||||
const data = escapeHtml(JSON.stringify(cell));
|
||||
const buttonLabel = cell.status === 'open'
|
||||
? escapeHtml(labels.assign || 'Assign')
|
||||
: escapeHtml(labels.edit || 'Edit');
|
||||
const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil';
|
||||
buttons += `<button type="button" class="secondary outline small" data-update-assign='${data}' data-tooltip="${buttonLabel}" data-tooltip-pos="top" aria-label="${buttonLabel}"><i class="bi ${icon}"></i></button>`;
|
||||
}
|
||||
const giteaUrl = String(cell.gitea_path || '').trim();
|
||||
if (giteaUrl !== '') {
|
||||
const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea');
|
||||
buttons += `<a href="${escapeHtml(giteaUrl)}" target="_blank" rel="noopener" role="button" class="secondary outline small" data-tooltip="${giteaLabel}" data-tooltip-pos="top" aria-label="${giteaLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`;
|
||||
}
|
||||
if (buttons === '') return '';
|
||||
return gridjs.html(`<span class="app-inline-actions">${buttons}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.category || 'Type',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.debitor || 'Customer',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const name = escapeHtml(String(cell?.name || ''));
|
||||
const url = String(cell?.url || '').trim();
|
||||
if (name === '') return gridjs.html('<span class="text-muted">—</span>');
|
||||
const style = 'display:inline-block;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom';
|
||||
if (url === '') return gridjs.html(`<span style="${style}" title="${name}">${name}</span>`);
|
||||
return gridjs.html(`<a href="${escapeHtml(url)}" style="${style}" title="${name}">${name}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.domain || 'Domain', sort: false },
|
||||
{ name: labels.createdOn || 'Created', sort: false },
|
||||
],
|
||||
paginationLimit: 50,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => {
|
||||
const rows = data.rows || data.data || [];
|
||||
return rows.map((row) => [
|
||||
{ no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' },
|
||||
{ label: row.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' },
|
||||
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
|
||||
{
|
||||
ticket_no: row.ticket_no || '',
|
||||
debitor_no: row.debitor_no || '',
|
||||
debitor_name: row.debitor_name || '',
|
||||
category_code: row.category_code || '',
|
||||
domain_no: row.domain_no || '',
|
||||
domain_url: row.domain_url || '',
|
||||
gitea_path: row.gitea_path || '',
|
||||
notes: row.notes || '',
|
||||
status: row.status || 'open',
|
||||
},
|
||||
{ label: row.category_label || '', variant: row.category_variant || 'neutral' },
|
||||
{ name: row.debitor_name || '', url: row.debitor_url || '' },
|
||||
row.domain_url || '',
|
||||
row.created_on || '',
|
||||
]);
|
||||
},
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
},
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
};
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
@@ -140,28 +134,23 @@ if (config) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed', { module: 'helpdesk-updates', component: 'grid' });
|
||||
const gridConfig = listResult?.gridConfig || null;
|
||||
if (!gridConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh button — bypasses session cache and reloads grid data from BC
|
||||
const refreshButton = document.getElementById('updates-refresh-button');
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', async () => {
|
||||
refreshButton.disabled = true;
|
||||
refreshButton.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
// Ping the data endpoint with refresh=1 to invalidate the cache
|
||||
const refreshUrl = new URL(config.dataUrl, appBase);
|
||||
refreshUrl.searchParams.set('refresh', '1');
|
||||
await fetch(refreshUrl.toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
|
||||
});
|
||||
// Now force the grid to re-render (next fetch will get fresh data)
|
||||
await getJson(refreshUrl.toString());
|
||||
gridConfig?.grid?.forceRender();
|
||||
} catch {
|
||||
// Ignore — grid will still show last data
|
||||
showAsyncFlash('error', labels.refreshError || labels.assignError || 'Refresh failed');
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
refreshButton.removeAttribute('aria-busy');
|
||||
@@ -169,157 +158,155 @@ if (config) {
|
||||
});
|
||||
}
|
||||
|
||||
// Assignment dialog logic
|
||||
if (canManage) {
|
||||
const dialog = document.getElementById('update-assign-dialog');
|
||||
const form = document.getElementById('update-assign-form');
|
||||
const contextEl = document.getElementById('update-assign-context');
|
||||
const domainSelect = document.getElementById('update-assign-domain');
|
||||
const giteaInput = document.getElementById('update-assign-gitea');
|
||||
const notesInput = document.getElementById('update-assign-notes');
|
||||
const ticketNoInput = document.getElementById('update-assign-ticket-no');
|
||||
const debitorNoInput = document.getElementById('update-assign-debitor-no');
|
||||
const debitorNameInput = document.getElementById('update-assign-debitor-name');
|
||||
const categoryCodeInput = document.getElementById('update-assign-category-code');
|
||||
const domainUrlInput = document.getElementById('update-assign-domain-url');
|
||||
const submitButton = document.getElementById('update-assign-submit');
|
||||
|
||||
if (dialog && form) {
|
||||
const closeDialog = () => {
|
||||
try { dialog.close(); } catch { /* no-op */ }
|
||||
document.body.classList.remove('modal-is-open');
|
||||
};
|
||||
|
||||
// Close buttons
|
||||
dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => {
|
||||
button.addEventListener('click', (e) => { e.preventDefault(); closeDialog(); });
|
||||
});
|
||||
dialog.addEventListener('cancel', (e) => { e.preventDefault(); closeDialog(); });
|
||||
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); });
|
||||
|
||||
// Domain select change -> update hidden domain_url
|
||||
domainSelect.addEventListener('change', () => {
|
||||
const selected = domainSelect.options[domainSelect.selectedIndex];
|
||||
domainUrlInput.value = selected?.dataset?.url || '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Populate the domain <select> with options loaded from the API.
|
||||
* Uses textContent and DOM methods instead of innerHTML to avoid XSS.
|
||||
*/
|
||||
const populateDomainSelect = (domains, preselectedNo) => {
|
||||
domainSelect.textContent = '';
|
||||
if (!Array.isArray(domains) || domains.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = labels.noDomains || 'No domains found';
|
||||
domainSelect.appendChild(opt);
|
||||
} else {
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = labels.selectDomain || 'Select domain...';
|
||||
domainSelect.appendChild(placeholder);
|
||||
for (const d of domains) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.value || '';
|
||||
opt.textContent = d.label || d.value || '';
|
||||
opt.dataset.url = d.url || '';
|
||||
if (preselectedNo && d.value === preselectedNo) {
|
||||
opt.selected = true;
|
||||
domainUrlInput.value = d.url || '';
|
||||
}
|
||||
domainSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
domainSelect.disabled = false;
|
||||
};
|
||||
|
||||
// Open dialog on assign button click (delegated)
|
||||
document.addEventListener('click', async (e) => {
|
||||
const button = e.target.closest('[data-update-assign]');
|
||||
if (!button) return;
|
||||
|
||||
let data;
|
||||
try { data = JSON.parse(button.dataset.updateAssign); } catch { return; }
|
||||
|
||||
// Populate form
|
||||
ticketNoInput.value = data.ticket_no || '';
|
||||
debitorNoInput.value = data.debitor_no || '';
|
||||
debitorNameInput.value = data.debitor_name || '';
|
||||
categoryCodeInput.value = data.category_code || '';
|
||||
giteaInput.value = data.gitea_path || '';
|
||||
notesInput.value = data.notes || '';
|
||||
domainUrlInput.value = data.domain_url || '';
|
||||
|
||||
const categoryDisplay = data.category_code === 'UPDATE-HF'
|
||||
? (labels.hotfix || 'Hotfix')
|
||||
: (labels.update || 'Update');
|
||||
contextEl.textContent = `${data.ticket_no} \u00b7 ${data.debitor_name} \u00b7 ${categoryDisplay}`;
|
||||
|
||||
// Show loading state in domain select
|
||||
domainSelect.textContent = '';
|
||||
const loadingOpt = document.createElement('option');
|
||||
loadingOpt.value = '';
|
||||
loadingOpt.textContent = labels.loadingDomains || 'Loading...';
|
||||
domainSelect.appendChild(loadingOpt);
|
||||
domainSelect.disabled = true;
|
||||
|
||||
document.body.classList.add('modal-is-open');
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const domainsUrl = new URL(config.domainsDataUrl, appBase);
|
||||
domainsUrl.searchParams.set('debitor_no', data.debitor_no);
|
||||
const res = await fetch(domainsUrl.toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
|
||||
});
|
||||
const domains = await res.json();
|
||||
populateDomainSelect(domains, data.domain_no);
|
||||
} catch {
|
||||
populateDomainSelect([], null);
|
||||
}
|
||||
});
|
||||
|
||||
// Form submit
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
submitButton.disabled = true;
|
||||
submitButton.setAttribute('aria-busy', 'true');
|
||||
|
||||
const formData = new URLSearchParams(new FormData(form));
|
||||
if (config.csrfKey) {
|
||||
formData.set(config.csrfKey, config.csrfToken || '');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL(config.assignUrl, appBase).toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
const result = await res.json().catch(() => null);
|
||||
|
||||
if (res.ok && result?.ok) {
|
||||
closeDialog();
|
||||
gridConfig?.grid?.forceRender();
|
||||
} else {
|
||||
const errorMsg = result?.errors?.general || labels.assignError || 'Failed to assign';
|
||||
window.alert(errorMsg);
|
||||
}
|
||||
} catch {
|
||||
window.alert(labels.assignError || 'Failed to assign');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.removeAttribute('aria-busy');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!canManage) {
|
||||
return gridConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = document.getElementById('update-assign-dialog');
|
||||
const form = document.getElementById('update-assign-form');
|
||||
const contextEl = document.getElementById('update-assign-context');
|
||||
const domainSelect = document.getElementById('update-assign-domain');
|
||||
const giteaInput = document.getElementById('update-assign-gitea');
|
||||
const notesInput = document.getElementById('update-assign-notes');
|
||||
const ticketNoInput = document.getElementById('update-assign-ticket-no');
|
||||
const debitorNoInput = document.getElementById('update-assign-debitor-no');
|
||||
const debitorNameInput = document.getElementById('update-assign-debitor-name');
|
||||
const categoryCodeInput = document.getElementById('update-assign-category-code');
|
||||
const domainUrlInput = document.getElementById('update-assign-domain-url');
|
||||
const submitButton = document.getElementById('update-assign-submit');
|
||||
|
||||
if (!dialog || !form || !domainSelect || !domainUrlInput || !submitButton) {
|
||||
return gridConfig;
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
try { dialog.close(); } catch { /* no-op */ }
|
||||
document.body.classList.remove('modal-is-open');
|
||||
};
|
||||
|
||||
dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
closeDialog();
|
||||
});
|
||||
});
|
||||
dialog.addEventListener('cancel', (event) => {
|
||||
event.preventDefault();
|
||||
closeDialog();
|
||||
});
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog) {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
domainSelect.addEventListener('change', () => {
|
||||
const selected = domainSelect.options[domainSelect.selectedIndex];
|
||||
domainUrlInput.value = selected?.dataset?.url || '';
|
||||
});
|
||||
|
||||
const populateDomainSelect = (domains, preselectedNo) => {
|
||||
domainSelect.textContent = '';
|
||||
if (!Array.isArray(domains) || domains.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = labels.noDomains || 'No domains found';
|
||||
domainSelect.appendChild(opt);
|
||||
} else {
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = labels.selectDomain || 'Select domain...';
|
||||
domainSelect.appendChild(placeholder);
|
||||
for (const domain of domains) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = domain.value || '';
|
||||
opt.textContent = domain.label || domain.value || '';
|
||||
opt.dataset.url = domain.url || '';
|
||||
if (preselectedNo && domain.value === preselectedNo) {
|
||||
opt.selected = true;
|
||||
domainUrlInput.value = domain.url || '';
|
||||
}
|
||||
domainSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
domainSelect.disabled = false;
|
||||
};
|
||||
|
||||
document.addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('[data-update-assign]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(button.dataset.updateAssign);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
ticketNoInput.value = data.ticket_no || '';
|
||||
debitorNoInput.value = data.debitor_no || '';
|
||||
debitorNameInput.value = data.debitor_name || '';
|
||||
categoryCodeInput.value = data.category_code || '';
|
||||
giteaInput.value = data.gitea_path || '';
|
||||
notesInput.value = data.notes || '';
|
||||
domainUrlInput.value = data.domain_url || '';
|
||||
|
||||
const categoryDisplay = data.category_code === 'UPDATE-HF'
|
||||
? (labels.hotfix || 'Hotfix')
|
||||
: (labels.update || 'Update');
|
||||
contextEl.textContent = `${data.ticket_no} - ${data.debitor_name} - ${categoryDisplay}`;
|
||||
|
||||
domainSelect.textContent = '';
|
||||
const loadingOpt = document.createElement('option');
|
||||
loadingOpt.value = '';
|
||||
loadingOpt.textContent = labels.loadingDomains || 'Loading...';
|
||||
domainSelect.appendChild(loadingOpt);
|
||||
domainSelect.disabled = true;
|
||||
|
||||
document.body.classList.add('modal-is-open');
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const domainsUrl = new URL(config.domainsDataUrl, appBase);
|
||||
domainsUrl.searchParams.set('debitor_no', data.debitor_no);
|
||||
const domains = await getJson(domainsUrl.toString());
|
||||
populateDomainSelect(domains, data.domain_no);
|
||||
} catch {
|
||||
populateDomainSelect([], null);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
submitButton.disabled = true;
|
||||
submitButton.setAttribute('aria-busy', 'true');
|
||||
|
||||
const formData = new URLSearchParams(new FormData(form));
|
||||
if (config.csrfKey) {
|
||||
formData.set(config.csrfKey, config.csrfToken || '');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await postForm(new URL(config.assignUrl, appBase).toString(), formData);
|
||||
if (result?.ok === true) {
|
||||
closeDialog();
|
||||
gridConfig?.grid?.forceRender();
|
||||
showAsyncFlash('success', labels.assignSuccess || labels.saved || 'Saved');
|
||||
} else {
|
||||
const errorMsg = result?.errors?.general || labels.assignError || 'Failed to assign';
|
||||
showAsyncFlash('error', errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error?.payload?.errors?.general || labels.assignError || 'Failed to assign';
|
||||
showAsyncFlash('error', errorMsg);
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.removeAttribute('aria-busy');
|
||||
}
|
||||
});
|
||||
|
||||
return gridConfig;
|
||||
},
|
||||
});
|
||||
|
||||
116
tests/Architecture/FrontendJsContractsTest.php
Normal file
116
tests/Architecture/FrontendJsContractsTest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,17 @@ trait FrontendRuntimeContractSupport
|
||||
'web/js/components/app-auto-submit.js',
|
||||
'modules/bookmarks/web/js/components/app-bookmark-save.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-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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ class ListTemplateContractTest extends TestCase
|
||||
$usesPartial || str_contains($content, 'renderGridFilterToolbar('),
|
||||
$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->assertSame(0, preg_match('/<select id=\"[^\"]*-filter\"/', $content), $file);
|
||||
$this->assertSame(0, preg_match('/<input[^>]*id=\"[^\"]*-filter\"/', $content), $file);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { initTabs } from './components/app-tabs.js';
|
||||
import { initSessionWarning } from './components/app-session-warning.js';
|
||||
import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js';
|
||||
import { initFileUpload } from './components/app-file-upload.js';
|
||||
import { initLookupFields } from './components/app-lookup-field.js';
|
||||
|
||||
const runtime = createComponentRuntime({
|
||||
root: document,
|
||||
@@ -193,6 +194,13 @@ const bootRuntime = async () => {
|
||||
selector: '[data-app-component="file-upload"]',
|
||||
configPath: 'components.fileUpload',
|
||||
});
|
||||
runtime.register('lookup-field', initLookupFields, {
|
||||
scope: 'global',
|
||||
configPath: 'components.lookupField',
|
||||
defaultConfig: {
|
||||
selector: '[data-app-lookup]',
|
||||
},
|
||||
});
|
||||
await mountModuleComponentsByPhase('early');
|
||||
|
||||
// Phase B: structure-defining components.
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
/**
|
||||
* 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) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const url = container.dataset.lookupUrl || '';
|
||||
const minChars = parseInt(container.dataset.lookupMinChars || '2', 10);
|
||||
const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10);
|
||||
@@ -28,10 +23,28 @@ export function initLookupField(container) {
|
||||
const initialDisplay = container.dataset.lookupInitialDisplay || '';
|
||||
const initialValue = container.dataset.lookupInitialValue || '';
|
||||
|
||||
if (!url) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const hiddenValue = container.querySelector('[data-lookup-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');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
@@ -59,15 +72,15 @@ export function initLookupField(container) {
|
||||
|
||||
container.appendChild(wrap);
|
||||
container.appendChild(dropdown);
|
||||
cleanupNodes.push(wrap, dropdown);
|
||||
|
||||
// State
|
||||
let results = [];
|
||||
let activeIndex = -1;
|
||||
let debounceTimer = null;
|
||||
let abortController = null;
|
||||
let blurTimer = null;
|
||||
let selected = false;
|
||||
let activeRequestController = null;
|
||||
|
||||
// Restore initial state
|
||||
if (initialValue && initialDisplay) {
|
||||
input.value = initialDisplay;
|
||||
selected = true;
|
||||
@@ -75,34 +88,30 @@ export function initLookupField(container) {
|
||||
showClear();
|
||||
}
|
||||
|
||||
// ── Indicator helpers ──────────────────────────────────────────────
|
||||
|
||||
function showSpinner() {
|
||||
clearElement(indicator);
|
||||
clearNode(indicator);
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'app-lookup-field-spinner';
|
||||
indicator.appendChild(spinner);
|
||||
}
|
||||
|
||||
function showClear() {
|
||||
clearElement(indicator);
|
||||
clearNode(indicator);
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'app-lookup-field-clear';
|
||||
button.setAttribute('aria-label', 'Clear');
|
||||
button.addEventListener('click', clearSelection);
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'bi bi-x-lg';
|
||||
button.appendChild(icon);
|
||||
on(button, 'click', clearSelection);
|
||||
indicator.appendChild(button);
|
||||
}
|
||||
|
||||
function hideIndicator() {
|
||||
clearElement(indicator);
|
||||
clearNode(indicator);
|
||||
}
|
||||
|
||||
// ── Dropdown helpers ───────────────────────────────────────────────
|
||||
|
||||
function openDropdown() {
|
||||
dropdown.hidden = false;
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
@@ -114,67 +123,6 @@ export function initLookupField(container) {
|
||||
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) {
|
||||
const options = dropdown.querySelectorAll('.app-lookup-field-option');
|
||||
options.forEach((opt, i) => {
|
||||
@@ -182,18 +130,17 @@ export function initLookupField(container) {
|
||||
});
|
||||
activeIndex = index;
|
||||
|
||||
// Scroll active into view
|
||||
const active = options[index];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection ──────────────────────────────────────────────────────
|
||||
|
||||
function selectItem(index) {
|
||||
const item = results[index];
|
||||
if (!item) return;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = item[displayKey] || '';
|
||||
input.dataset.lookupSelected = '';
|
||||
@@ -225,38 +172,88 @@ export function initLookupField(container) {
|
||||
container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true }));
|
||||
}
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────
|
||||
function renderResults(items) {
|
||||
results = items;
|
||||
activeIndex = -1;
|
||||
clearNode(dropdown);
|
||||
|
||||
function fetchResults(query) {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
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 = 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();
|
||||
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
fetch(url + separator + 'q=' + encodeURIComponent(query), {
|
||||
signal: abortController.signal,
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
hideIndicator();
|
||||
const items = Array.isArray(data) ? data : (data.data || []);
|
||||
renderResults(items);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
hideIndicator();
|
||||
renderError();
|
||||
try {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
const data = await getJson(`${url}${separator}q=${encodeURIComponent(query)}`, {
|
||||
signal: activeRequestController.signal,
|
||||
});
|
||||
hideIndicator();
|
||||
const items = Array.isArray(data) ? data : (data?.data || []);
|
||||
renderResults(items);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
hideIndicator();
|
||||
renderError();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input events ───────────────────────────────────────────────────
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
on(input, 'input', () => {
|
||||
if (selected) {
|
||||
selected = false;
|
||||
delete input.dataset.lookupSelected;
|
||||
@@ -271,59 +268,91 @@ export function initLookupField(container) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => fetchResults(query), debounceMs);
|
||||
window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
void fetchResults(query);
|
||||
}, debounceMs);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
on(input, 'keydown', (event) => {
|
||||
if (dropdown.hidden) return;
|
||||
|
||||
const options = dropdown.querySelectorAll('.app-lookup-field-option');
|
||||
if (!options.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0) {
|
||||
selectItem(activeIndex);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
on(input, 'focus', () => {
|
||||
if (input.value.trim().length >= minChars && !selected && results.length > 0) {
|
||||
openDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
// Delay to allow mousedown on option to fire first
|
||||
setTimeout(() => closeDropdown(), 150);
|
||||
on(input, 'blur', () => {
|
||||
window.clearTimeout(blurTimer);
|
||||
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() {
|
||||
document.querySelectorAll('[data-app-lookup]').forEach((el) => {
|
||||
if (el.dataset.lookupInitialized) return;
|
||||
const instances = [];
|
||||
elements.forEach((el) => {
|
||||
if (el.dataset.lookupInitialized) {
|
||||
return;
|
||||
}
|
||||
el.dataset.lookupInitialized = '1';
|
||||
initLookupField(el);
|
||||
instances.push(initLookupField(el));
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAll);
|
||||
} else {
|
||||
initAll();
|
||||
return {
|
||||
destroy: () => {
|
||||
instances.forEach((instance) => {
|
||||
if (instance && typeof instance.destroy === 'function') {
|
||||
instance.destroy();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
266
web/js/core/app-http.js
Normal file
266
web/js/core/app-http.js
Normal 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 ?? {}),
|
||||
});
|
||||
};
|
||||
76
web/js/core/app-list-page-module.js
Normal file
76
web/js/core/app-list-page-module.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 getAppBase = () => {
|
||||
@@ -125,15 +127,7 @@ export const buildBadgeList = (items, options = {}) => {
|
||||
|
||||
export const postAction = async (url, csrf, data = {}) => {
|
||||
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch'
|
||||
},
|
||||
body
|
||||
});
|
||||
return postFormRequest(url, body);
|
||||
};
|
||||
|
||||
export const badgeHtml = (gridjs, value = '') => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { confirmDialog } from '../core/app-confirm-dialog.js';
|
||||
import { warnOnce } from '../core/app-dom.js';
|
||||
import { postForm } from '../core/app-http.js';
|
||||
|
||||
export function initUsersListPage(options = {}) {
|
||||
const {
|
||||
@@ -29,15 +30,7 @@ export function initUsersListPage(options = {}) {
|
||||
|
||||
const postAction = async (url, data = {}) => {
|
||||
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch'
|
||||
},
|
||||
body
|
||||
});
|
||||
return postForm(url, body);
|
||||
};
|
||||
|
||||
const exportButton = document.querySelector(exportSelector);
|
||||
@@ -118,9 +111,11 @@ export function initUsersListPage(options = {}) {
|
||||
|
||||
const executeBulk = async () => {
|
||||
const url = resolveBulkUrl(action);
|
||||
const response = await postAction(url, { uuids: ids.join(',') });
|
||||
const data = await response.json().catch(() => null);
|
||||
if (response.ok && data && data.ok) {
|
||||
try {
|
||||
const data = await postAction(url, { uuids: ids.join(',') });
|
||||
if (!data || data.ok !== true) {
|
||||
throw new Error('Bulk action failed');
|
||||
}
|
||||
gridConfig.selection?.clear?.();
|
||||
gridConfig.grid?.forceRender();
|
||||
|
||||
@@ -136,7 +131,7 @@ export function initUsersListPage(options = {}) {
|
||||
showFlash('success', text);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} catch {
|
||||
if (showFlash && labels.bulkMessages) {
|
||||
const msg = labels.bulkMessages[action];
|
||||
if (msg?.error) {
|
||||
|
||||
Reference in New Issue
Block a user