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