From 693d33a0c87c8f5a990b66b596ac0f08164c4648 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 16 Apr 2026 13:21:24 +0200 Subject: [PATCH] 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) --- .../Helpdesk/Service/DomainDetailService.php | 226 ++++++++++++ .../helpdesk/pages/helpdesk/domain($id).php | 59 ++++ .../pages/helpdesk/domain(default).phtml | 234 +++++++++++++ .../pages/helpdesk/domain-detail-data().php | 29 ++ .../Service/DomainDetailServiceTest.php | 205 +++++++++++ .../helpdesk/web/js/helpdesk-domain-detail.js | 321 ++++++++++++++++++ 6 files changed, 1074 insertions(+) create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/DomainDetailService.php create mode 100644 modules/helpdesk/pages/helpdesk/domain($id).php create mode 100644 modules/helpdesk/pages/helpdesk/domain(default).phtml create mode 100644 modules/helpdesk/pages/helpdesk/domain-detail-data().php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/DomainDetailServiceTest.php create mode 100644 modules/helpdesk/web/js/helpdesk-domain-detail.js 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; + } + +}