feat(helpdesk): add dashboards, communication feed, settings UI and fix routing

Add support/sales/controlling dashboards with KPIs, trend charts and
risk indicators. Add debitor communication timeline, contact filters,
system recommendations engine, and configurable controlling risk rules.

Rename search→index to fix query-string preservation on back navigation.
Remove fake escalation rate metric, add KPI info tooltips, and switch
trend chart colors to red (created) / green (closed) for clarity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 18:34:03 +02:00
parent e897cc2c56
commit aee9cb10f3
43 changed files with 7451 additions and 635 deletions

View File

@@ -21,8 +21,12 @@ class TicketCommunicationService
* error?: string
* }
*/
public function loadTicketCommunicationFeed(string $ticketNo, int $maxEntries = 40): array
{
public function loadTicketCommunicationFeed(
string $ticketNo,
int $maxEntries = 40,
array $actorNameMap = [],
bool $allowPerTicketActorLookup = true
): array {
$ticketNo = trim($ticketNo);
if ($ticketNo === '') {
return ['ok' => false, 'error' => 'Missing ticket number'];
@@ -42,18 +46,18 @@ class TicketCommunicationService
$messageEntries = $this->buildSoapMessageEntriesFromTicketLog($ticketLog);
$soapResult = $this->bcSoapGateway->getTicketCommunicationText($ticketNo, $messageEntries);
$soapError = (string) ($soapResult['error'] ?? '');
$soapUsed = (bool) ($soapResult['soap_used'] ?? false);
$soapUsed = (bool) $soapResult['soap_used'];
$soapEntries = [];
$soapMessageMap = [];
if (($soapResult['ok'] ?? false) === true) {
if ($soapResult['ok'] === true) {
$soapEntries = $this->parseSoapEntries(
$ticketNo,
(string) ($soapResult['communication_text'] ?? ''),
(string) ($soapResult['message_entries'] ?? '')
(string) $soapResult['communication_text'],
(string) $soapResult['message_entries']
);
$soapMessageMap = $this->extractSoapMessageMap(
(string) ($soapResult['communication_text'] ?? ''),
(string) ($soapResult['message_entries'] ?? '')
(string) $soapResult['communication_text'],
(string) $soapResult['message_entries']
);
}
@@ -61,10 +65,15 @@ class TicketCommunicationService
$odataEntries = $this->mapODataEntries($ticketNo, $ticketLog, $soapMessageMap);
}
$actorNameMap = $this->resolveContactActorNames($ticketNo, $odataEntries, $soapEntries);
if ($actorNameMap !== []) {
$odataEntries = $this->applyActorNameMap($odataEntries, $actorNameMap);
$soapEntries = $this->applyActorNameMap($soapEntries, $actorNameMap);
} elseif ($allowPerTicketActorLookup) {
$resolvedActorNameMap = $this->resolveContactActorNames($ticketNo, $odataEntries, $soapEntries);
if ($resolvedActorNameMap !== []) {
$odataEntries = $this->applyActorNameMap($odataEntries, $resolvedActorNameMap);
$soapEntries = $this->applyActorNameMap($soapEntries, $resolvedActorNameMap);
}
}
$merged = $this->mergeEntries($odataEntries, $soapEntries, $maxEntries);
@@ -161,11 +170,17 @@ class TicketCommunicationService
$allEntries = [];
$ticketsWithData = 0;
$soapPartialFailures = 0;
$actorNameMap = $this->buildContactActorNameMapForDebitor($customerName);
foreach (array_keys($ticketsToProcess) as $ticketNo) {
$feed = $this->loadTicketCommunicationFeed($ticketNo, $maxEntriesPerTicket);
$feed = $this->loadTicketCommunicationFeed(
$ticketNo,
$maxEntriesPerTicket,
$actorNameMap,
false
);
$ticketEntries = [];
if ($feed['ok'] ?? false) {
if ($feed['ok']) {
$ticketEntries = is_array($feed['entries'] ?? null) ? $feed['entries'] : [];
}
@@ -428,7 +443,7 @@ class TicketCommunicationService
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]) : '';
$time = $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) {
@@ -837,6 +852,38 @@ class TicketCommunicationService
return $value !== '' && preg_match('/^KT\d+$/', $value) === 1;
}
/**
* @return array<string, string>
*/
private function buildContactActorNameMapForDebitor(string $customerName): array
{
$customerName = trim($customerName);
if ($customerName === '') {
return [];
}
try {
$contacts = $this->bcODataGateway->getContactsForCustomer('', $customerName);
} catch (\Throwable) {
return [];
}
$nameMap = [];
foreach ($contacts as $contact) {
if (!is_array($contact)) {
continue;
}
$contactNo = strtoupper(trim((string) ($contact['No'] ?? '')));
$contactName = trim((string) ($contact['Name'] ?? ''));
if ($contactNo === '' || $contactName === '') {
continue;
}
$nameMap[$contactNo] = $contactName;
}
return $nameMap;
}
private function normalizeActorRole(string $createdByType, string $actor): string
{
$type = mb_strtolower(trim($createdByType));