Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-domain-detail.js
fs fb86c02427 fix(helpdesk): repair security-level persistence, enrichment, and list UX
Saving security levels appeared broken in both the domain detail view and
the domains grid. Root causes:

- Repository never unwrapped MintyPHP DB rows (nested by table name), so
  reads always returned defaults even though writes succeeded.
- Batch queries bound tenant_id last while SQL expected it first, so
  list/detail enrichment for the grid matched nothing.
- Detail page AJAX submit looked up CSRF via a non-existent data attribute
  and dropped the note field entirely.
- Endpoint used a hand-rolled CSRF check instead of Session::checkCsrfToken().

Fixes:
- DomainSecurityLevelRepository: use RepositoryArrayHelper::unwrap(List),
  swap param order in listLevelsByDomainNos / listDetailsByDomainNos,
  centralize table name in a const.
- security-level-data endpoint: canonical Session::checkCsrfToken(),
  unified JSON/PRG error path with proper HTTP status codes and flash.
- Detail page: drop broken AJAX hijack, rely on native form POST + PRG
  (GR-CORE-012).
- Grid list: refetch via gridRef.grid.forceRender() after dialog save
  instead of DOM-only mutation, so Grid.js internal state stays in sync.
- Add dedicated Note column next to the badge with ellipsis + title
  tooltip.
- Replace console.* in the dialog with showAsyncFlash for user-visible
  errors; add i18n keys (de/en) for all error messages.
- Drop unused postAction import and TENANT_ID_OTHER test constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:04:21 +02:00

365 lines
13 KiB
JavaScript

/**
* Helpdesk domain detail page — read-only dashboard.
*
* Fetches customer, contract, handover, and related domain data
* from a single async endpoint and renders all sections.
*
* All dynamic values are escaped via the esc() helper before insertion
* into innerHTML. This matches the pattern used in helpdesk-detail.js
* and other helpdesk JS modules in this codebase.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
import { renderEmptyState } from './helpdesk-empty-state.js';
function esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return String(dateStr);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
const STATUS_LABEL_MAP = {
draft: 'Draft',
in_progress: 'In progress',
completed: 'Completed',
archived: 'Archived',
};
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-domain-detail"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-domain-detail"]');
};
function init(container) {
const requestController = new AbortController();
const domainNo = container.dataset.domainNo || '';
const customerNo = container.dataset.customerNo || '';
const detailUrl = container.dataset.detailUrl || '';
const debitorBaseUrl = container.dataset.debitorBaseUrl || '';
const handoverEditBaseUrl = container.dataset.handoverEditBaseUrl || '';
const domainBaseUrl = container.dataset.domainBaseUrl || '';
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
if (!domainNo || !detailUrl) return;
const i18nEl = document.getElementById('helpdesk-domain-i18n');
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
const t = (key) => i18n[key] || key;
fetchDomainDetails();
async function fetchDomainDetails() {
const loadingEl = document.getElementById('domain-loading');
const contentEl = document.getElementById('domain-content');
try {
const params = new URLSearchParams({ domainNo, customerNo });
const data = await getJson(`${detailUrl}?${params.toString()}`, {
signal: requestController.signal,
});
if (!data.ok) {
showError(data.error || t('Network error — please try again'));
return;
}
renderCustomerInfo(data.customer);
renderContractInfo(data.contract);
renderSecurityLevel(data.security_level, data.security_level_variant);
renderHandoversList(data.handovers || []);
renderUpdatesList(data.updates || []);
renderRelatedDomains(data.related_domains || []);
if (loadingEl) loadingEl.hidden = true;
if (contentEl) contentEl.hidden = false;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showError(t('Network error — please try again'));
}
}
function showError(message) {
const loadingEl = document.getElementById('domain-loading');
if (loadingEl) {
// safe-html: esc() escapes all dynamic content
loadingEl.innerHTML = '<div class="notice" data-variant="error" role="alert"><p>' + esc(message) + '</p></div>';
loadingEl.removeAttribute('aria-busy');
}
}
function renderCustomerInfo(customer) {
const el = document.getElementById('domain-customer-info');
if (!el) return;
if (!customer) {
el.textContent = '';
const p = document.createElement('p');
p.className = 'text-muted';
p.textContent = t('No contract information available');
el.appendChild(p);
return;
}
const name = customer.Name || '';
const no = customer.No || '';
const address = customer.Address || '';
const city = customer.City || '';
const postCode = customer.Post_Code || '';
const phone = customer.Phone_No || '';
const email = customer.E_Mail || '';
const salesperson = customer.Salesperson_Code || '';
const supportType = customer.Support_Type || '';
const debitorLink = no && debitorBaseUrl
? '<a href="' + esc(debitorBaseUrl + encodeURIComponent(no)) + '">' + esc(name || no) + '</a>'
: esc(name || no);
// safe-html: all dynamic values are escaped via esc() before insertion
el.innerHTML =
'<dl class="helpdesk-ticket-meta">' +
'<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Name')) + '</dt><dd>' + debitorLink + '</dd></div>' +
'<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Customer No.')) + '</dt><dd>' + esc(no) + '</dd></div>' +
'<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Phone')) + '</dt><dd>' + (phone ? '<a href="tel:' + esc(phone) + '">' + esc(phone) + '</a>' : '—') + '</dd></div>' +
(salesperson ? '<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Salesperson')) + '</dt><dd>' + esc(salesperson) + '</dd></div>' : '') +
(supportType ? '<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Support type')) + '</dt><dd>' + esc(supportType) + '</dd></div>' : '') +
'</dl>';
}
function renderContractInfo(contract) {
const section = document.getElementById('domain-contract-section');
const el = document.getElementById('domain-contract-info');
if (!el || !section) return;
if (!contract || !contract.contract_type) {
section.hidden = true;
return;
}
section.hidden = false;
// safe-html: all dynamic values are escaped via esc() before insertion
el.innerHTML =
'<dl class="helpdesk-ticket-meta">' +
'<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Contract type')) + '</dt><dd>' + esc(contract.contract_type) + '</dd></div>' +
'<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Description')) + '</dt><dd>' + esc(contract.contract_description || '—') + '</dd></div>' +
'<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Contract No.')) + '</dt><dd>' + esc(contract.contract_no || '—') + '</dd></div>' +
(contract.header_state ? '<div class="helpdesk-ticket-meta-item"><dt>' + esc(t('Contract state')) + '</dt><dd>' + esc(contract.header_state) + '</dd></div>' : '') +
'</dl>';
}
function renderSecurityLevel(level, variant) {
const badge = document.getElementById('domain-security-badge');
const select = document.querySelector('[data-security-level-select]');
if (!badge && !select) return;
const levelLabels = { niedrig: t('Low'), normal: t('Normal'), hoch: t('High'), kritisch: t('Critical') };
const label = levelLabels[level] ?? level;
if (badge) {
badge.textContent = label;
badge.dataset.variant = variant || 'neutral';
}
if (select) {
select.value = level || 'normal';
}
}
function renderHandoversList(handovers) {
const el = document.getElementById('domain-handovers-list');
const countEl = document.getElementById('domain-handover-count');
if (!el) return;
if (countEl) countEl.textContent = String(handovers.length);
if (!handovers || handovers.length === 0) {
el.innerHTML = renderEmptyState({ // safe-html: renderEmptyState escapes internally
message: t('No handovers for this domain yet'),
size: 'compact',
icon: true,
});
return;
}
const statusVariantMap = {
draft: 'neutral',
in_progress: 'info',
completed: 'success',
archived: 'neutral',
};
const returnPath = 'helpdesk/domain/' + encodeURIComponent(domainNo);
let items = '';
for (const h of handovers) {
const editUrl = handoverEditBaseUrl + encodeURIComponent(h.id) + '?return=' + encodeURIComponent(returnPath);
const productLabel = h.product_name || h.product_bc_description || h.product_code || '—';
const statusVariant = statusVariantMap[h.status] || 'neutral';
const statusLabel = t(STATUS_LABEL_MAP[h.status] || h.status);
const meta = [h.created_by_name, formatDate(h.created_at)].filter(Boolean).join(' · ');
// safe-html: all dynamic values are escaped via esc()
items +=
'<a href="' + esc(editUrl) + '" class="helpdesk-domain-handover-item">' +
'<div class="helpdesk-domain-handover-item-main">' +
'<span class="helpdesk-domain-handover-item-product">' + esc(productLabel) + '</span>' +
'<span class="badge" data-variant="' + esc(statusVariant) + '">' + esc(statusLabel) + '</span>' +
'</div>' +
(meta ? '<div class="helpdesk-domain-handover-item-meta">' + esc(meta) + '</div>' : '') +
'</a>';
}
// safe-html: all content built from esc()-escaped values
el.innerHTML = '<div class="helpdesk-domain-handover-list">' + items + '</div>';
}
function renderUpdatesList(updates) {
const section = document.getElementById('domain-updates-section');
const el = document.getElementById('domain-updates-list');
const countEl = document.getElementById('domain-update-count');
if (!el || !section) return;
if (!updates || updates.length === 0) {
section.hidden = true;
return;
}
section.hidden = false;
if (countEl) countEl.textContent = String(updates.length);
// Build update items using DOM methods for safety
const listDiv = document.createElement('div');
listDiv.className = 'helpdesk-domain-handover-list';
for (const u of updates) {
const ticketNo = u.ticket_no || '';
const ticketUrl = ticketNo && ticketBaseUrl ? ticketBaseUrl + encodeURIComponent(ticketNo) : '';
const categoryLabel = u.category_label || u.category_code || '';
const categoryVariant = u.category_variant || 'neutral';
const giteaUrl = u.gitea_path || '';
const item = document.createElement('div');
item.className = 'helpdesk-domain-handover-item';
const main = document.createElement('div');
main.className = 'helpdesk-domain-handover-item-main';
const productSpan = document.createElement('span');
productSpan.className = 'helpdesk-domain-handover-item-product';
if (ticketUrl) {
const link = document.createElement('a');
link.href = ticketUrl;
link.textContent = ticketNo;
productSpan.appendChild(link);
} else {
productSpan.textContent = ticketNo;
}
main.appendChild(productSpan);
const badge = document.createElement('span');
badge.className = 'badge';
badge.dataset.variant = categoryVariant;
badge.textContent = categoryLabel;
main.appendChild(badge);
item.appendChild(main);
const metaParts = [];
if (giteaUrl) {
// Show last path segment as clickable link
const parts = giteaUrl.replace(/\/+$/, '').split('/');
const shortLabel = parts[parts.length - 1] || giteaUrl;
const giteaLink = document.createElement('a');
giteaLink.href = giteaUrl;
giteaLink.target = '_blank';
giteaLink.rel = 'noopener';
giteaLink.textContent = shortLabel;
metaParts.push(giteaLink);
}
const dateStr = formatDate(u.created_at);
if (dateStr && dateStr !== '\u2014') {
const dateSpan = document.createElement('span');
dateSpan.textContent = dateStr;
metaParts.push(dateSpan);
}
if (metaParts.length) {
const metaDiv = document.createElement('div');
metaDiv.className = 'helpdesk-domain-handover-item-meta';
metaParts.forEach((part, i) => {
if (i > 0) metaDiv.appendChild(document.createTextNode(' \u00b7 '));
metaDiv.appendChild(part);
});
item.appendChild(metaDiv);
}
listDiv.appendChild(item);
}
el.textContent = '';
el.appendChild(listDiv);
}
function renderRelatedDomains(domains) {
const section = document.getElementById('domain-related-section');
const el = document.getElementById('domain-related-domains');
if (!el || !section) return;
if (!domains || domains.length === 0) {
section.hidden = true;
return;
}
section.hidden = false;
const stateVariantMap = {
'Aktiv': 'success',
'In Vorbereitung': 'warning',
};
let items = '';
for (const d of domains) {
const url = d.URL || '';
const no = d.No || '';
const state = d.State || '';
const variant = stateVariantMap[state] || 'neutral';
const detailLink = domainBaseUrl + encodeURIComponent(no);
// safe-html: all dynamic values are escaped via esc()
items +=
'<a href="' + esc(detailLink) + '" class="helpdesk-domain-related-item">' +
'<span class="helpdesk-domain-related-item-url">' + esc(url || no) + '</span>' +
'<span class="badge small" data-variant="' + esc(variant) + '">' + esc(state) + '</span>' +
'</a>';
}
// safe-html: all content built from esc()-escaped values
el.innerHTML = items;
}
return {
destroy: () => {
requestController.abort();
},
};
}
export function initHelpdeskDomainDetail(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
return init(container);
}