diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DomainDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DomainDetailService.php new file mode 100644 index 0000000..0f0cdd8 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DomainDetailService.php @@ -0,0 +1,226 @@ +, error?: string} + */ + public function loadDomain(string $domainNo): array + { + $domainNo = trim($domainNo); + if ($domainNo === '') { + return ['status' => 'not_found']; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['status' => 'not_configured', 'error' => 'BC connection not configured']; + } + + try { + $domain = $this->bcODataGateway->getDomain($domainNo); + } catch (\Throwable) { + return ['status' => 'error', 'error' => 'BC connection failed']; + } + + if ($domain === null) { + return ['status' => 'not_found']; + } + + return [ + 'status' => 'success', + 'domain' => $domain, + ]; + } + + /** + * Load all detail data for a domain (for async data endpoint). + * + * @api Called from domain-detail-data endpoint + * @return array{ + * ok: bool, + * customer?: array|null, + * contract?: array|null, + * handovers?: list>, + * handover_stats?: array{total: int, draft: int, in_progress: int, completed: int, archived: int}, + * related_domains?: list>, + * error?: string + * } + */ + public function loadDomainDetails(string $domainNo, string $customerNo, int $tenantId): array + { + $domainNo = trim($domainNo); + $customerNo = trim($customerNo); + + if ($domainNo === '') { + return ['ok' => false, 'error' => 'Missing domain number']; + } + + $customer = null; + if ($customerNo !== '') { + try { + $customer = $this->bcODataGateway->getCustomer($customerNo); + } catch (\Throwable) { + // Best-effort — continue without customer data + } + } + + $contract = $this->loadContractForDomain($domainNo); + + $handovers = $this->handoverRepository->findByDomainNo($tenantId, $domainNo); + $handoverStats = $this->aggregateHandoverStats($handovers); + + $relatedDomains = []; + if ($customerNo !== '') { + try { + $allCustomerDomains = $this->bcODataGateway->getDomainsForCustomer($customerNo); + $relatedDomains = array_values(array_filter( + $allCustomerDomains, + static fn (array $d): bool => trim((string) ($d['No'] ?? '')) !== $domainNo + )); + } catch (\Throwable) { + // Best-effort — continue without related domains + } + } + + $updates = []; + if ($this->updateRepository !== null) { + $updates = $this->prepareUpdatesForResponse( + $this->updateRepository->findByDomainNo($tenantId, $domainNo) + ); + } + + return [ + 'ok' => true, + 'customer' => $customer, + 'contract' => $contract, + 'handovers' => $this->prepareHandoversForResponse($handovers), + 'handover_stats' => $handoverStats, + 'updates' => $updates, + 'related_domains' => $relatedDomains, + ]; + } + + /** + * Load contract line info for a specific domain from BC. + * + * @return array|null + */ + private function loadContractForDomain(string $domainNo): ?array + { + try { + $contractLines = $this->bcODataGateway->listDomainContractLines(); + } catch (\Throwable) { + return null; + } + + foreach ($contractLines as $line) { + $lineNo = trim((string) ($line['No'] ?? '')); + if ($lineNo === $domainNo) { + return [ + 'contract_type' => trim((string) ($line['PI_Header_Type'] ?? '')), + 'contract_no' => trim((string) ($line['Header_No'] ?? '')), + 'contract_description' => trim((string) ($line['PI_Header_Description'] ?? '')), + 'header_state' => trim((string) ($line['PI_Header_State'] ?? '')), + 'line_state' => trim((string) ($line['Line_State'] ?? '')), + ]; + } + } + + return null; + } + + /** + * @param list> $handovers + * @return array{total: int, draft: int, in_progress: int, completed: int, archived: int} + */ + private function aggregateHandoverStats(array $handovers): array + { + $stats = ['total' => 0, 'draft' => 0, 'in_progress' => 0, 'completed' => 0, 'archived' => 0]; + + foreach ($handovers as $handover) { + $stats['total']++; + $status = trim((string) ($handover['status'] ?? '')); + if (isset($stats[$status])) { + $stats[$status]++; + } + } + + return $stats; + } + + /** + * Prepare update rows for the JSON response. + * + * @param list> $updates + * @return list> + */ + private function prepareUpdatesForResponse(array $updates): array + { + $prepared = []; + foreach ($updates as $update) { + $categoryCode = (string) ($update['category_code'] ?? ''); + $status = (string) ($update['status'] ?? ''); + $prepared[] = [ + 'ticket_no' => (string) ($update['ticket_no'] ?? ''), + 'category_code' => $categoryCode, + 'category_label' => UpdateService::categoryLabel($categoryCode), + 'category_variant' => UpdateService::categoryVariant($categoryCode), + 'gitea_path' => (string) ($update['gitea_path'] ?? ''), + 'status' => $status, + 'status_label' => UpdateService::statusLabel($status), + 'status_variant' => UpdateService::statusVariant($status), + 'created_at' => (string) ($update['created_at'] ?? ''), + ]; + } + + return $prepared; + } + + /** + * Prepare handover rows for the JSON response (strip internal fields). + * + * @param list> $handovers + * @return list> + */ + private function prepareHandoversForResponse(array $handovers): array + { + $prepared = []; + foreach ($handovers as $handover) { + $prepared[] = [ + 'id' => (int) ($handover['id'] ?? 0), + 'product_code' => (string) ($handover['product_code'] ?? ''), + 'product_name' => (string) ($handover['product_name'] ?? ''), + 'product_bc_description' => (string) ($handover['product_bc_description'] ?? ''), + 'status' => (string) ($handover['status'] ?? ''), + 'created_by_name' => (string) ($handover['created_by_name'] ?? ''), + 'created_at' => (string) ($handover['created_at'] ?? ''), + ]; + } + + return $prepared; + } +} diff --git a/modules/helpdesk/pages/helpdesk/domain($id).php b/modules/helpdesk/pages/helpdesk/domain($id).php new file mode 100644 index 0000000..d0e167b --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/domain($id).php @@ -0,0 +1,59 @@ +loadDomain($domainNo); + +$resultStatus = (string) ($result['status'] ?? ''); + +if ($resultStatus === 'not_found') { + Flash::error(t('Domain not found'), 'helpdesk-domains', 'domain_not_found'); + Router::redirect('helpdesk/domains'); + + return; +} + +if ($resultStatus === 'not_configured') { + Flash::error(t('BC connection is not configured'), 'helpdesk-domains', 'not_configured'); + Router::redirect('helpdesk/domains'); + + return; +} + +$domain = $result['domain'] ?? []; +$hasError = $resultStatus === 'error'; +$errorMessage = $result['error'] ?? ''; + +$domainUrl = (string) ($domain['URL'] ?? ''); +$customerNo = (string) ($domain['Customer_No'] ?? ''); +$customerName = (string) ($domain['Customer_Name'] ?? ''); +$domainState = (string) ($domain['State'] ?? ''); +$domainAdministration = (string) ($domain['Administration'] ?? ''); + +$pageTitle = $domainUrl !== '' ? $domainUrl : $domainNo; + +Buffer::set('title', $pageTitle . ' — ' . t('Helpdesk')); +Buffer::set('style_groups', json_encode(['helpdesk'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], + ['label' => t('Domains'), 'path' => 'helpdesk/domains'], + ['label' => $pageTitle], +]; diff --git a/modules/helpdesk/pages/helpdesk/domain(default).phtml b/modules/helpdesk/pages/helpdesk/domain(default).phtml new file mode 100644 index 0000000..4e61bef --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/domain(default).phtml @@ -0,0 +1,234 @@ + 'success', + 'In Vorbereitung' => 'warning', +]; +$stateVariant = $stateVariantMap[$domainState] ?? 'neutral'; + +?> +
+
+ $pageTitle, + 'backHref' => lurl('helpdesk/domains'), + 'backTitle' => t('Back to domains'), + 'actions' => [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + + + + + + + +
+
+ +
+ + +
+ + +
+
+
+
+
+
+
+ + + +
+
+ + +
+ + + + +
+ + + + + + diff --git a/modules/helpdesk/pages/helpdesk/domain-detail-data().php b/modules/helpdesk/pages/helpdesk/domain-detail-data().php new file mode 100644 index 0000000..e997be5 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/domain-detail-data().php @@ -0,0 +1,29 @@ +query('domainNo', '')); +$customerNo = trim((string) $request->query('customerNo', '')); + +if ($domainNo === '') { + Router::json(['ok' => false, 'error' => 'Missing domain number']); + + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); + +$service = app(DomainDetailService::class); +$result = $service->loadDomainDetails($domainNo, $customerNo, $tenantId); + +Router::json($result); diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DomainDetailServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DomainDetailServiceTest.php new file mode 100644 index 0000000..2d093d5 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DomainDetailServiceTest.php @@ -0,0 +1,205 @@ +createMock(BcODataGateway::class); + $settings = $settings ?? $this->createConfiguredSettings(true); + $repository = $repository ?? $this->createMock(HandoverRepository::class); + + return new DomainDetailService($gateway, $settings, $repository); + } + + private function createConfiguredSettings(bool $configured): EffectiveHelpdeskSettingsService + { + $mock = $this->createMock(EffectiveHelpdeskSettingsService::class); + $mock->method('isConfigured')->willReturn($configured); + + return $mock; + } + + // ── loadDomain tests ───────────────────────────────────────── + + public function testLoadDomainHappyPath(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getDomain')->willReturn([ + 'No' => 'DNS00001', + 'Customer_No' => 'D10001', + 'Customer_Name' => 'Acme Corp', + 'URL' => 'example.com', + 'State' => 'Aktiv', + 'Administration' => 'Intern', + ]); + + $service = $this->createService($gateway); + $result = $service->loadDomain('DNS00001'); + + $this->assertSame('success', $result['status']); + $this->assertSame('DNS00001', $result['domain']['No']); + $this->assertSame('example.com', $result['domain']['URL']); + } + + public function testLoadDomainNotFound(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getDomain')->willReturn(null); + + $service = $this->createService($gateway); + $result = $service->loadDomain('INVALID'); + + $this->assertSame('not_found', $result['status']); + } + + public function testLoadDomainEmptyNo(): void + { + $service = $this->createService(); + $result = $service->loadDomain(''); + + $this->assertSame('not_found', $result['status']); + } + + public function testLoadDomainNotConfigured(): void + { + $service = $this->createService(settings: $this->createConfiguredSettings(false)); + $result = $service->loadDomain('DNS00001'); + + $this->assertSame('not_configured', $result['status']); + } + + public function testLoadDomainBcError(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getDomain')->willThrowException(new \RuntimeException('Connection failed')); + + $service = $this->createService($gateway); + $result = $service->loadDomain('DNS00001'); + + $this->assertSame('error', $result['status']); + $this->assertSame('BC connection failed', $result['error']); + } + + // ── loadDomainDetails tests ────────────────────────────────── + + public function testLoadDomainDetailsHappyPath(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willReturn([ + 'No' => 'D10001', + 'Name' => 'Acme Corp', + 'Address' => 'Main St 1', + ]); + $gateway->method('listDomainContractLines')->willReturn([ + ['No' => 'DNS00001', 'Header_No' => 'V1000', 'PI_Header_Type' => 'WEBSITE', 'PI_Header_Description' => 'Website Contract', 'PI_Header_State' => 'Aktiv', 'Line_State' => 'Aktiv'], + ['No' => 'DNS00002', 'Header_No' => 'V2000', 'PI_Header_Type' => 'SW&H', 'PI_Header_Description' => 'Other', 'PI_Header_State' => 'Aktiv', 'Line_State' => 'Aktiv'], + ]); + $gateway->method('getDomainsForCustomer')->willReturn([ + ['No' => 'DNS00001', 'URL' => 'example.com', 'State' => 'Aktiv'], + ['No' => 'DNS00002', 'URL' => 'other.com', 'State' => 'Aktiv'], + ]); + + $repo = $this->createMock(HandoverRepository::class); + $repo->method('findByDomainNo')->willReturn([ + ['id' => 1, 'product_code' => 'PROD-A', 'product_name' => 'Product A', 'product_bc_description' => '', 'status' => 'draft', 'created_by_name' => 'John', 'created_at' => '2026-01-01 10:00:00'], + ['id' => 2, 'product_code' => 'PROD-A', 'product_name' => 'Product A', 'product_bc_description' => '', 'status' => 'completed', 'created_by_name' => 'Jane', 'created_at' => '2026-02-15 14:00:00'], + ]); + + $service = $this->createService($gateway, repository: $repo); + $result = $service->loadDomainDetails('DNS00001', 'D10001', self::TENANT_ID); + + $this->assertTrue($result['ok']); + $this->assertSame('Acme Corp', $result['customer']['Name']); + $this->assertSame('WEBSITE', $result['contract']['contract_type']); + $this->assertSame('V1000', $result['contract']['contract_no']); + $this->assertCount(2, $result['handovers']); + $this->assertSame(2, $result['handover_stats']['total']); + $this->assertSame(1, $result['handover_stats']['draft']); + $this->assertSame(1, $result['handover_stats']['completed']); + $this->assertCount(1, $result['related_domains']); + $this->assertSame('DNS00002', $result['related_domains'][0]['No']); + } + + public function testLoadDomainDetailsNoContract(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willReturn(['No' => 'D10001', 'Name' => 'Acme']); + $gateway->method('listDomainContractLines')->willReturn([]); + $gateway->method('getDomainsForCustomer')->willReturn([]); + + $repo = $this->createMock(HandoverRepository::class); + $repo->method('findByDomainNo')->willReturn([]); + + $service = $this->createService($gateway, repository: $repo); + $result = $service->loadDomainDetails('DNS00001', 'D10001', self::TENANT_ID); + + $this->assertTrue($result['ok']); + $this->assertNull($result['contract']); + $this->assertSame(0, $result['handover_stats']['total']); + $this->assertEmpty($result['related_domains']); + } + + public function testLoadDomainDetailsEmptyDomainNo(): void + { + $service = $this->createService(); + $result = $service->loadDomainDetails('', 'D10001', self::TENANT_ID); + + $this->assertFalse($result['ok']); + $this->assertSame('Missing domain number', $result['error']); + } + + public function testLoadDomainDetailsCustomerFetchFails(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willThrowException(new \RuntimeException('BC error')); + $gateway->method('listDomainContractLines')->willReturn([]); + $gateway->method('getDomainsForCustomer')->willReturn([]); + + $repo = $this->createMock(HandoverRepository::class); + $repo->method('findByDomainNo')->willReturn([]); + + $service = $this->createService($gateway, repository: $repo); + $result = $service->loadDomainDetails('DNS00001', 'D10001', self::TENANT_ID); + + $this->assertTrue($result['ok']); + $this->assertNull($result['customer']); + } + + public function testLoadDomainDetailsHandoverStatAggregation(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willReturn(null); + $gateway->method('listDomainContractLines')->willReturn([]); + $gateway->method('getDomainsForCustomer')->willReturn([]); + + $repo = $this->createMock(HandoverRepository::class); + $repo->method('findByDomainNo')->willReturn([ + ['id' => 1, 'product_code' => 'P', 'product_name' => '', 'product_bc_description' => '', 'status' => 'draft', 'created_by_name' => '', 'created_at' => ''], + ['id' => 2, 'product_code' => 'P', 'product_name' => '', 'product_bc_description' => '', 'status' => 'in_progress', 'created_by_name' => '', 'created_at' => ''], + ['id' => 3, 'product_code' => 'P', 'product_name' => '', 'product_bc_description' => '', 'status' => 'in_progress', 'created_by_name' => '', 'created_at' => ''], + ['id' => 4, 'product_code' => 'P', 'product_name' => '', 'product_bc_description' => '', 'status' => 'archived', 'created_by_name' => '', 'created_at' => ''], + ]); + + $service = $this->createService($gateway, repository: $repo); + $result = $service->loadDomainDetails('DNS00001', '', self::TENANT_ID); + + $this->assertSame(4, $result['handover_stats']['total']); + $this->assertSame(1, $result['handover_stats']['draft']); + $this->assertSame(2, $result['handover_stats']['in_progress']); + $this->assertSame(0, $result['handover_stats']['completed']); + $this->assertSame(1, $result['handover_stats']['archived']); + } +} diff --git a/modules/helpdesk/web/js/helpdesk-domain-detail.js b/modules/helpdesk/web/js/helpdesk-domain-detail.js new file mode 100644 index 0000000..2c27e2d --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-domain-detail.js @@ -0,0 +1,321 @@ +/** + * 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 = ''; + 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 + ? '' + esc(name || no) + '' + : esc(name || no); + + // safe-html: all dynamic values are escaped via esc() before insertion + el.innerHTML = + '
' + + '
' + esc(t('Name')) + '
' + debitorLink + '
' + + '
' + esc(t('Customer No.')) + '
' + esc(no) + '
' + + '
' + esc(t('Phone')) + '
' + (phone ? '' + esc(phone) + '' : '—') + '
' + + (salesperson ? '
' + esc(t('Salesperson')) + '
' + esc(salesperson) + '
' : '') + + (supportType ? '
' + esc(t('Support type')) + '
' + esc(supportType) + '
' : '') + + '
'; + } + + 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 = + '
' + + '
' + esc(t('Contract type')) + '
' + esc(contract.contract_type) + '
' + + '
' + esc(t('Description')) + '
' + esc(contract.contract_description || '—') + '
' + + '
' + esc(t('Contract No.')) + '
' + esc(contract.contract_no || '—') + '
' + + (contract.header_state ? '
' + esc(t('Contract state')) + '
' + esc(contract.header_state) + '
' : '') + + '
'; + } + + 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 += + '' + + '
' + + '' + esc(productLabel) + '' + + '' + esc(statusLabel) + '' + + '
' + + (meta ? '
' + esc(meta) + '
' : '') + + '
'; + } + + // safe-html: all content built from esc()-escaped values + el.innerHTML = '
' + items + '
'; + } + + 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 += + '' + + '' + esc(url || no) + '' + + '' + esc(state) + '' + + ''; + } + + // safe-html: all content built from esc()-escaped values + el.innerHTML = items; + } + +}