From e897cc2c56dee7246e43f1f62a8de60a86c219ac Mon Sep 17 00:00:00 2001 From: fs Date: Fri, 3 Apr 2026 16:45:41 +0200 Subject: [PATCH] feat(helpdesk): add ticket/debtor communication feed and chat timeline UI --- modules/helpdesk/i18n/default_de.json | 15 + modules/helpdesk/i18n/default_en.json | 15 + .../Helpdesk/HelpdeskContainerRegistrar.php | 13 + .../Module/Helpdesk/Service/BcSoapGateway.php | 360 ++++++++ .../Service/TicketCommunicationService.php | 872 ++++++++++++++++++ modules/helpdesk/module.php | 2 + .../pages/helpdesk/debitor(default).phtml | 34 + .../helpdesk/debitor-communication-data().php | 53 ++ .../pages/helpdesk/debitor-summary-data().php | 23 +- .../helpdesk/pages/helpdesk/ticket($id).php | 1 + .../pages/helpdesk/ticket(default).phtml | 25 +- .../helpdesk/ticket-communication-data().php | 51 + .../Helpdesk/Service/BcSoapGatewayTest.php | 142 +++ .../TicketCommunicationServiceTest.php | 234 +++++ modules/helpdesk/web/css/helpdesk.css | 317 +++++++ .../helpdesk/web/js/helpdesk-communication.js | 128 +++ modules/helpdesk/web/js/helpdesk-detail.js | 52 ++ modules/helpdesk/web/js/helpdesk-ticket.js | 46 + 18 files changed, 2372 insertions(+), 11 deletions(-) create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php create mode 100644 modules/helpdesk/pages/helpdesk/debitor-communication-data().php create mode 100644 modules/helpdesk/pages/helpdesk/ticket-communication-data().php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php create mode 100644 modules/helpdesk/web/js/helpdesk-communication.js diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 2cba88e..4c6db51 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -104,10 +104,25 @@ "Status change": "Statusänderung", "No activity found.": "Keine Aktivitäten gefunden.", "Could not load activity log.": "Aktivitätsverlauf konnte nicht geladen werden.", + "Communication history": "Kommunikationsverlauf", + "No communication found.": "Keine Kommunikation gefunden.", + "Could not load communication history.": "Kommunikationsverlauf konnte nicht geladen werden.", + "Live communication unavailable, showing activity fallback.": "Live-Kommunikation nicht verfügbar, es werden Aktivitätsdaten als Fallback angezeigt.", + "Ticket communication": "Ticket-Kommunikation", + "Last 5 tickets": "Letzte 5 Tickets", + "Last 10 tickets": "Letzte 10 Tickets", + "Some ticket communications could not be loaded completely.": "Einige Ticket-Kommunikationen konnten nicht vollständig geladen werden.", + "Unknown user": "Unbekannter Benutzer", + "Unknown time": "Unbekannte Zeit", + "Source": "Quelle", + "SOAP": "SOAP", + "Activity fallback": "Aktivitäts-Fallback", "sent a message": "hat eine Nachricht gesendet", "changed status": "hat den Status geändert", "Forwarded": "hat das Ticket weitergeleitet", "attached a file": "hat eine Datei angehängt", + "logged an activity": "hat eine Aktivität erfasst", + "on": "am", "Process": "Prozess", "All": "Alle", "Open": "Offen", diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index ca0cd9f..40124b6 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -104,10 +104,25 @@ "Status change": "Status change", "No activity found.": "No activity found.", "Could not load activity log.": "Could not load activity log.", + "Communication history": "Communication history", + "No communication found.": "No communication found.", + "Could not load communication history.": "Could not load communication history.", + "Live communication unavailable, showing activity fallback.": "Live communication unavailable, showing activity fallback.", + "Ticket communication": "Ticket communication", + "Last 5 tickets": "Last 5 tickets", + "Last 10 tickets": "Last 10 tickets", + "Some ticket communications could not be loaded completely.": "Some ticket communications could not be loaded completely.", + "Unknown user": "Unknown user", + "Unknown time": "Unknown time", + "Source": "Source", + "SOAP": "SOAP", + "Activity fallback": "Activity fallback", "sent a message": "sent a message", "changed status": "changed status", "Forwarded": "forwarded the ticket", "attached a file": "attached a file", + "logged an activity": "logged an activity", + "on": "on", "Process": "Process", "All": "All", "Open": "Open", diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index 1032b31..2290404 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -6,11 +6,13 @@ use MintyPHP\App\AppContainer; use MintyPHP\App\Container\ContainerRegistrar; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\Service\BcODataGateway; +use MintyPHP\Module\Helpdesk\Service\BcSoapGateway; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; use MintyPHP\Module\Helpdesk\Service\DebitorSearchService; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository; +use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Settings\SettingsMetadataGateway; @@ -34,6 +36,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(SessionStoreInterface::class) )); + $container->set(BcSoapGateway::class, static fn (AppContainer $c): BcSoapGateway => new BcSoapGateway( + $c->get(HelpdeskSettingsGateway::class), + $c->get(HelpdeskOAuthTokenService::class), + $c->get(SessionStoreInterface::class) + )); + $container->set(HelpdeskTokenRepository::class, static fn (AppContainer $c): HelpdeskTokenRepository => new HelpdeskTokenRepository( $c->get(SettingServicesFactory::class)->createSettingsCryptoGateway() )); @@ -52,5 +60,10 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(BcODataGateway::class), $c->get(HelpdeskSettingsGateway::class) )); + + $container->set(TicketCommunicationService::class, static fn (AppContainer $c): TicketCommunicationService => new TicketCommunicationService( + $c->get(BcSoapGateway::class), + $c->get(BcODataGateway::class) + )); } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php new file mode 100644 index 0000000..43050cf --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php @@ -0,0 +1,360 @@ + false, + 'soap_used' => false, + 'return_value' => 0, + 'communication_text' => '', + 'message_entries' => '', + 'error' => 'Missing ticket number', + ]; + } + + if (!$this->settingsGateway->isConfigured()) { + return [ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 0, + 'communication_text' => '', + 'message_entries' => '', + 'error' => 'BC connection not configured', + ]; + } + + $endpoint = $this->buildSoapEndpoint(); + if ($endpoint === null) { + return [ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 0, + 'communication_text' => '', + 'message_entries' => '', + 'error' => 'Could not derive SOAP endpoint from BC URL', + ]; + } + + $requestBody = $this->buildGetTicketCommunicationTextEnvelope($ticketNo, $messageEntries); + $request = $this->buildRequestContext($endpoint, $requestBody); + if (isset($request['error'])) { + return [ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 0, + 'communication_text' => '', + 'message_entries' => '', + 'error' => (string) $request['error'], + ]; + } + + $response = $this->sendSoapRequest( + $endpoint, + self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT, + $requestBody, + (array) ($request['headers'] ?? []), + (string) ($request['basic_user'] ?? ''), + (string) ($request['basic_password'] ?? '') + ); + + $httpCode = (int) ($response['http_code'] ?? 0); + $rawBody = (string) ($response['body'] ?? ''); + if ($httpCode < 200 || $httpCode >= 300) { + $errorSnippet = trim((string) ($response['error'] ?? '')); + if ($errorSnippet === '' && $rawBody !== '') { + $errorSnippet = mb_substr($rawBody, 0, 250); + } + + return [ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 0, + 'communication_text' => '', + 'message_entries' => '', + 'error' => 'BC SOAP request failed: HTTP ' . $httpCode . ($errorSnippet !== '' ? ' - ' . $errorSnippet : ''), + ]; + } + + $parsed = $this->parseGetTicketCommunicationTextResponse($rawBody); + if (!($parsed['ok'] ?? false)) { + return [ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 0, + 'communication_text' => '', + 'message_entries' => '', + 'error' => (string) ($parsed['error'] ?? 'Could not parse SOAP response'), + ]; + } + + $returnValue = (int) ($parsed['return_value'] ?? 0); + if ($returnValue >= 400) { + return [ + 'ok' => false, + 'soap_used' => false, + 'return_value' => $returnValue, + 'communication_text' => (string) ($parsed['communication_text'] ?? ''), + 'message_entries' => (string) ($parsed['message_entries'] ?? ''), + 'error' => 'BC SOAP returned error code ' . $returnValue, + ]; + } + + return [ + 'ok' => true, + 'soap_used' => true, + 'return_value' => $returnValue, + 'communication_text' => (string) ($parsed['communication_text'] ?? ''), + 'message_entries' => (string) ($parsed['message_entries'] ?? ''), + ]; + } + + private function buildGetTicketCommunicationTextEnvelope(string $ticketNo, string $messageEntries): string + { + $escapedTicketNo = htmlspecialchars($ticketNo, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $normalizedMessageEntries = trim($messageEntries); + if ($normalizedMessageEntries === '') { + $normalizedMessageEntries = '0'; + } + $escapedMessageEntries = htmlspecialchars($normalizedMessageEntries, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + + return << + + + + {$escapedTicketNo} + + {$escapedMessageEntries} + + + +XML; + } + + /** + * @return array{headers?: array, basic_user?: string, basic_password?: string, error?: string} + */ + private function buildRequestContext(string $endpoint, string $requestBody): array + { + $headers = [ + 'Content-Type: text/xml; charset=utf-8', + 'Accept: text/xml,application/xml', + 'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"', + 'Content-Length: ' . strlen($requestBody), + ]; + + $authMode = $this->settingsGateway->getAuthMode(); + if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) { + return [ + 'headers' => $headers, + 'basic_user' => $this->settingsGateway->getBasicUser() ?? '', + 'basic_password' => $this->settingsGateway->getBasicPassword() ?? '', + ]; + } + + if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) { + return ['headers' => $headers]; + } + + $tenantId = $this->resolveCurrentTenantId(); + if ($tenantId <= 0) { + return ['error' => 'OAuth2 mode requires active tenant context']; + } + + $audience = $this->buildOAuthAudienceFromEndpoint($endpoint); + if ($audience === null) { + return ['error' => 'Could not build OAuth audience for SOAP endpoint']; + } + + $token = $this->oauthTokenService->getAccessToken($tenantId, $audience); + if ($token === null || trim($token) === '') { + return ['error' => 'Could not obtain OAuth2 access token for SOAP request']; + } + + $headers[] = 'Authorization: Bearer ' . $token; + + return ['headers' => $headers]; + } + + /** + * @return array{ok: bool, return_value?: int, communication_text?: string, message_entries?: string, error?: string} + */ + private function parseGetTicketCommunicationTextResponse(string $body): array + { + $body = trim($body); + if ($body === '') { + return ['ok' => false, 'error' => 'Empty SOAP response']; + } + + $dom = new \DOMDocument(); + $previousLibxmlState = libxml_use_internal_errors(true); + $loaded = $dom->loadXML($body); + libxml_clear_errors(); + libxml_use_internal_errors($previousLibxmlState); + if (!$loaded) { + return ['ok' => false, 'error' => 'Invalid SOAP XML response']; + } + + $xpath = new \DOMXPath($dom); + $faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0); + if ($faultNode instanceof \DOMNode) { + return ['ok' => false, 'error' => trim((string) $faultNode->textContent) ?: 'SOAP fault']; + } + + $returnNode = $xpath->query("//*[local-name()='return_value']")->item(0); + $communicationNode = $xpath->query("//*[local-name()='communicationText']")->item(0); + $messageEntriesNode = $xpath->query("//*[local-name()='messageEntries']")->item(0); + + return [ + 'ok' => true, + 'return_value' => $returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent)) + ? (int) trim((string) $returnNode->textContent) + : 0, + 'communication_text' => $communicationNode instanceof \DOMNode ? (string) $communicationNode->textContent : '', + 'message_entries' => $messageEntriesNode instanceof \DOMNode ? (string) $messageEntriesNode->textContent : '', + ]; + } + + protected function sendSoapRequest( + string $endpoint, + string $soapAction, + string $requestBody, + array $headers, + string $basicUser, + string $basicPassword + ): array { + $ch = curl_init(); + if ($ch === false) { + return ['http_code' => 0, 'body' => '', 'error' => 'curl_init failed']; + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $requestBody, + CURLOPT_HTTPHEADER => $headers, + ]); + + if ($basicUser !== '' || $basicPassword !== '') { + curl_setopt($ch, CURLOPT_USERPWD, $basicUser . ':' . $basicPassword); + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + } + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + unset($ch); + + return [ + 'http_code' => $httpCode, + 'body' => is_string($responseBody) ? $responseBody : '', + 'error' => $error, + ]; + } + + private function buildSoapEndpoint(): ?string + { + $baseUrl = trim($this->settingsGateway->getODataBaseUrl()); + if ($baseUrl === '') { + return null; + } + + $parsed = parse_url($baseUrl); + if (!is_array($parsed)) { + return null; + } + + $scheme = strtolower((string) ($parsed['scheme'] ?? 'https')); + $host = trim((string) ($parsed['host'] ?? '')); + if ($host === '') { + return null; + } + + $odataPath = '/' . ltrim((string) ($parsed['path'] ?? ''), '/'); + $instancePath = (string) preg_replace('#/ODataV4(?:/.*)?$#i', '', $odataPath); + $instancePath = rtrim($instancePath !== '' ? $instancePath : '/BusinessCentral', '/'); + if ($instancePath === '') { + $instancePath = '/BusinessCentral'; + } + + $odataPort = (int) ($parsed['port'] ?? 0); + $soapPort = $odataPort === 7048 ? 7047 : $odataPort; + + $authority = $host; + if ($soapPort > 0 && !(($scheme === 'https' && $soapPort === 443) || ($scheme === 'http' && $soapPort === 80))) { + $authority .= ':' . $soapPort; + } + + $company = rawurlencode($this->settingsGateway->getCompanyName()); + + return $scheme . '://' . $authority . $instancePath . '/WS/' . $company . '/Codeunit/' . self::CODEUNIT_NAME; + } + + private function buildOAuthAudienceFromEndpoint(string $endpoint): ?string + { + $parsed = parse_url($endpoint); + if (!is_array($parsed)) { + return null; + } + + $scheme = strtolower((string) ($parsed['scheme'] ?? 'https')); + $host = trim((string) ($parsed['host'] ?? '')); + if ($host === '') { + return null; + } + + $port = (int) ($parsed['port'] ?? 0); + $authority = $host; + if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { + $authority .= ':' . $port; + } + + return $scheme . '://' . $authority; + } + + private function resolveCurrentTenantId(): int + { + $session = $this->sessionStore->all(); + $tenantId = (int) (($session['current_tenant']['id'] ?? 0)); + + return $tenantId > 0 ? $tenantId : 0; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php new file mode 100644 index 0000000..9d514d5 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php @@ -0,0 +1,872 @@ +>, + * meta?: array, + * error?: string + * } + */ + public function loadTicketCommunicationFeed(string $ticketNo, int $maxEntries = 40): array + { + $ticketNo = trim($ticketNo); + if ($ticketNo === '') { + return ['ok' => false, 'error' => 'Missing ticket number']; + } + + $maxEntries = max(1, min(250, $maxEntries)); + + $ticketLog = []; + $odataError = ''; + $odataEntries = []; + try { + $ticketLog = $this->bcODataGateway->getTicketLog($ticketNo); + } catch (\Throwable $e) { + $odataError = $e->getMessage(); + } + + $messageEntries = $this->buildSoapMessageEntriesFromTicketLog($ticketLog); + $soapResult = $this->bcSoapGateway->getTicketCommunicationText($ticketNo, $messageEntries); + $soapError = (string) ($soapResult['error'] ?? ''); + $soapUsed = (bool) ($soapResult['soap_used'] ?? false); + $soapEntries = []; + $soapMessageMap = []; + if (($soapResult['ok'] ?? false) === true) { + $soapEntries = $this->parseSoapEntries( + $ticketNo, + (string) ($soapResult['communication_text'] ?? ''), + (string) ($soapResult['message_entries'] ?? '') + ); + $soapMessageMap = $this->extractSoapMessageMap( + (string) ($soapResult['communication_text'] ?? ''), + (string) ($soapResult['message_entries'] ?? '') + ); + } + + if ($ticketLog !== []) { + $odataEntries = $this->mapODataEntries($ticketNo, $ticketLog, $soapMessageMap); + } + + $actorNameMap = $this->resolveContactActorNames($ticketNo, $odataEntries, $soapEntries); + if ($actorNameMap !== []) { + $odataEntries = $this->applyActorNameMap($odataEntries, $actorNameMap); + $soapEntries = $this->applyActorNameMap($soapEntries, $actorNameMap); + } + + $merged = $this->mergeEntries($odataEntries, $soapEntries, $maxEntries); + $fallbackUsed = $this->hasFallbackOnlyEntries($odataEntries) && $soapEntries === []; + + if ($merged === [] && $soapError !== '' && $odataError !== '') { + return [ + 'ok' => false, + 'error' => 'Could not load communication feed', + 'meta' => [ + 'ticket_no' => $ticketNo, + 'soap_used' => false, + 'soap_error' => $soapError, + 'fallback_used' => false, + ], + ]; + } + + return [ + 'ok' => true, + 'entries' => $merged, + 'meta' => [ + 'ticket_no' => $ticketNo, + 'soap_used' => $soapUsed, + 'soap_error' => $soapError, + 'fallback_used' => $fallbackUsed, + ], + ]; + } + + /** + * @return array{ + * ok: bool, + * entries?: array>, + * meta?: array, + * error?: string + * } + */ + public function loadDebitorCommunicationFeed( + string $customerNo, + string $customerName, + int $ticketsLimit = 10, + int $maxEntriesPerTicket = 40, + int $maxEntriesTotal = 250 + ): array { + $customerNo = trim($customerNo); + $customerName = trim($customerName); + if ($customerNo === '' || $customerName === '') { + return ['ok' => false, 'error' => 'Missing customer number or name']; + } + + $ticketsLimit = max(1, min(25, $ticketsLimit)); + $maxEntriesPerTicket = max(1, min(80, $maxEntriesPerTicket)); + $maxEntriesTotal = max(1, min(500, $maxEntriesTotal)); + + try { + $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName); + } catch (\Throwable) { + return ['ok' => false, 'error' => 'Could not load tickets for customer']; + } + + if ($tickets === []) { + return [ + 'ok' => true, + 'entries' => [], + 'meta' => [ + 'customer_no' => $customerNo, + 'tickets_considered' => 0, + 'tickets_with_data' => 0, + 'soap_partial_failures' => 0, + ], + ]; + } + + usort($tickets, function (array $a, array $b): int { + $sa = $this->ticketSortStamp($a); + $sb = $this->ticketSortStamp($b); + + return $sb <=> $sa; + }); + + $ticketsToProcess = []; + foreach ($tickets as $ticket) { + $ticketNo = trim((string) ($ticket['No'] ?? '')); + if ($ticketNo === '') { + continue; + } + $ticketsToProcess[$ticketNo] = true; + if (count($ticketsToProcess) >= $ticketsLimit) { + break; + } + } + + $allEntries = []; + $ticketsWithData = 0; + $soapPartialFailures = 0; + + foreach (array_keys($ticketsToProcess) as $ticketNo) { + $feed = $this->loadTicketCommunicationFeed($ticketNo, $maxEntriesPerTicket); + $ticketEntries = []; + if ($feed['ok'] ?? false) { + $ticketEntries = is_array($feed['entries'] ?? null) ? $feed['entries'] : []; + } + + if ($ticketEntries !== []) { + $ticketsWithData++; + foreach ($ticketEntries as $entry) { + $allEntries[] = $entry; + } + } + + $meta = is_array($feed['meta'] ?? null) ? $feed['meta'] : []; + $soapUsed = (bool) ($meta['soap_used'] ?? false); + $soapError = trim((string) ($meta['soap_error'] ?? '')); + if (!$soapUsed && $soapError !== '') { + $soapPartialFailures++; + } + } + + $entries = $this->mergeEntries($allEntries, [], $maxEntriesTotal); + + return [ + 'ok' => true, + 'entries' => $entries, + 'meta' => [ + 'customer_no' => $customerNo, + 'tickets_considered' => count($ticketsToProcess), + 'tickets_with_data' => $ticketsWithData, + 'soap_partial_failures' => $soapPartialFailures, + ], + ]; + } + + /** + * @param array> $ticketLog + * @param array $soapMessageMap + * @return array> + */ + private function mapODataEntries(string $ticketNo, array $ticketLog, array $soapMessageMap = []): array + { + $entries = []; + foreach ($ticketLog as $row) { + $type = trim((string) ($row['Type'] ?? '')); + $stateSubtype = trim((string) ($row['State_Subtype'] ?? '')); + $recordId = trim((string) ($row['Record_ID'] ?? '')); + $actor = trim((string) ($row['Created_By'] ?? '')); + $createdByType = trim((string) ($row['Created_By_Type'] ?? '')); + $date = trim((string) ($row['Creation_Date'] ?? '')); + $time = trim((string) ($row['Creation_Time'] ?? '')); + $entryNo = $this->normalizeEntryNo($row['Entry_No'] ?? null); + + $timestampIso = $this->combineDateTimeToIso($date, $time); + $soapMessage = $entryNo !== '' ? (string) ($soapMessageMap[$entryNo] ?? '') : ''; + $message = $soapMessage !== '' + ? $soapMessage + : $this->buildODataMessage($type, $stateSubtype, $recordId); + [$normalizedType, $typeVariant] = $this->normalizeType($type, $message); + + $entry = [ + 'entry_id' => 'odata-' . substr(hash('sha256', $ticketNo . '|' . ($row['Entry_No'] ?? '') . '|' . $timestampIso . '|' . $actor . '|' . $normalizedType . '|' . $message), 0, 20), + 'ticket_no' => $ticketNo, + 'timestamp_iso' => $timestampIso, + 'date' => $date, + 'time' => $this->normalizeTime($time), + 'actor' => $actor, + 'actor_role' => $this->normalizeActorRole($createdByType, $actor), + 'type' => $normalizedType, + 'type_variant' => $typeVariant, + 'state_subtype' => $stateSubtype, + 'message' => $message, + 'source' => $soapMessage !== '' ? 'soap' : 'odata', + ]; + + $entries[] = $entry; + } + + return $entries; + } + + /** + * @return array> + */ + private function parseSoapEntries(string $ticketNo, string $communicationText, string $messageEntries): array + { + $entries = []; + $candidates = []; + if (!$this->isSoapPayloadEmpty($messageEntries)) { + $candidates[] = $messageEntries; + } + if (!$this->isSoapPayloadEmpty($communicationText)) { + $candidates[] = $communicationText; + } + + foreach ($candidates as $candidate) { + foreach ($this->extractEntriesFromPayload($ticketNo, $candidate) as $entry) { + $entries[] = $entry; + } + } + + return $this->mergeEntries($entries, [], 200); + } + + /** + * @param array> $ticketLog + */ + private function buildSoapMessageEntriesFromTicketLog(array $ticketLog): string + { + $entryNos = []; + foreach ($ticketLog as $row) { + $type = trim((string) ($row['Type'] ?? '')); + [$normalizedType, $typeVariant] = $this->normalizeType($type, ''); + if ($typeVariant !== 'message' && !in_array($normalizedType, ['Message', 'Nachricht'], true)) { + continue; + } + + $entryNo = $this->normalizeEntryNo($row['Entry_No'] ?? null); + if ($entryNo !== '') { + $entryNos[$entryNo] = true; + } + } + + if ($entryNos === []) { + return ''; + } + + return implode(';', array_keys($entryNos)) . ';'; + } + + /** + * @return array + */ + private function extractSoapMessageMap(string $communicationText, string $messageEntries): array + { + $sources = [$communicationText, $messageEntries]; + foreach ($sources as $payload) { + $payload = trim($payload); + if ($payload === '' || $this->isSoapPayloadEmpty($payload)) { + continue; + } + + $decoded = json_decode($payload, true); + if (!is_array($decoded) || !array_is_list($decoded)) { + continue; + } + + $map = []; + foreach ($decoded as $row) { + if (!is_array($row)) { + continue; + } + + $entryNo = $this->normalizeEntryNo($row['EntryNo'] ?? $row['entryNo'] ?? $row['entry_no'] ?? null); + if ($entryNo === '') { + continue; + } + + $message = $this->normalizeSoapMessageText((string) ($row['DataBlob'] ?? $row['dataBlob'] ?? $row['message'] ?? $row['text'] ?? '')); + if ($message === '') { + continue; + } + + $map[$entryNo] = $message; + } + + if ($map !== []) { + return $map; + } + } + + return []; + } + + /** + * @return array> + */ + private function extractEntriesFromPayload(string $ticketNo, string $payload): array + { + $payload = trim($payload); + if ($payload === '' || $this->isSoapPayloadEmpty($payload)) { + return []; + } + + $jsonDecoded = json_decode($payload, true); + if (is_array($jsonDecoded) && array_is_list($jsonDecoded)) { + $entries = []; + foreach ($jsonDecoded as $row) { + if (!is_array($row)) { + continue; + } + $message = $this->normalizeSoapMessageText((string) ($row['message'] ?? $row['text'] ?? $row['DataBlob'] ?? $row['dataBlob'] ?? '')); + if ($message === '') { + continue; + } + $timestampIso = trim((string) ($row['timestamp'] ?? $row['created_at'] ?? '')); + $actor = trim((string) ($row['actor'] ?? $row['user'] ?? '')); + $createdByType = trim((string) ($row['created_by_type'] ?? $row['Created_By_Type'] ?? '')); + $stateSubtype = trim((string) ($row['state_subtype'] ?? '')); + [$type, $variant] = $this->normalizeType((string) ($row['type'] ?? 'Nachricht'), $message); + [$date, $time] = $this->splitTimestamp($timestampIso); + $entry = $this->buildSoapEntry( + $ticketNo, + $timestampIso, + $date, + $time, + $actor, + $this->normalizeActorRole($createdByType, $actor), + $type, + $variant, + $stateSubtype, + $message + ); + $entryNo = $this->normalizeEntryNo($row['EntryNo'] ?? $row['entryNo'] ?? $row['entry_no'] ?? null); + if ($entryNo !== '') { + $entry['entry_id'] = 'soap-' . substr(hash('sha256', $ticketNo . '|' . $entryNo . '|' . $timestampIso . '|' . $actor . '|' . $type . '|' . $message), 0, 20); + } + $entries[] = $entry; + } + if ($entries !== []) { + return $entries; + } + } + + $normalized = str_replace(["\r\n", "\r"], "\n", $payload); + $normalized = preg_replace('##i', "\n", $normalized) ?? $normalized; + $normalized = strip_tags($normalized); + $normalized = html_entity_decode($normalized, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $lines = preg_split('/\n+/', $normalized) ?: []; + $lines = array_values(array_filter(array_map(static fn (string $line): string => trim($line), $lines), static fn (string $line): bool => $line !== '')); + if ($lines === []) { + return []; + } + + $entries = []; + foreach ($lines as $line) { + $entries[] = $this->parseSoapLine($ticketNo, $line); + } + + return $entries; + } + + private function isSoapPayloadEmpty(string $payload): bool + { + $normalized = trim(html_entity_decode($payload, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($normalized === '') { + return true; + } + + return in_array($normalized, ['0', '[]', '0[]', 'null', '{}'], true); + } + + /** + * @return array + */ + private function parseSoapLine(string $ticketNo, string $line): array + { + $working = trim($line); + $timestampIso = ''; + $date = ''; + $time = ''; + + if (preg_match('/^(\d{4}-\d{2}-\d{2})(?:[T\s](\d{2}:\d{2}(?::\d{2})?))?\s*[-|]\s*(.+)$/u', $working, $m) === 1) { + $date = $m[1]; + $time = isset($m[2]) ? $this->normalizeTime($m[2]) : ''; + $timestampIso = $this->combineDateTimeToIso($date, $time); + $working = trim($m[3]); + } elseif (preg_match('/^(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}(?::\d{2})?)\s*[-|]\s*(.+)$/u', $working, $m) === 1) { + $date = $m[1]; + $time = $this->normalizeTime($m[2]); + $timestampIso = $this->convertEuropeanDateTimeToIso($date, $time); + $working = trim($m[3]); + } + + $actor = ''; + $message = $working; + if (preg_match('/^([^:]{2,80}):\s*(.+)$/u', $working, $m) === 1) { + $actor = trim($m[1]); + $message = trim($m[2]); + } + + [$type, $variant] = $this->normalizeType('Nachricht', $message); + $stateSubtype = ''; + + return $this->buildSoapEntry( + $ticketNo, + $timestampIso, + $date, + $time, + $actor, + $this->normalizeActorRole('', $actor), + $type, + $variant, + $stateSubtype, + $message + ); + } + + /** + * @return array + */ + private function buildSoapEntry( + string $ticketNo, + string $timestampIso, + string $date, + string $time, + string $actor, + string $actorRole, + string $type, + string $variant, + string $stateSubtype, + string $message + ): array { + return [ + 'entry_id' => 'soap-' . substr(hash('sha256', $ticketNo . '|' . $timestampIso . '|' . $actor . '|' . $type . '|' . $message), 0, 20), + 'ticket_no' => $ticketNo, + 'timestamp_iso' => $timestampIso, + 'date' => $date, + 'time' => $time, + 'actor' => $actor, + 'actor_role' => $actorRole, + 'type' => $type, + 'type_variant' => $variant, + 'state_subtype' => $stateSubtype, + 'message' => $message, + 'source' => 'soap', + ]; + } + + /** + * @param array> $primary + * @param array> $secondary + * @return array> + */ + private function mergeEntries(array $primary, array $secondary, int $maxEntries): array + { + $combined = [...$primary, ...$secondary]; + if ($combined === []) { + return []; + } + + usort($combined, function (array $a, array $b): int { + $ta = trim((string) ($a['timestamp_iso'] ?? '')); + $tb = trim((string) ($b['timestamp_iso'] ?? '')); + if ($ta === $tb) { + return strcmp((string) ($b['entry_id'] ?? ''), (string) ($a['entry_id'] ?? '')); + } + if ($ta === '') { + return 1; + } + if ($tb === '') { + return -1; + } + + return strcmp($tb, $ta); + }); + + $deduped = []; + $seen = []; + foreach ($combined as $entry) { + $signature = $this->entrySignature($entry); + if (isset($seen[$signature])) { + continue; + } + $seen[$signature] = true; + $deduped[] = $entry; + if (count($deduped) >= $maxEntries) { + break; + } + } + + return $deduped; + } + + /** + * @param array $entry + */ + private function entrySignature(array $entry): string + { + return hash( + 'sha256', + implode('|', [ + (string) ($entry['ticket_no'] ?? ''), + (string) ($entry['timestamp_iso'] ?? ''), + (string) ($entry['actor'] ?? ''), + (string) ($entry['type'] ?? ''), + hash('sha256', (string) ($entry['message'] ?? '')), + ]) + ); + } + + /** + * @param array> $entries + */ + private function hasFallbackOnlyEntries(array $entries): bool + { + if ($entries === []) { + return false; + } + + foreach ($entries as $entry) { + if (($entry['source'] ?? '') === 'soap') { + return false; + } + } + + return true; + } + + private function buildODataMessage(string $type, string $stateSubtype, string $recordId): string + { + $type = trim($type); + $recordId = trim($recordId); + $stateSubtype = trim($stateSubtype); + + if ($type === 'Status' && $stateSubtype !== '') { + return $stateSubtype; + } + + if ($recordId !== '') { + return $recordId; + } + + if ($type !== '') { + return $type; + } + + return ''; + } + + private function normalizeEntryNo(mixed $value): string + { + if (is_int($value)) { + return (string) $value; + } + + $raw = trim((string) $value); + if ($raw === '' || preg_match('/^\d+$/', $raw) !== 1) { + return ''; + } + + return (string) ((int) $raw); + } + + private function normalizeSoapMessageText(string $message): string + { + $message = trim($message); + if ($message === '') { + return ''; + } + + $message = preg_replace('##i', "\n", $message) ?? $message; + $message = preg_replace('#

\s*

#i', "\n\n", $message) ?? $message; + $message = strip_tags($message); + $message = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $message = str_replace(["\r\n", "\r"], "\n", $message); + $message = preg_replace("/\n{3,}/", "\n\n", $message) ?? $message; + + return trim($message); + } + + /** + * @return array{0: string, 1: string} + */ + private function normalizeType(string $type, string $message): array + { + $type = trim($type); + $needle = mb_strtolower($type . ' ' . $message); + if ($type === 'Status' || str_contains($needle, 'status')) { + return ['Status', 'status']; + } + if ($type === 'Weiterleitung' || str_contains($needle, 'weiterleit') || str_contains($needle, 'forward')) { + return ['Weiterleitung', 'forward']; + } + if ($type === 'Datei' || str_contains($needle, 'datei') || str_contains($needle, 'file') || str_contains($needle, 'anhang')) { + return ['Datei', 'file']; + } + if ($type === 'Nachricht' || $type === 'Message') { + return ['Nachricht', 'message']; + } + if ($type !== '') { + return [$type, 'other']; + } + + return ['Nachricht', 'message']; + } + + private function ticketSortStamp(array $ticket): int + { + $activity = trim((string) ($ticket['Last_Activity_Date'] ?? '')); + if ($activity !== '' && $activity !== '0001-01-01T00:00:00Z') { + $ts = strtotime($activity); + if (is_int($ts)) { + return $ts; + } + } + + $created = trim((string) ($ticket['Created_On'] ?? '')); + if ($created !== '' && $created !== '0001-01-01T00:00:00Z') { + $ts = strtotime($created); + if (is_int($ts)) { + return $ts; + } + } + + return 0; + } + + private function combineDateTimeToIso(string $date, string $time): string + { + $date = trim($date); + $time = $this->normalizeTime($time); + if ($date === '') { + return ''; + } + + if ($time === '') { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) { + return $date . 'T00:00:00'; + } + + return ''; + } + + $ts = strtotime($date . ' ' . $time); + if (!is_int($ts)) { + return ''; + } + + return date('Y-m-d\TH:i:s', $ts); + } + + private function convertEuropeanDateTimeToIso(string $date, string $time): string + { + if (preg_match('/^(\d{2})\.(\d{2})\.(\d{4})$/', trim($date), $m) !== 1) { + return ''; + } + + return $this->combineDateTimeToIso($m[3] . '-' . $m[2] . '-' . $m[1], $time); + } + + /** + * @return array{0: string, 1: string} + */ + private function splitTimestamp(string $timestamp): array + { + $timestamp = trim($timestamp); + if ($timestamp === '') { + return ['', '']; + } + + $ts = strtotime($timestamp); + if (!is_int($ts)) { + return ['', '']; + } + + return [date('Y-m-d', $ts), date('H:i:s', $ts)]; + } + + private function normalizeTime(string $time): string + { + $time = trim($time); + if ($time === '') { + return ''; + } + + if (preg_match('/^(\d{2}:\d{2})(:\d{2})?/', $time, $m) === 1) { + return isset($m[2]) && $m[2] !== '' ? ($m[1] . $m[2]) : ($m[1] . ':00'); + } + + return $time; + } + + /** + * @param array> $odataEntries + * @param array> $soapEntries + * @return array + */ + private function resolveContactActorNames(string $ticketNo, array $odataEntries, array $soapEntries): array + { + $contactActorIds = $this->extractContactActorIds([...$odataEntries, ...$soapEntries]); + if ($contactActorIds === []) { + return []; + } + + $ticket = null; + try { + $ticket = $this->bcODataGateway->getTicket($ticketNo); + } catch (\Throwable) { + $ticket = null; + } + + $currentContactName = trim((string) (($ticket['Current_Contact_Name'] ?? ''))); + $customerName = trim((string) (($ticket['Company_Contact_Name'] ?? ''))); + + $nameMap = []; + if ($customerName !== '') { + try { + $contacts = $this->bcODataGateway->getContactsForCustomer('', $customerName); + foreach ($contacts as $contact) { + $contactNo = strtoupper(trim((string) ($contact['No'] ?? ''))); + $contactName = trim((string) ($contact['Name'] ?? '')); + if ($contactNo !== '' && $contactName !== '') { + $nameMap[$contactNo] = $contactName; + } + } + } catch (\Throwable) { + $nameMap = []; + } + } + + $resolved = []; + foreach ($contactActorIds as $contactActorId) { + if (isset($nameMap[$contactActorId])) { + $resolved[$contactActorId] = $nameMap[$contactActorId]; + } + } + + if ($currentContactName !== '') { + $remaining = array_values(array_filter( + $contactActorIds, + static fn (string $contactActorId): bool => !isset($resolved[$contactActorId]) + )); + if (count($remaining) === 1) { + $resolved[$remaining[0]] = $currentContactName; + } + } + + return $resolved; + } + + /** + * @param array> $entries + * @return array> + */ + private function applyActorNameMap(array $entries, array $actorNameMap): array + { + if ($entries === [] || $actorNameMap === []) { + return $entries; + } + + foreach ($entries as $index => $entry) { + $actor = strtoupper(trim((string) ($entry['actor'] ?? ''))); + if ($actor !== '' && isset($actorNameMap[$actor])) { + $entries[$index]['actor'] = $actorNameMap[$actor]; + } + } + + return $entries; + } + + /** + * @param array> $entries + * @return array + */ + private function extractContactActorIds(array $entries): array + { + $ids = []; + foreach ($entries as $entry) { + $actor = strtoupper(trim((string) ($entry['actor'] ?? ''))); + if ($this->isContactActorId($actor)) { + $ids[$actor] = true; + } + } + + return array_keys($ids); + } + + private function isContactActorId(string $value): bool + { + return $value !== '' && preg_match('/^KT\d+$/', $value) === 1; + } + + private function normalizeActorRole(string $createdByType, string $actor): string + { + $type = mb_strtolower(trim($createdByType)); + if ($type !== '') { + if ( + str_contains($type, 'kontakt') + || str_contains($type, 'contact') + || str_contains($type, 'customer') + || str_contains($type, 'debitor') + || str_contains($type, 'client') + || str_contains($type, 'external') + ) { + return 'customer'; + } + if ( + str_contains($type, 'user') + || str_contains($type, 'benutzer') + || str_contains($type, 'support') + || str_contains($type, 'agent') + || str_contains($type, 'internal') + ) { + return 'support'; + } + } + + $actorCode = strtoupper(trim($actor)); + if ($this->isContactActorId($actorCode)) { + return 'customer'; + } + + return 'support'; + } +} diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 516ca77..a4c9d04 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -26,6 +26,8 @@ return [ ['path' => 'helpdesk/debitor-tickets-data', 'target' => 'helpdesk/debitor-tickets-data'], ['path' => 'helpdesk/debitor-ticket-categories-data', 'target' => 'helpdesk/debitor-ticket-categories-data'], ['path' => 'helpdesk/ticket-log-data', 'target' => 'helpdesk/ticket-log-data'], + ['path' => 'helpdesk/ticket-communication-data', 'target' => 'helpdesk/ticket-communication-data'], + ['path' => 'helpdesk/debitor-communication-data', 'target' => 'helpdesk/debitor-communication-data'], ], 'public_paths' => [], diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index d35c109..67d6fa2 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -38,6 +38,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city : data-tickets-url="" data-ticket-base-url="" data-summary-url="" + data-communication-url="" >

+ + + + @@ -272,6 +289,23 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city : 'No recent activity found.' => t('No recent activity found.'), 'Could not load contacts.' => t('Could not load contacts.'), 'Could not load tickets.' => t('Could not load tickets.'), + 'Ticket communication' => t('Ticket communication'), + 'Last 5 tickets' => t('Last 5 tickets'), + 'No communication found.' => t('No communication found.'), + 'Could not load communication history.' => t('Could not load communication history.'), + 'Some ticket communications could not be loaded completely.' => t('Some ticket communications could not be loaded completely.'), + 'Unknown user' => t('Unknown user'), + 'Unknown time' => t('Unknown time'), + 'Ticket' => t('Ticket'), + 'Activity' => t('Activity'), + 'Message' => t('Message'), + 'Status change' => t('Status change'), + 'Forwarded' => t('Forwarded'), + 'attached a file' => t('attached a file'), + 'logged an activity' => t('logged an activity'), + 'on' => t('on'), + 'SOAP' => t('SOAP'), + 'Activity fallback' => t('Activity fallback'), 'Loading...' => t('Loading...'), ]); ?> diff --git a/modules/helpdesk/pages/helpdesk/debitor-communication-data().php b/modules/helpdesk/pages/helpdesk/debitor-communication-data().php new file mode 100644 index 0000000..06709ac --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor-communication-data().php @@ -0,0 +1,53 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +$customerNo = trim((string) $request->query('customerNo', '')); +$customerName = trim((string) $request->query('customerName', '')); + +if ($customerNo === '' || $customerName === '') { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'missing_parameters']); + + return; +} + +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); +$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global'; +$cacheKey = 'module.helpdesk.debitor_comm_cache.v3.' . $tenantScope . '.' . $customerNo; +$cacheTtl = 120; +$cached = $sessionStore->get($cacheKey); + +if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + Router::json((array) ($cached['payload'] ?? ['ok' => false])); + + return; +} + +$service = app(TicketCommunicationService::class); +$result = $service->loadDebitorCommunicationFeed($customerNo, $customerName, 5, 40, 250); +if (($result['ok'] ?? false) === true) { + $sessionStore->set($cacheKey, [ + 'payload' => $result, + 'fetched_at' => time(), + ]); +} + +Router::json($result); diff --git a/modules/helpdesk/pages/helpdesk/debitor-summary-data().php b/modules/helpdesk/pages/helpdesk/debitor-summary-data().php index c2f964b..4f198b7 100644 --- a/modules/helpdesk/pages/helpdesk/debitor-summary-data().php +++ b/modules/helpdesk/pages/helpdesk/debitor-summary-data().php @@ -3,6 +3,7 @@ use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; use MintyPHP\Http\SessionStoreInterface; +use MintyPHP\Router; use MintyPHP\Support\Guard; Guard::requireLogin(); @@ -15,8 +16,8 @@ $customerNo = trim((string) $request->query('customerNo', '')); $customerName = trim((string) $request->query('customerName', '')); if ($customerNo === '' || $customerName === '') { - header('Content-Type: application/json'); - echo json_encode(['ok' => false, 'error' => 'Missing parameters']); + http_response_code(400); + Router::json(['ok' => false, 'error' => 'Missing parameters']); return; } @@ -31,9 +32,12 @@ $cacheTtl = 120; $cached = $sessionStore->get($cacheKey); if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { - $summary = DebitorDetailService::summarizeTickets($cached['tickets'] ?? []); - header('Content-Type: application/json'); - echo json_encode($summary); + $cachedTickets = $cached['tickets'] ?? []; + if (!is_array($cachedTickets)) { + $cachedTickets = []; + } + $summary = DebitorDetailService::summarizeTickets($cachedTickets); + Router::json($summary); return; } @@ -43,13 +47,15 @@ $service = app(DebitorDetailService::class); $ticketsResult = $service->loadTickets($customerNo, $customerName); if (!($ticketsResult['ok'] ?? false)) { - header('Content-Type: application/json'); - echo json_encode(['ok' => false, 'error' => $ticketsResult['error'] ?? 'Failed to load tickets']); + Router::json(['ok' => false, 'error' => $ticketsResult['error'] ?? 'Failed to load tickets']); return; } $tickets = $ticketsResult['tickets'] ?? []; +if (!is_array($tickets)) { + $tickets = []; +} $sessionStore->set($cacheKey, [ 'tickets' => $tickets, @@ -58,5 +64,4 @@ $sessionStore->set($cacheKey, [ $summary = DebitorDetailService::summarizeTickets($tickets); -header('Content-Type: application/json'); -echo json_encode($summary); +Router::json($summary); diff --git a/modules/helpdesk/pages/helpdesk/ticket($id).php b/modules/helpdesk/pages/helpdesk/ticket($id).php index ce13245..f1a036f 100644 --- a/modules/helpdesk/pages/helpdesk/ticket($id).php +++ b/modules/helpdesk/pages/helpdesk/ticket($id).php @@ -41,6 +41,7 @@ if ($ticket === null && !$connectionError) { $ticketCustomerNo = $fromCustomerNo; $ticketDescription = (string) ($ticket['Description'] ?? $ticketNo); $ticketLogUrl = lurl('helpdesk/ticket-log-data'); +$ticketCommunicationUrl = lurl('helpdesk/ticket-communication-data'); Buffer::set('title', $ticketDescription . ' — ' . t('Helpdesk')); Buffer::set('style_groups', json_encode(['helpdesk'])); diff --git a/modules/helpdesk/pages/helpdesk/ticket(default).phtml b/modules/helpdesk/pages/helpdesk/ticket(default).phtml index 8956773..6248834 100644 --- a/modules/helpdesk/pages/helpdesk/ticket(default).phtml +++ b/modules/helpdesk/pages/helpdesk/ticket(default).phtml @@ -8,6 +8,7 @@ * @var string $ticketCustomerNo * @var string $ticketDescription * @var string $ticketLogUrl + * @var string $ticketCommunicationUrl */ $ticketNo = $ticketNo ?? ''; @@ -17,6 +18,7 @@ $connectionError = $connectionError ?? false; $ticketCustomerNo = $ticketCustomerNo ?? ''; $ticketDescription = $ticketDescription ?? ''; $ticketLogUrl = $ticketLogUrl ?? ''; +$ticketCommunicationUrl = $ticketCommunicationUrl ?? ''; $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo)) : lurl('helpdesk/debitor'); @@ -24,6 +26,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
- -
+ +
+
+
+
+
+
+
@@ -147,10 +156,22 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t 'Activity' => t('Activity'), 'No activity found.' => t('No activity found.'), 'Could not load activity log.' => t('Could not load activity log.'), + 'Communication history' => t('Communication history'), + 'No communication found.' => t('No communication found.'), + 'Could not load communication history.' => t('Could not load communication history.'), + 'Live communication unavailable, showing activity fallback.' => t('Live communication unavailable, showing activity fallback.'), + 'Ticket' => t('Ticket'), + 'Unknown user' => t('Unknown user'), + 'Unknown time' => t('Unknown time'), + 'Source' => t('Source'), + 'SOAP' => t('SOAP'), + 'Activity fallback' => t('Activity fallback'), 'sent a message' => t('sent a message'), 'changed status' => t('changed status'), 'Forwarded' => t('Forwarded'), 'attached a file' => t('attached a file'), + 'logged an activity' => t('logged an activity'), + 'on' => t('on'), 'Process' => t('Process'), 'Loading...' => t('Loading...'), ]); ?> diff --git a/modules/helpdesk/pages/helpdesk/ticket-communication-data().php b/modules/helpdesk/pages/helpdesk/ticket-communication-data().php new file mode 100644 index 0000000..b9b372a --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/ticket-communication-data().php @@ -0,0 +1,51 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +$ticketNo = trim((string) $request->query('ticketNo', '')); +if ($ticketNo === '') { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'missing_parameters']); + + return; +} + +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); +$tenantScope = $tenantId > 0 ? (string) $tenantId : 'global'; +$cacheKey = 'module.helpdesk.ticket_comm_cache.v2.' . $tenantScope . '.' . $ticketNo; +$cacheTtl = 120; +$cached = $sessionStore->get($cacheKey); + +if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + Router::json((array) ($cached['payload'] ?? ['ok' => false])); + + return; +} + +$service = app(TicketCommunicationService::class); +$result = $service->loadTicketCommunicationFeed($ticketNo, 40); +if (($result['ok'] ?? false) === true) { + $sessionStore->set($cacheKey, [ + 'payload' => $result, + 'fetched_at' => time(), + ]); +} + +Router::json($result); diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php new file mode 100644 index 0000000..c464981 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/BcSoapGatewayTest.php @@ -0,0 +1,142 @@ +createMock(HelpdeskSettingsGateway::class); + $settings->method('isConfigured')->willReturn(true); + $settings->method('getODataBaseUrl')->willReturn('https://bc.example.com:7048/BusinessCentral-NUP/ODataV4'); + $settings->method('getCompanyName')->willReturn('Test Company'); + $settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC); + $settings->method('getBasicUser')->willReturn('tester'); + $settings->method('getBasicPassword')->willReturn('secret'); + + $oauthTokenService = $this->createMock(HelpdeskOAuthTokenService::class); + $sessionStore = $this->createMock(SessionStoreInterface::class); + $sessionStore->method('all')->willReturn([ + 'current_tenant' => ['id' => 1], + ]); + + return new class($settings, $oauthTokenService, $sessionStore, $response) extends BcSoapGateway { + /** + * @param array{http_code:int,body:string,error:string} $fakeResponse + */ + public function __construct( + HelpdeskSettingsGateway $settings, + HelpdeskOAuthTokenService $oauthTokenService, + SessionStoreInterface $sessionStore, + private readonly array $fakeResponse + ) { + parent::__construct($settings, $oauthTokenService, $sessionStore); + } + + protected function sendSoapRequest( + string $endpoint, + string $soapAction, + string $requestBody, + array $headers, + string $basicUser, + string $basicPassword + ): array { + return $this->fakeResponse; + } + }; + } + + public function testGetTicketCommunicationTextReturnsErrorForEmptyTicketNo(): void + { + $gateway = $this->createGateway([ + 'http_code' => 200, + 'body' => '', + 'error' => '', + ]); + + $result = $gateway->getTicketCommunicationText(''); + + $this->assertFalse($result['ok']); + $this->assertSame('Missing ticket number', $result['error']); + } + + public function testGetTicketCommunicationTextParsesSuccessfulResponse(): void + { + $soapBody = <<<'XML' + + + + + 0 + Hallo aus SOAP + line 1 + + + +XML; + + $gateway = $this->createGateway([ + 'http_code' => 200, + 'body' => $soapBody, + 'error' => '', + ]); + + $result = $gateway->getTicketCommunicationText('T26-1216'); + + $this->assertTrue($result['ok']); + $this->assertTrue($result['soap_used']); + $this->assertSame(0, $result['return_value']); + $this->assertSame('Hallo aus SOAP', $result['communication_text']); + $this->assertSame('line 1', $result['message_entries']); + } + + public function testGetTicketCommunicationTextReturnsErrorWhenReturnCodeIndicatesFailure(): void + { + $soapBody = <<<'XML' + + + + + 400 + + + + +XML; + + $gateway = $this->createGateway([ + 'http_code' => 200, + 'body' => $soapBody, + 'error' => '', + ]); + + $result = $gateway->getTicketCommunicationText('T26-1216'); + + $this->assertFalse($result['ok']); + $this->assertSame(400, $result['return_value']); + $this->assertSame('BC SOAP returned error code 400', $result['error']); + } + + public function testGetTicketCommunicationTextReturnsErrorForInvalidSoapXml(): void + { + $gateway = $this->createGateway([ + 'http_code' => 200, + 'body' => 'invalid-xml', + 'error' => '', + ]); + + $result = $gateway->getTicketCommunicationText('T26-1216'); + + $this->assertFalse($result['ok']); + $this->assertSame('Invalid SOAP XML response', $result['error']); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php new file mode 100644 index 0000000..1f92572 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/TicketCommunicationServiceTest.php @@ -0,0 +1,234 @@ +createMock(BcSoapGateway::class); + $soapGateway->method('getTicketCommunicationText')->willReturn([ + 'ok' => true, + 'soap_used' => true, + 'return_value' => 0, + 'communication_text' => "2026-04-03 08:10 - Alice: Hallo\n2026-04-03 08:11 - Alice: Update", + 'message_entries' => '', + ]); + + $odataGateway = $this->createMock(BcODataGateway::class); + $odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([ + [ + 'Entry_No' => 1, + 'Created_By' => 'ICTEST', + 'Creation_Date' => '2026-04-03', + 'Creation_Time' => '07:59:00.000', + 'Type' => 'Status', + 'State_Subtype' => 'Offen', + 'Record_ID' => 'ICI Support Ticket: T26-1216', + ], + ]); + + $service = new TicketCommunicationService($soapGateway, $odataGateway); + $result = $service->loadTicketCommunicationFeed('T26-1216', 40); + + $this->assertTrue($result['ok']); + $this->assertIsArray($result['entries']); + $this->assertGreaterThanOrEqual(3, count($result['entries'])); + $this->assertSame(true, $result['meta']['soap_used']); + $this->assertSame(false, $result['meta']['fallback_used']); + + $sources = array_unique(array_map(static fn (array $entry): string => (string) ($entry['source'] ?? ''), $result['entries'])); + $this->assertContains('soap', $sources); + $this->assertContains('odata', $sources); + } + + public function testLoadTicketCommunicationFeedFallsBackToODataWhenSoapFails(): void + { + $soapGateway = $this->createMock(BcSoapGateway::class); + $soapGateway->method('getTicketCommunicationText')->willReturn([ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 400, + 'communication_text' => '', + 'message_entries' => '', + 'error' => 'BC SOAP returned error code 400', + ]); + + $odataGateway = $this->createMock(BcODataGateway::class); + $odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([ + [ + 'Entry_No' => 1, + 'Created_By' => 'ICTEST', + 'Creation_Date' => '2026-04-03', + 'Creation_Time' => '07:59:00.000', + 'Type' => 'Nachricht', + 'State_Subtype' => '', + 'Record_ID' => 'Fallback entry', + ], + ]); + + $service = new TicketCommunicationService($soapGateway, $odataGateway); + $result = $service->loadTicketCommunicationFeed('T26-1216', 40); + + $this->assertTrue($result['ok']); + $this->assertNotEmpty($result['entries']); + $this->assertSame(false, $result['meta']['soap_used']); + $this->assertSame(true, $result['meta']['fallback_used']); + $this->assertSame('BC SOAP returned error code 400', $result['meta']['soap_error']); + $this->assertSame('odata', $result['entries'][0]['source']); + } + + public function testLoadDebitorCommunicationFeedUsesLastTenTickets(): void + { + $soapGateway = $this->createMock(BcSoapGateway::class); + $soapGateway->method('getTicketCommunicationText')->willReturn([ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 400, + 'communication_text' => '', + 'message_entries' => '', + 'error' => 'SOAP unavailable', + ]); + + $tickets = []; + for ($i = 1; $i <= 12; $i++) { + $tickets[] = [ + 'No' => 'T' . $i, + 'Last_Activity_Date' => sprintf('2026-04-%02dT08:00:00Z', $i), + 'Created_On' => sprintf('2026-03-%02dT08:00:00Z', $i), + ]; + } + + $odataGateway = $this->createMock(BcODataGateway::class); + $odataGateway->method('getTicketsForCustomer')->with('10001', 'Test GmbH')->willReturn($tickets); + $odataGateway->method('getTicketLog')->willReturnCallback(static function (string $ticketNo): array { + return [[ + 'Entry_No' => 1, + 'Created_By' => 'ICTEST', + 'Creation_Date' => '2026-04-03', + 'Creation_Time' => '07:59:00.000', + 'Type' => 'Nachricht', + 'State_Subtype' => '', + 'Record_ID' => 'Entry ' . $ticketNo, + ]]; + }); + + $service = new TicketCommunicationService($soapGateway, $odataGateway); + $result = $service->loadDebitorCommunicationFeed('10001', 'Test GmbH', 10, 40, 250); + + $this->assertTrue($result['ok']); + $this->assertSame(10, $result['meta']['tickets_considered']); + $this->assertSame(10, $result['meta']['tickets_with_data']); + $this->assertSame(10, $result['meta']['soap_partial_failures']); + + $ticketNos = array_unique(array_map(static fn (array $entry): string => (string) ($entry['ticket_no'] ?? ''), $result['entries'])); + $this->assertCount(10, $ticketNos); + $this->assertNotContains('T1', $ticketNos); + $this->assertNotContains('T2', $ticketNos); + $this->assertContains('T12', $ticketNos); + } + + public function testLoadTicketCommunicationFeedRespectsEntryLimit(): void + { + $soapGateway = $this->createMock(BcSoapGateway::class); + $soapGateway->method('getTicketCommunicationText')->willReturn([ + 'ok' => true, + 'soap_used' => true, + 'return_value' => 0, + 'communication_text' => "2026-04-03 08:10 - Alice: A\n2026-04-03 08:11 - Alice: B\n2026-04-03 08:12 - Alice: C", + 'message_entries' => '', + ]); + + $odataGateway = $this->createMock(BcODataGateway::class); + $odataGateway->method('getTicketLog')->with('T26-1216')->willReturn([]); + + $service = new TicketCommunicationService($soapGateway, $odataGateway); + $result = $service->loadTicketCommunicationFeed('T26-1216', 2); + + $this->assertTrue($result['ok']); + $this->assertCount(2, $result['entries']); + } + + public function testLoadTicketCommunicationFeedMapsSoapDataBlobByEntryNo(): void + { + $soapGateway = $this->createMock(BcSoapGateway::class); + $soapGateway->method('getTicketCommunicationText')->willReturn([ + 'ok' => true, + 'soap_used' => true, + 'return_value' => 100, + 'communication_text' => '[{"EntryNo":"19356","DataBlob":"

Hallo Team

"}]', + 'message_entries' => '', + ]); + + $odataGateway = $this->createMock(BcODataGateway::class); + $odataGateway->method('getTicketLog')->willReturn([ + [ + 'Entry_No' => 19356, + 'Created_By' => 'KT002452', + 'Creation_Date' => '2026-04-02', + 'Creation_Time' => '13:54:00.000', + 'Type' => 'Nachricht', + 'State_Subtype' => '', + 'Record_ID' => '', + ], + ]); + + $service = new TicketCommunicationService($soapGateway, $odataGateway); + $result = $service->loadTicketCommunicationFeed('T26-1204', 40); + + $this->assertTrue($result['ok']); + $this->assertNotEmpty($result['entries']); + $this->assertSame(false, $result['meta']['fallback_used']); + $this->assertSame('soap', $result['entries'][0]['source']); + $this->assertSame('Hallo Team', $result['entries'][0]['message']); + } + + public function testLoadTicketCommunicationFeedResolvesContactCodeToName(): void + { + $soapGateway = $this->createMock(BcSoapGateway::class); + $soapGateway->method('getTicketCommunicationText')->willReturn([ + 'ok' => false, + 'soap_used' => false, + 'return_value' => 400, + 'communication_text' => '', + 'message_entries' => '', + 'error' => 'BC SOAP returned error code 400', + ]); + + $odataGateway = $this->createMock(BcODataGateway::class); + $odataGateway->method('getTicketLog')->with('T26-1204')->willReturn([ + [ + 'Entry_No' => 19356, + 'Created_By' => 'KT002967', + 'Creation_Date' => '2026-04-02', + 'Creation_Time' => '13:54:00.000', + 'Type' => 'Nachricht', + 'State_Subtype' => '', + 'Record_ID' => 'Hallo', + ], + ]); + $odataGateway->method('getTicket')->with('T26-1204')->willReturn([ + 'No' => 'T26-1204', + 'Company_Contact_Name' => 'Aero-Dienst GmbH', + 'Current_Contact_Name' => 'Fallback Kontakt', + ]); + $odataGateway->method('getContactsForCustomer')->with('', 'Aero-Dienst GmbH')->willReturn([ + [ + 'No' => 'KT002967', + 'Name' => 'Felix Schneider', + ], + ]); + + $service = new TicketCommunicationService($soapGateway, $odataGateway); + $result = $service->loadTicketCommunicationFeed('T26-1204', 40); + + $this->assertTrue($result['ok']); + $this->assertNotEmpty($result['entries']); + $this->assertSame('Felix Schneider', $result['entries'][0]['actor']); + } +} diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 947c8a7..efc3a0f 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -111,4 +111,321 @@ color: var(--app-muted, #6c757d); font-size: 0.8125rem; } + + /* Communication feed (ticket + debtor aside) */ + .helpdesk-comm-hint { + margin: 0.75rem 1rem 0; + font-size: 0.8125rem; + color: var(--app-muted, #6c757d); + } + + .helpdesk-comm-feed { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0; + padding: 0.75rem 1rem; + list-style: none; + max-height: 26rem; + min-width: 0; + } + + .helpdesk-comm-feed > li { + margin: 0; + padding: 0; + list-style: none; + } + + #ticket-communication .helpdesk-comm-feed { + max-height: 34rem; + } + + #app-details-aside-section .helpdesk-comm-feed { + max-height: none; + height: 100%; + min-height: 0; + padding: 0.5rem 0 0; + } + + .helpdesk-chat-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-width: 100%; + min-width: 0; + } + + .helpdesk-chat-row--message { + --helpdesk-tl-x: 7px; + --helpdesk-tl-dot: 10px; + position: relative; + align-self: stretch; + padding: 0.05rem 0 0.05rem 1.35rem; + } + + .helpdesk-chat-row--customer { + align-self: flex-start; + } + + .helpdesk-chat-row--support { + align-self: flex-start; + } + + .helpdesk-chat-row--activity { + align-self: stretch; + max-width: 100%; + gap: 0.15rem; + } + + .helpdesk-chat-meta-block { + display: grid; + gap: 0.18rem; + min-width: 0; + } + + .helpdesk-chat-meta-block--center { + justify-items: center; + } + + .helpdesk-chat-meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.12rem 0.45rem; + font-size: 0.6875rem; + color: var(--app-muted, #6c757d); + min-width: 0; + } + + .helpdesk-chat-meta-row > * { + min-width: 0; + } + + .helpdesk-chat-meta-row > * + *::before { + content: "•"; + margin-right: 0.4rem; + color: color-mix(in srgb, var(--app-muted, #6c757d) 70%, transparent); + } + + .helpdesk-chat-meta-row--center { + justify-content: center; + } + + .helpdesk-chat-meta-item { + line-height: 1.35; + max-width: 100%; + overflow-wrap: anywhere; + } + + .helpdesk-chat-meta-link { + color: color-mix(in srgb, var(--app-primary, #1a56db) 88%, #0b2e7a); + text-decoration: none; + line-height: 1.35; + max-width: 100%; + overflow-wrap: anywhere; + } + + .helpdesk-chat-meta-link:hover { + text-decoration: underline; + } + + .helpdesk-chat-actor { + display: block; + font-size: 0.8125rem; + font-weight: 600; + color: var(--app-contrast, #1f2937); + overflow-wrap: anywhere; + } + + .helpdesk-chat-time { + font-size: 0.75rem; + color: var(--app-muted, #6c757d); + white-space: normal; + overflow-wrap: anywhere; + } + + .helpdesk-chat-meta-row--center .helpdesk-chat-time { + margin-left: 0; + } + + .helpdesk-chat-bubble { + margin: 0; + border: 1px solid #d0d7de; + background: #fff; + border-radius: 0.5rem; + padding: 0.62rem 0.78rem; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.45; + box-shadow: 0 1px 0 rgb(27 31 36 / 0.04); + } + + .helpdesk-chat-row--customer .helpdesk-chat-bubble { + background: var(--app-secondary); + color: var(--app-secondary-inverse); + border-color: var(--app-secondary-border); + } + + .helpdesk-chat-row--support .helpdesk-chat-bubble { + background: var(--app-primary); + border-color: var(--app-primary); + color: var(--app-primary-inverse); + } + .helpdesk-chat-row--message::before { + content: ""; + position: absolute; + left: var(--helpdesk-tl-x); + top: -0.45rem; + bottom: -0.45rem; + width: 1px; + background: color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent); + z-index: 0; + } + + .helpdesk-chat-row--message::after { + content: ""; + position: absolute; + left: calc(var(--helpdesk-tl-x) - (var(--helpdesk-tl-dot) / 2) + 0.5px); + top: 0.52rem; + width: var(--helpdesk-tl-dot); + height: var(--helpdesk-tl-dot); + border-radius: 50%; + box-sizing: border-box; + border: 2px solid + color-mix(in srgb, var(--app-border, #dee2e6) 82%, #8f9bae); + background: #fff; + z-index: 1; + } + + .helpdesk-chat-row--message > * { + position: relative; + z-index: 1; + } + + .helpdesk-chat-row--message .helpdesk-chat-meta-block, + .helpdesk-chat-row--message .helpdesk-chat-bubble { + margin-left: 1.35rem; + } + + .helpdesk-chat-activity { + --helpdesk-tl-x: 7px; + --helpdesk-tl-dot: 10px; + position: relative; + display: block; + padding: 0.1rem 0 0.1rem 1.35rem; + margin: 0; + } + + .helpdesk-chat-activity::before { + content: ""; + position: absolute; + left: var(--helpdesk-tl-x); + top: -0.45rem; + bottom: -0.45rem; + width: 1px; + background: linear-gradient( + to bottom, + transparent 0%, + color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent) 12%, + color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent) 88%, + transparent 100% + ); + z-index: 0; + } + + .helpdesk-chat-activity::after { + content: ""; + position: absolute; + left: calc(var(--helpdesk-tl-x) - (var(--helpdesk-tl-dot) / 2) + 0.5px); + top: 0.48rem; + width: var(--helpdesk-tl-dot); + height: var(--helpdesk-tl-dot); + border-radius: 50%; + box-sizing: border-box; + border: 2px solid + color-mix(in srgb, var(--app-border, #dee2e6) 86%, #8f9bae); + background: #fff; + z-index: 1; + } + + .helpdesk-chat-activity-pill { + position: relative; + z-index: 1; + display: block; + border-radius: 0; + border: 0; + border-left: 0; + background: transparent; + color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155); + padding: 0.08rem 0 0.08rem 0; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.4; + max-width: 100%; + white-space: normal; + overflow: visible; + text-overflow: clip; + overflow-wrap: anywhere; + } + + .helpdesk-chat-activity[data-variant="status"] .helpdesk-chat-activity-pill { + color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155); + } + + .helpdesk-chat-activity[data-variant="forward"] .helpdesk-chat-activity-pill { + color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155); + } + + .helpdesk-chat-activity[data-variant="file"] .helpdesk-chat-activity-pill { + color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155); + } + + #app-details-aside-section .helpdesk-chat-row { + max-width: 100%; + min-width: 0; + } + + #app-details-aside-section .helpdesk-chat-time { + margin-left: 0; + } + + #app-details-aside-section .helpdesk-chat-meta-row { + gap: 0.25rem 0.4rem; + } + + @media (min-width: 968px) { + .app-details-container { + overflow-x: hidden; + overflow-x: clip; + } + + #app-details-aside-section { + display: flex; + min-width: 0; + } + + #app-details-aside-section .helpdesk-comm-aside { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-height: 0; + } + + #app-details-aside-section #debitor-communication-content:not([hidden]) { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + } + + #app-details-aside-section #debitor-communication-feed { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; + } + } } diff --git a/modules/helpdesk/web/js/helpdesk-communication.js b/modules/helpdesk/web/js/helpdesk-communication.js new file mode 100644 index 0000000..35d5c91 --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-communication.js @@ -0,0 +1,128 @@ +function esc(str) { + const d = document.createElement('div'); + d.textContent = String(str ?? ''); + return d.innerHTML; +} + +function activityActionLabel(typeVariant, type, stateSubtype, t) { + if (typeVariant === 'status') { + const base = t('changed status'); + return stateSubtype ? `${base} (${stateSubtype})` : base; + } + if (typeVariant === 'forward') { + return t('Forwarded'); + } + if (typeVariant === 'file') { + return t('attached a file'); + } + if (type && String(type).trim() !== '') { + return type; + } + + return t('logged an activity'); +} + +function entryTimestamp(entry, t) { + const date = entry.date || ''; + const timeRaw = entry.time || ''; + const time = timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw; + if (date && time) return `${date} ${time}`; + if (entry.timestamp_iso) return entry.timestamp_iso; + if (date) return date; + return t('Unknown time'); +} + +function participantRole(entry) { + const explicitRole = String(entry.actor_role || '').trim().toLowerCase(); + if (explicitRole === 'customer' || explicitRole === 'support') { + return explicitRole; + } + + const actor = String(entry.actor || '').trim().toLowerCase(); + if (actor.startsWith('kt') || actor.includes('kontakt') || actor.includes('contact')) { + return 'customer'; + } + + return 'support'; +} + +function ticketHref(baseUrl, ticketNo) { + const base = String(baseUrl || '').trim(); + const no = String(ticketNo || '').trim(); + if (!base || !no) return ''; + + return `${base}${encodeURIComponent(no)}`; +} + +export function renderCommunicationError(message) { + return ``; +} + +export function renderCommunicationHint(message) { + return `

${esc(message)}

`; +} + +export function renderCommunicationFeed(entries, options = {}) { + const { + t = (k) => k, + emptyMessage = 'No communication found.', + showTicketNo = false, + ticketBaseUrl = '', + } = options; + + const list = Array.isArray(entries) ? entries : []; + if (list.length === 0) { + return `
+

${esc(t(emptyMessage))}

+
`; + } + + let html = '
    '; + for (const entry of list) { + const actor = entry.actor || t('Unknown user'); + const typeVariant = entry.type_variant || 'other'; + const type = entry.type || ''; + const stateSubtype = entry.state_subtype || ''; + const message = entry.message || ''; + const ticketNo = entry.ticket_no || ''; + const timeLabel = entryTimestamp(entry, t); + const ticketUrl = ticketHref(ticketBaseUrl, ticketNo); + + if (typeVariant === 'message') { + const role = participantRole(entry); + html += `
  1. `; + html += '
    '; + html += `${esc(actor)}`; + html += ''; + if (showTicketNo && ticketNo) { + if (ticketUrl) { + html += `${esc(t('Ticket'))} ${esc(ticketNo)}`; + } else { + html += `${esc(t('Ticket'))} ${esc(ticketNo)}`; + } + } + html += `${esc(timeLabel)}`; + html += ''; + html += '
    '; + html += `

    ${esc(message)}

    `; + html += '
  2. '; + continue; + } + + const activityAction = activityActionLabel(typeVariant, type, stateSubtype, t); + let activityText = `${actor} ${activityAction}`.trim(); + if (timeLabel) { + activityText += ` ${t('on')} ${timeLabel}`; + } + if (showTicketNo && ticketNo) { + activityText += ` · ${t('Ticket')} ${ticketNo}`; + } + + html += `
  3. `; + html += `
    ${esc(activityText)}
    `; + html += '
  4. '; + } + html += '
'; + + return html; +} diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js index 8d0ee77..555e6e5 100644 --- a/modules/helpdesk/web/js/helpdesk-detail.js +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -8,6 +8,11 @@ */ import { initStandardListPage } from '/js/core/app-grid-factory.js'; import { readPageConfig } from '/js/core/app-page-config.js'; +import { + renderCommunicationError, + renderCommunicationFeed, + renderCommunicationHint, +} from './helpdesk-communication.js'; const container = document.querySelector('.app-details-container[data-customer-no]'); if (container) { @@ -21,6 +26,7 @@ function init(container) { const ticketsUrl = container.dataset.ticketsUrl || ''; const ticketBaseUrl = container.dataset.ticketBaseUrl || ''; const summaryUrl = container.dataset.summaryUrl || ''; + const communicationUrl = container.dataset.communicationUrl || ''; if (!customerNo || !customerName) return; @@ -34,6 +40,7 @@ function init(container) { let summaryPromise = null; let overviewTicketsPromise = null; let ticketCategoriesPromise = null; + let debitorCommunicationPromise = null; let contactsRendered = { overview: false, full: false }; let ticketsGridInitialized = false; let ticketsGridInitializing = false; @@ -111,6 +118,21 @@ function init(container) { return ticketCategoriesPromise; } + function fetchDebitorCommunication() { + if (!debitorCommunicationPromise) { + if (!communicationUrl) { + debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' }); + } else { + const params = new URLSearchParams({ customerNo, customerName }); + debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' }) + .then(r => r.json()) + .catch(() => ({ ok: false, error: 'Network error' })); + } + } + + return debitorCommunicationPromise; + } + function hydrateTicketCategoryFilter(config, categories) { const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter'); if (!(categoryFilter instanceof HTMLSelectElement)) return; @@ -583,6 +605,35 @@ function init(container) { contactsRendered.full = true; } + async function renderDebitorCommunicationAside() { + const feedEl = document.getElementById('debitor-communication-feed'); + const loadingEl = document.getElementById('debitor-communication-loading'); + const contentEl = document.getElementById('debitor-communication-content'); + if (!feedEl || !loadingEl || !contentEl) return; + + showLoading('debitor-communication-loading'); + const result = await fetchDebitorCommunication(); + hideLoading('debitor-communication-loading'); + showContent('debitor-communication-content'); + + if (!result.ok) { + feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.')); + return; + } + + let html = ''; + if ((result.meta?.soap_partial_failures || 0) > 0) { + html += renderCommunicationHint(t('Some ticket communications could not be loaded completely.')); + } + html += renderCommunicationFeed(result.entries || [], { + t, + emptyMessage: 'No communication found.', + showTicketNo: true, + ticketBaseUrl, + }); + feedEl.innerHTML = html; + } + // --- Tab change observer --- // Watch for tab panel visibility changes to trigger lazy rendering @@ -607,4 +658,5 @@ function init(container) { // Start loading immediately — overview is the default tab renderOverview(); + renderDebitorCommunicationAside(); } diff --git a/modules/helpdesk/web/js/helpdesk-ticket.js b/modules/helpdesk/web/js/helpdesk-ticket.js index fb58fe7..4f1e303 100644 --- a/modules/helpdesk/web/js/helpdesk-ticket.js +++ b/modules/helpdesk/web/js/helpdesk-ticket.js @@ -4,6 +4,11 @@ * Loads the ticket activity log (PBI_FP_TicketLog) via fetch and renders * a chronological timeline of status changes and messages. */ +import { + renderCommunicationError, + renderCommunicationFeed, + renderCommunicationHint, +} from './helpdesk-communication.js'; const container = document.querySelector('.app-details-container[data-ticket-no]'); if (container) { @@ -13,6 +18,7 @@ if (container) { function init(container) { const ticketNo = container.dataset.ticketNo || ''; const logUrl = container.dataset.ticketLogUrl || ''; + const communicationUrl = container.dataset.ticketCommunicationUrl || ''; if (!ticketNo || !logUrl) return; @@ -157,5 +163,45 @@ function init(container) { } } + async function loadCommunication() { + const communicationEl = document.getElementById('ticket-communication'); + const loadingEl = document.getElementById('ticket-communication-loading'); + if (!communicationEl || !communicationUrl) return; + + try { + const params = new URLSearchParams({ ticketNo }); + const response = await fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' }); + const data = await response.json(); + + if (loadingEl) loadingEl.hidden = true; + + if (!data.ok) { + communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.')); + return; + } + + let html = ''; + if ( + data.meta + && data.meta.soap_used === false + && data.meta.fallback_used === true + && data.meta.soap_error + ) { + html += renderCommunicationHint(t('Live communication unavailable, showing activity fallback.')); + } + html += renderCommunicationFeed(data.entries || [], { + t, + emptyMessage: 'No communication found.', + showTicketNo: false, + }); + + communicationEl.innerHTML = html; + } catch { + if (loadingEl) loadingEl.hidden = true; + communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.')); + } + } + loadTimeline(); + loadCommunication(); }