feat(helpdesk): add ticket/debtor communication feed and chat timeline UI

This commit is contained in:
2026-04-03 16:45:41 +02:00
parent 8de67fa882
commit e897cc2c56
18 changed files with 2372 additions and 11 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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)
));
}
}

View File

@@ -0,0 +1,360 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
/**
* Gateway for Business Central SOAP codeunit calls used by helpdesk communication.
*/
class BcSoapGateway
{
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
private const CODEUNIT_NAME = 'ICISupportWebserviceAPI';
private const SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketCommunicationText';
public function __construct(
private readonly HelpdeskSettingsGateway $settingsGateway,
private readonly HelpdeskOAuthTokenService $oauthTokenService,
private readonly SessionStoreInterface $sessionStore
) {
}
/**
* @return array{
* ok: bool,
* soap_used: bool,
* return_value: int,
* communication_text: string,
* message_entries: string,
* error?: string
* }
*/
public function getTicketCommunicationText(string $ticketNo, string $messageEntries = ''): array
{
$ticketNo = trim($ticketNo);
if ($ticketNo === '') {
return [
'ok' => 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 <<<XML
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<soap:Body>
<urn:GetTicketCommunicationText>
<urn:ticketNo>{$escapedTicketNo}</urn:ticketNo>
<urn:communicationText></urn:communicationText>
<urn:messageEntries>{$escapedMessageEntries}</urn:messageEntries>
</urn:GetTicketCommunicationText>
</soap:Body>
</soap:Envelope>
XML;
}
/**
* @return array{headers?: array<int, string>, 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;
}
}

View File

@@ -0,0 +1,872 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
/**
* Aggregates ticket communication from SOAP and OData into a normalized feed.
*/
class TicketCommunicationService
{
public function __construct(
private readonly BcSoapGateway $bcSoapGateway,
private readonly BcODataGateway $bcODataGateway
) {
}
/**
* @return array{
* ok: bool,
* entries?: array<int, array<string, string>>,
* meta?: array<string, mixed>,
* 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<int, array<string, string>>,
* meta?: array<string, mixed>,
* 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<int, array<string, mixed>> $ticketLog
* @param array<string, string> $soapMessageMap
* @return array<int, array<string, string>>
*/
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<int, array<string, string>>
*/
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<int, array<string, mixed>> $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<string, string>
*/
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<int, array<string, string>>
*/
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('#<br\s*/?>#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<string, string>
*/
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<string, string>
*/
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<int, array<string, string>> $primary
* @param array<int, array<string, string>> $secondary
* @return array<int, array<string, string>>
*/
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<string, string> $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<int, array<string, string>> $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('#<br\s*/?>#i', "\n", $message) ?? $message;
$message = preg_replace('#</p>\s*<p>#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<int, array<string, string>> $odataEntries
* @param array<int, array<string, string>> $soapEntries
* @return array<string, string>
*/
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<int, array<string, string>> $entries
* @return array<int, array<string, string>>
*/
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<int, array<string, string>> $entries
* @return array<int, string>
*/
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';
}
}

View File

@@ -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' => [],

View File

@@ -38,6 +38,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
data-tickets-url="<?php e(lurl('helpdesk/debitor-tickets-data')); ?>"
data-ticket-base-url="<?php e(lurl('helpdesk/ticket/')); ?>"
data-summary-url="<?php e(lurl('helpdesk/debitor-summary-data')); ?>"
data-communication-url="<?php e(lurl('helpdesk/debitor-communication-data')); ?>"
>
<section>
<?php
@@ -222,6 +223,22 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
</div>
<?php endif; ?>
</section>
<?php if (!$hasError && $customer !== []): ?>
<aside id="app-details-aside-section">
<div class="app-details-aside-section helpdesk-comm-aside">
<hgroup>
<h2><?php e(t('Ticket communication')); ?></h2>
<p><?php e(t('Last 5 tickets')); ?></p>
</hgroup>
<hr>
<div id="debitor-communication-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<div id="debitor-communication-content" hidden>
<div id="debitor-communication-feed"></div>
</div>
</div>
</aside>
<?php endif; ?>
</div>
<!-- Grid.js tickets config -->
@@ -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...'),
]); ?></script>

View File

@@ -0,0 +1,53 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
$request = requestInput();
if ($request->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);

View File

@@ -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);

View File

@@ -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']));

View File

@@ -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
<div class="app-details-container"
data-ticket-no="<?php e($ticketNo); ?>"
data-ticket-log-url="<?php e($ticketLogUrl); ?>"
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
>
<section>
<?php
@@ -127,14 +130,20 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
</div>
</div>
<!-- Activity timeline — loaded async -->
<div class="grid">
<!-- Activity timeline + communication feed — loaded async -->
<div class="grid grid-2">
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Activity timeline')); ?></div>
<div id="ticket-timeline">
<div id="timeline-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
</div>
</div>
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Communication history')); ?></div>
<div id="ticket-communication">
<div id="ticket-communication-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
</div>
</div>
</div>
<?php endif; ?>
</section>
@@ -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...'),
]); ?></script>

View File

@@ -0,0 +1,51 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
$request = requestInput();
if ($request->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);

View File

@@ -0,0 +1,142 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use PHPUnit\Framework\TestCase;
class BcSoapGatewayTest extends TestCase
{
/**
* @param array{http_code:int,body:string,error:string} $response
*/
private function createGateway(array $response): BcSoapGateway
{
$settings = $this->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'
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetTicketCommunicationText_Result xmlns="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<return_value>0</return_value>
<communicationText>Hallo aus SOAP</communicationText>
<messageEntries>line 1</messageEntries>
</GetTicketCommunicationText_Result>
</soap:Body>
</soap:Envelope>
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'
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetTicketCommunicationText_Result xmlns="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<return_value>400</return_value>
<communicationText></communicationText>
</GetTicketCommunicationText_Result>
</soap:Body>
</soap:Envelope>
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']);
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
use PHPUnit\Framework\TestCase;
class TicketCommunicationServiceTest extends TestCase
{
public function testLoadTicketCommunicationFeedMergesSoapAndOData(): 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: 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":"<p>Hallo <strong>Team</strong></p>"}]',
'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']);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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 `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
}
export function renderCommunicationHint(message) {
return `<p class="helpdesk-comm-hint">${esc(message)}</p>`;
}
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 `<div class="app-empty-state app-empty-state-compact">
<p class="app-empty-state-message">${esc(t(emptyMessage))}</p>
</div>`;
}
let html = '<ol class="helpdesk-comm-feed" role="list">';
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 += `<li class="helpdesk-chat-row helpdesk-chat-row--message helpdesk-chat-row--${esc(role)}" data-variant="${esc(typeVariant)}">`;
html += '<div class="helpdesk-chat-meta-block">';
html += `<strong class="helpdesk-chat-actor">${esc(actor)}</strong>`;
html += '<small class="helpdesk-chat-meta-row">';
if (showTicketNo && ticketNo) {
if (ticketUrl) {
html += `<a class="helpdesk-chat-meta-link" href="${esc(ticketUrl)}">${esc(t('Ticket'))} ${esc(ticketNo)}</a>`;
} else {
html += `<span class="helpdesk-chat-meta-item">${esc(t('Ticket'))} ${esc(ticketNo)}</span>`;
}
}
html += `<span class="helpdesk-chat-meta-item helpdesk-chat-time">${esc(timeLabel)}</span>`;
html += '</small>';
html += '</div>';
html += `<p class="helpdesk-chat-bubble">${esc(message)}</p>`;
html += '</li>';
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 += `<li class="helpdesk-chat-row helpdesk-chat-row--activity" data-variant="${esc(typeVariant)}">`;
html += `<div class="helpdesk-chat-activity" data-variant="${esc(typeVariant)}"><span class="helpdesk-chat-activity-pill">${esc(activityText)}</span></div>`;
html += '</li>';
}
html += '</ol>';
return html;
}

View File

@@ -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();
}

View File

@@ -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();
}