Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-domain-detail.js
fs 693d33a0c8 feat(helpdesk): add domain detail page with customer, contract, and handover sections
New domain detail page showing customer info, contract data, linked handovers,
updates section, and related domains for the same customer. Data loaded async
with skeleton loading states. Includes DomainDetailService and tests.

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

322 lines
12 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 { 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 container = document.querySelector('.app-details-container[data-domain-no]');
if (container) {
init(container);
}
function init(container) {
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 res = await fetch(detailUrl + '?' + params, { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok) {
showError(data.error || t('Network error — please try again'));
return;
}
renderCustomerInfo(data.customer);
renderContractInfo(data.contract);
renderHandoversList(data.handovers || []);
renderUpdatesList(data.updates || []);
renderRelatedDomains(data.related_domains || []);
if (loadingEl) loadingEl.hidden = true;
if (contentEl) contentEl.hidden = false;
} catch {
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 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;
}
}