From 2f0f4072686a7370b02eae1f5e8074a9a48b8ee3 Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 5 Apr 2026 16:32:34 +0200 Subject: [PATCH] feat(helpdesk): redesign dashboards with KPI groups, skeleton loading, and unified spacing Restructure Support dashboard into 2 KPI groups (Tickets + Contracts) with section dividers, merge escalations and recommendations into "Attention needed". Redesign Sales dashboard with clickable contract timeline and dialog. Add CSS shimmer skeleton loading for all dashboard tabs and aside. Unify vertical rhythm via * + * sibling combinator on tab content containers. Remove ~265 lines of dead CSS (contract cards, escalation styles) and unused JS functions. Refactor hydrateTicketCategoryFilter into generic hydrateSelectFilter. Fix role="button" CSS bleed from shell and remove extra future year from timeline. Co-Authored-By: Claude Opus 4.6 --- lib/Http/RouteRegistrar.php | 10 +- .../Helpdesk/Service/BcODataGateway.php | 371 +++++++- .../Module/Helpdesk/Service/BcSoapGateway.php | 4 +- .../Helpdesk/Service/DebitorDetailService.php | 104 +- .../Service/HelpdeskSettingsGateway.php | 4 +- .../Service/SystemRecommendationEngine.php | 2 +- .../pages/helpdesk/debitor(default).phtml | 265 ++++-- .../helpdesk/debitor-contacts-data().php | 26 + .../debitor-controlling-dashboard-data().php | 1 - .../debitor-support-dashboard-data().php | 2 +- .../Helpdesk/Service/BcODataGatewayTest.php | 30 +- .../Service/DebitorDetailServiceTest.php | 125 ++- modules/helpdesk/web/css/helpdesk.css | 893 +++++++++++++----- modules/helpdesk/web/js/helpdesk-detail.js | 735 +++++++------- pages/admin/imports/index(default).phtml | 6 +- .../partials/app-main-aside-icon-bar.phtml | 34 +- .../AgentSystemEnforcementContractTest.php | 4 +- web/css/layout/app-aside-icon-bar.css | 9 + web/css/layout/app-sidebar.css | 2 +- 19 files changed, 1776 insertions(+), 851 deletions(-) diff --git a/lib/Http/RouteRegistrar.php b/lib/Http/RouteRegistrar.php index a6766d8..891f38a 100644 --- a/lib/Http/RouteRegistrar.php +++ b/lib/Http/RouteRegistrar.php @@ -19,8 +19,8 @@ final class RouteRegistrar { $coreRoutePaths = []; foreach ($catalog->coreRoutes() as $route) { - $path = trim((string) ($route['path'] ?? '')); - $target = trim((string) ($route['target'] ?? '')); + $path = trim($route['path']); + $target = trim($route['target']); if ($path === '' || $target === '') { continue; } @@ -31,9 +31,9 @@ final class RouteRegistrar $modulePaths = []; foreach ($this->moduleRegistry->getRoutes() as $route) { - $path = trim((string) ($route['path'] ?? '')); - $target = trim((string) ($route['target'] ?? '')); - $moduleId = trim((string) ($route['module_id'] ?? '')); + $path = trim($route['path']); + $target = trim($route['target']); + $moduleId = trim($route['module_id']); if ($path === '' || $target === '') { continue; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index e1ae5c5..d8085c0 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -47,7 +47,7 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($query); + $escaped = $this->escapeODataStrictUserInput($query); $upperQuery = mb_strtoupper($escaped); $select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code'; $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER); @@ -108,13 +108,13 @@ class BcODataGateway $cityClause = ''; if ($city !== '') { - $escapedCity = $this->escapeODataString($city); + $escapedCity = $this->escapeODataStrictUserInput($city); $cityClause = "startswith(City,'" . $escapedCity . "')"; } $searchStrategies = ['']; if ($search !== '') { - $escapedSearch = $this->escapeODataString($search); + $escapedSearch = $this->escapeODataStrictUserInput($search); $upperSearch = mb_strtoupper($escapedSearch); $searchStrategies = [ "contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')", @@ -180,7 +180,7 @@ class BcODataGateway return null; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataStrictUserInput($customerNo); $filter = "No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) . '?$filter=' . rawurlencode($filter) @@ -199,20 +199,205 @@ class BcODataGateway /** * Get contacts for a customer by customer name. * - * BC OData does not support filtering by IntegrationCustomerNo, so we - * filter by Company_Name (the customer's name in the contact card). - * * @return array> */ public function getContactsForCustomer(string $customerNo, string $customerName = ''): array { + $result = $this->getContactsForCustomerWithMeta($customerNo, $customerName); + + return $result['contacts']; + } + + /** + * Get contacts for a customer with fallback metadata. + * + * @return array{ + * contacts: array>, + * meta: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * } + * } + */ + public function getContactsForCustomerWithMeta(string $customerNo, string $customerName = ''): array + { + $customerNo = trim($customerNo); $customerName = trim($customerName); - if ($customerName === '') { + if ($customerNo === '' || $customerName === '') { + return [ + 'contacts' => [], + 'meta' => [ + 'source' => 'primary.company_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $primaryError = false; + $primaryContacts = []; + + try { + $primaryContacts = $this->fetchContactsByField('Company_Name', $customerName); + } catch (\Throwable) { + $primaryError = true; + } + + if ($primaryContacts !== []) { + return [ + 'contacts' => $primaryContacts, + 'meta' => [ + 'source' => 'primary.company_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty'; + + try { + $integrationContacts = $this->fetchContactsByField('IntegrationCustomerNo', $customerNo); + if ($integrationContacts !== []) { + return [ + 'contacts' => $integrationContacts, + 'meta' => [ + 'source' => 'fallback.integration_customer_no', + 'fallback_used' => true, + 'fallback_reason' => $fallbackReason, + ], + ]; + } + } catch (\Throwable) { + // Continue with second fallback. + } + + try { + $companyNoContacts = $this->fetchContactsByField('Company_No', $customerNo); + if ($companyNoContacts !== []) { + return [ + 'contacts' => $companyNoContacts, + 'meta' => [ + 'source' => 'fallback.company_no', + 'fallback_used' => true, + 'fallback_reason' => $fallbackReason, + ], + ]; + } + } catch (\Throwable) { + // Fall back to primary result (empty set). + } + + return [ + 'contacts' => $this->dedupeRowsByNo($primaryContacts), + 'meta' => [ + 'source' => 'primary.company_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + /** + * Get tickets for a customer by customer name. + * + * @return array> + */ + public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array + { + $result = $this->getTicketsForCustomerWithMeta($customerNo, $customerName); + + return $result['tickets']; + } + + /** + * Get tickets for a customer with fallback metadata. + * + * @return array{ + * tickets: array>, + * meta: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * } + * } + */ + public function getTicketsForCustomerWithMeta(string $customerNo, string $customerName = ''): array + { + $customerNo = trim($customerNo); + $customerName = trim($customerName); + if ($customerNo === '' || $customerName === '') { + return [ + 'tickets' => [], + 'meta' => [ + 'source' => 'primary.company_contact_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $primaryError = false; + $primaryTickets = []; + + try { + $primaryTickets = $this->fetchTicketsByCompanyContactName($customerName); + } catch (\Throwable) { + $primaryError = true; + } + + if ($primaryTickets !== []) { + return [ + 'tickets' => $primaryTickets, + 'meta' => [ + 'source' => 'primary.company_contact_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty'; + try { + $fallbackTickets = $this->fetchTicketsByCustomerNoFromLv($customerNo, $customerName); + } catch (\Throwable) { + $fallbackTickets = []; + } + + if ($fallbackTickets !== []) { + return [ + 'tickets' => $fallbackTickets, + 'meta' => [ + 'source' => 'fallback.customer_no_lv', + 'fallback_used' => true, + 'fallback_reason' => $fallbackReason, + ], + ]; + } + + return [ + 'tickets' => $primaryTickets, + 'meta' => [ + 'source' => 'primary.company_contact_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + /** + * @return array> + */ + private function fetchContactsByField(string $field, string $value): array + { + $value = trim($value); + if ($value === '') { return []; } - $escaped = $this->escapeODataString($customerName); - $filter = "Company_Name eq '" . $escaped . "'"; + $escaped = $this->escapeODataTrustedLiteral($value); + $filter = $field . " eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT) . '?$filter=' . rawurlencode($filter) . '&$top=100' @@ -223,25 +408,23 @@ class BcODataGateway return []; } - return $this->extractODataValues($response); + return $this->dedupeRowsByNo($this->extractODataValues($response)); } /** - * Get tickets for a customer by customer name. - * * Uses the PBI_FP_Tickets entity (FactPage / Power BI view) which supports - * server-side $filter and $orderby — unlike the Card-based "Tickets" entity. + * server-side $filter and $orderby. * * @return array> */ - public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array + private function fetchTicketsByCompanyContactName(string $customerName): array { $customerName = trim($customerName); if ($customerName === '') { return []; } - $escaped = $this->escapeODataString($customerName); + $escaped = $this->escapeODataTrustedLiteral($customerName); $filter = "Company_Contact_Name eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS) . '?$filter=' . rawurlencode($filter) @@ -254,7 +437,59 @@ class BcODataGateway return []; } - return $this->extractODataValues($response); + return $this->dedupeRowsByNo($this->extractODataValues($response)); + } + + /** + * Fallback ticket source by customer number using LV entity. + * + * @return array> + */ + private function fetchTicketsByCustomerNoFromLv(string $customerNo, string $customerName): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $escaped = $this->escapeODataTrustedLiteral($customerNo); + $filter = "Customer_No eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) + . '?$filter=' . rawurlencode($filter) + . '&$top=300' + . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name') + . '&$orderby=' . rawurlencode('Created_On desc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + $rows = $this->extractODataValues($response); + $mapped = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $ticketNo = trim((string) ($row['No'] ?? '')); + if ($ticketNo === '') { + continue; + } + + $mapped[] = [ + 'No' => $ticketNo, + 'Description' => trim((string) ($row['Process_Code'] ?? '')), + 'Company_Contact_Name' => $customerName, + 'Current_Contact_Name' => '', + 'Category_1_Description' => trim((string) ($row['Category_1_Code'] ?? '')), + 'Ticket_State' => trim((string) ($row['Ticket_State'] ?? '')), + 'Support_User_Name' => trim((string) ($row['Support_User_Name'] ?? '')), + 'Created_On' => trim((string) ($row['Created_On'] ?? '')), + 'Last_Activity_Date' => trim((string) ($row['Last_Activity_Date'] ?? '')), + ]; + } + + return $this->dedupeRowsByNo($mapped); } /** @@ -275,7 +510,7 @@ class BcODataGateway } $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_ESCALATION) - . '?$top=200' + . '?$top=50' . '&$select=' . rawurlencode('Code,Description,Target_Time,No_of_Escalation_Level') . '&$orderby=' . rawurlencode('Code asc'); @@ -305,11 +540,11 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($customerNo); $filter = "Customer_No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) . '?$filter=' . rawurlencode($filter) - . '&$top=250' + . '&$top=200' . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date') . '&$orderby=' . rawurlencode('Last_Activity_Date desc'); @@ -336,11 +571,11 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($customerNo); $filter = "Customer_No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) . '?$filter=' . rawurlencode($filter) - . '&$top=500' + . '&$top=300' . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name') . '&$orderby=' . rawurlencode('Created_On desc'); @@ -418,7 +653,7 @@ class BcODataGateway */ private function fetchContractsByCustomerField(string $customerNo, string $customerField, string $select): array { - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($customerNo); $filter = $customerField . " eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACTS) . '?$filter=' . rawurlencode($filter) @@ -446,12 +681,12 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($customerNo); $filter = "Customer_No eq '" . $escaped . "'"; $select = 'Header_No,Line_No,PI_Header_State,PI_Header_Description,Type,Description,Quantity,Unit_of_Measure_Code,Unit_Price,Line_Discount_Percent,Line_Amount,Line_State,Starting_Date,Ending_Date,Position,Attached_to_Line_No'; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES) . '?$filter=' . rawurlencode($filter) - . '&$top=500' + . '&$top=300' . '&$select=' . rawurlencode($select) . '&$orderby=' . rawurlencode('Header_No asc,Line_No asc'); @@ -475,7 +710,7 @@ class BcODataGateway return null; } - $escaped = $this->escapeODataString($ticketNo); + $escaped = $this->escapeODataStrictUserInput($ticketNo); $filter = "No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS) . '?$filter=' . rawurlencode($filter) @@ -509,7 +744,7 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($ticketNo); + $escaped = $this->escapeODataStrictUserInput($ticketNo); $filter = "Support_Ticket_No eq '" . $escaped . "'"; // Fetch detailed log (user, time, Record_ID) @@ -529,21 +764,31 @@ class BcODataGateway return []; } - // Fetch status subtypes (human-readable state names) - $lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV) - . '?$filter=' . rawurlencode($filter) - . '&$orderby=' . rawurlencode('Entry_No desc') - . '&$top=100' - . '&$select=Entry_No,State_Subtype'; + // Only fetch status subtypes if detail entries contain status-type rows + $hasStatusEntries = false; + foreach ($entries as $entry) { + if (($entry['Type'] ?? '') === 'Status') { + $hasStatusEntries = true; + break; + } + } - $lvResponse = $this->request('GET', $lvUrl); $subtypeMap = []; - if ($lvResponse !== null) { - foreach ($this->extractODataValues($lvResponse) as $lv) { - $entryNo = $lv['Entry_No'] ?? null; - $subtype = (string) ($lv['State_Subtype'] ?? ''); - if ($entryNo !== null && $subtype !== '' && $subtype !== '0') { - $subtypeMap[$entryNo] = $subtype; + if ($hasStatusEntries) { + $lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV) + . '?$filter=' . rawurlencode($filter) + . '&$orderby=' . rawurlencode('Entry_No desc') + . '&$top=100' + . '&$select=Entry_No,State_Subtype'; + + $lvResponse = $this->request('GET', $lvUrl); + if ($lvResponse !== null) { + foreach ($this->extractODataValues($lvResponse) as $lv) { + $entryNo = $lv['Entry_No'] ?? null; + $subtype = (string) ($lv['State_Subtype'] ?? ''); + if ($entryNo !== null && $subtype !== '' && $subtype !== '0') { + $subtypeMap[$entryNo] = $subtype; + } } } } @@ -639,7 +884,7 @@ class BcODataGateway return ['error' => 'Not configured or empty customer number']; } - $escapedCustomerNo = $this->escapeODataString($customerNo); + $escapedCustomerNo = $this->escapeODataStrictUserInput($customerNo); $results = []; // 1. Customer entity @@ -656,7 +901,7 @@ class BcODataGateway // 2. Contact entity — filter by Company_Name $contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT); if ($customerName !== '') { - $escapedCustomerName = $this->escapeODataString((string) $customerName); + $escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName); $contactFilteredUrl = $contactBaseUrl . '?$filter=' . rawurlencode("Company_Name eq '" . $escapedCustomerName . "'") . '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title'); @@ -668,7 +913,7 @@ class BcODataGateway // 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name $ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS); if ($customerName !== '') { - $escapedCustomerName = $this->escapeODataString((string) $customerName); + $escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName); $ticketsFilteredUrl = $ticketsBaseUrl . '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $escapedCustomerName . "'") . '&$top=10&$orderby=' . rawurlencode('Created_On desc') @@ -887,15 +1132,35 @@ class BcODataGateway } /** - * Sanitize and escape a string for use inside an OData $filter string literal. - * - * Rejects characters that could manipulate OData filter logic (parentheses, - * boolean operators). Only allows letters, digits, spaces, dots, hyphens, - * underscores, umlauts and common punctuation. + * @param array> $rows + * @return array> + */ + private function dedupeRowsByNo(array $rows): array + { + $uniqueRows = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $no = trim((string) ($row['No'] ?? '')); + $rowKey = $no !== '' ? $no : md5((string) json_encode($row)); + if (isset($uniqueRows[$rowKey])) { + continue; + } + + $uniqueRows[$rowKey] = $row; + } + + return array_values($uniqueRows); + } + + /** + * Sanitize and escape a user-provided string for use in OData filters. * * @throws \InvalidArgumentException If the value contains disallowed characters. */ - private function escapeODataString(string $value): string + private function escapeODataStrictUserInput(string $value): string { if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) { throw new \InvalidArgumentException('Search query contains invalid characters.'); @@ -904,6 +1169,14 @@ class BcODataGateway return str_replace("'", "''", $value); } + /** + * Escape trusted literals (e.g. BC-sourced names/codes) for OData filters. + */ + private function escapeODataTrustedLiteral(string $value): string + { + return str_replace("'", "''", trim($value)); + } + private function resolveCurrentTenantId(): int { $session = $this->sessionStore->all(); diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php index 43050cf..c0a1939 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php @@ -109,14 +109,14 @@ class BcSoapGateway } $parsed = $this->parseGetTicketCommunicationTextResponse($rawBody); - if (!($parsed['ok'] ?? false)) { + if (!$parsed['ok']) { return [ 'ok' => false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', - 'error' => (string) ($parsed['error'] ?? 'Could not parse SOAP response'), + 'error' => isset($parsed['error']) ? (string) $parsed['error'] : 'Could not parse SOAP response', ]; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index c50e96f..a99a316 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -56,7 +56,16 @@ class DebitorDetailService /** * Load contacts for a customer (for async data endpoint). * - * @return array{ok: bool, contacts?: array>, error?: string} + * @return array{ + * ok: bool, + * contacts?: array>, + * meta?: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * }, + * error?: string + * } */ public function loadContacts(string $customerNo, string $customerName): array { @@ -72,18 +81,38 @@ class DebitorDetailService } try { - $contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName); + $result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName); } catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; } - return ['ok' => true, 'contacts' => $contacts]; + $contacts = $result['contacts']; + $meta = $result['meta']; + + return [ + 'ok' => true, + 'contacts' => $contacts, + 'meta' => [ + 'source' => $meta['source'], + 'fallback_used' => $meta['fallback_used'], + 'fallback_reason' => $meta['fallback_reason'], + ], + ]; } /** * Load tickets for a customer (for async data endpoint). * - * @return array{ok: bool, tickets?: array>, error?: string} + * @return array{ + * ok: bool, + * tickets?: array>, + * meta?: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * }, + * error?: string + * } */ public function loadTickets(string $customerNo, string $customerName): array { @@ -99,12 +128,23 @@ class DebitorDetailService } try { - $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName); + $result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName); } catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; } - return ['ok' => true, 'tickets' => $tickets]; + $tickets = $result['tickets']; + $meta = $result['meta']; + + return [ + 'ok' => true, + 'tickets' => $tickets, + 'meta' => [ + 'source' => $meta['source'], + 'fallback_used' => $meta['fallback_used'], + 'fallback_reason' => $meta['fallback_reason'], + ], + ]; } /** @@ -162,7 +202,7 @@ class DebitorDetailService $tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : []; $escalation = $this->loadEscalationHealth($customerNo, $tickets); - if (!($escalation['ok'] ?? false)) { + if (!$escalation['ok']) { $escalation = null; } @@ -277,7 +317,7 @@ class DebitorDetailService } $targetSeconds = $targetSecondsByCode[$escalationCode] ?? null; - if (!is_int($targetSeconds) || $targetSeconds <= 0) { + if (!is_int($targetSeconds)) { continue; } @@ -316,12 +356,12 @@ class DebitorDetailService } usort($entries, static function (array $a, array $b): int { - $cmp = ((int) ($b['overdue_seconds'] ?? 0)) <=> ((int) ($a['overdue_seconds'] ?? 0)); + $cmp = $b['overdue_seconds'] <=> $a['overdue_seconds']; if ($cmp !== 0) { return $cmp; } - return strcmp((string) ($a['ticket_no'] ?? ''), (string) ($b['ticket_no'] ?? '')); + return strcmp($a['ticket_no'], $b['ticket_no']); }); return [ @@ -1362,11 +1402,6 @@ class DebitorDetailService 'backlog_critical' => $criticalOpen, ], 'trend' => array_values($buckets), - 'efficiency' => [ - 'resolution_hours' => $resolutionMedian, - 'unassigned_count' => $unassignedOpen, - 'oldest_open_hours' => $oldestOpenAgeHours, - ], 'risk_indicators' => $riskIndicators, 'risk_summary' => [ 'triggered' => $triggeredCount, @@ -1457,7 +1492,7 @@ class DebitorDetailService /** * @param array> $rules - * @return array + * @return array */ private static function evaluateControllingRisks( array $rules, @@ -1478,10 +1513,13 @@ class DebitorDetailService : ($createdInPeriod > 0 ? 100.0 : 0.0); $indicators[] = [ 'rule_id' => 'ticket_volume_increase', - 'label' => 'Ticket volume increase', - 'triggered' => $increase > $threshold, + 'label' => 'Ticket volume', + 'description' => 'risk_desc_ticket_volume', + 'triggered' => $increase >= $threshold, 'value' => ($increase > 0 ? '+' : '') . $increase . '%', - 'threshold' => $threshold . '%', + 'raw_value' => max(0, $increase), + 'threshold_value' => $threshold, + 'unit' => '%', ]; } @@ -1489,13 +1527,17 @@ class DebitorDetailService $rule = is_array($rules['avg_resolution_high'] ?? null) ? $rules['avg_resolution_high'] : []; if (($rule['enabled'] ?? true) === true) { $threshold = (int) ($rule['threshold_hours'] ?? 72); - $triggered = $avgResolution !== null && $avgResolution > $threshold; + $triggered = $avgResolution !== null && $avgResolution >= $threshold; + $rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0; $indicators[] = [ 'rule_id' => 'avg_resolution_high', - 'label' => 'High avg. resolution time', + 'label' => 'Resolution time', + 'description' => 'risk_desc_resolution_time', 'triggered' => $triggered, 'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—', - 'threshold' => $threshold . 'h', + 'raw_value' => $rawValue, + 'threshold_value' => $threshold, + 'unit' => 'h', ]; } @@ -1505,10 +1547,13 @@ class DebitorDetailService $threshold = (int) ($rule['threshold_hours'] ?? 168); $indicators[] = [ 'rule_id' => 'open_aging', - 'label' => 'Open ticket aging', - 'triggered' => $oldestOpenAgeHours > $threshold, + 'label' => 'Oldest open ticket', + 'description' => 'risk_desc_open_aging', + 'triggered' => $oldestOpenAgeHours >= $threshold, 'value' => $oldestOpenAgeHours . 'h', - 'threshold' => $threshold . 'h', + 'raw_value' => $oldestOpenAgeHours, + 'threshold_value' => $threshold, + 'unit' => 'h', ]; } @@ -1518,10 +1563,13 @@ class DebitorDetailService $threshold = (int) ($rule['threshold_percent'] ?? 20); $indicators[] = [ 'rule_id' => 'unassigned_ratio', - 'label' => 'Unassigned ticket ratio', - 'triggered' => $unassignedRate > $threshold, + 'label' => 'Unassigned', + 'description' => 'risk_desc_unassigned', + 'triggered' => $unassignedRate >= $threshold, 'value' => $unassignedRate . '%', - 'threshold' => $threshold . '%', + 'raw_value' => $unassignedRate, + 'threshold_value' => $threshold, + 'unit' => '%', ]; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php index b0bc0e8..67931a3 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php @@ -383,7 +383,7 @@ class HelpdeskSettingsGateway { $normalized = self::normalizeSystemRecommendationsConfig($config); $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (!is_string($encoded) || $encoded === '') { + if (!is_string($encoded)) { return false; } @@ -617,7 +617,7 @@ class HelpdeskSettingsGateway { $normalized = self::normalizeControllingRiskConfig($config); $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (!is_string($encoded) || $encoded === '') { + if (!is_string($encoded)) { return false; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php index cac612d..4d241d6 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php @@ -127,7 +127,7 @@ class SystemRecommendationEngine continue; } - $escalationCode = strtoupper(trim((string) ($match['escalation_code'] ?? ''))); + $escalationCode = strtoupper(trim($match['escalation_code'])); if ($escalationCode === '' || !isset($codeMap[$escalationCode])) { continue; } diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index fd83d36..b10f432 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -93,48 +93,93 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
-
-