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>
365 lines
13 KiB
JavaScript
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);
|
|
}
|