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 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 16:32:34 +02:00
parent aee9cb10f3
commit 2f0f407268
19 changed files with 1776 additions and 851 deletions

View File

@@ -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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>,
* 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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>,
* 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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
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();

View File

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

View File

@@ -56,7 +56,16 @@ class DebitorDetailService
/**
* Load contacts for a customer (for async data endpoint).
*
* @return array{ok: bool, contacts?: array<int, array<string, mixed>>, error?: string}
* @return array{
* ok: bool,
* contacts?: array<int, array<string, mixed>>,
* 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<int, array<string, mixed>>, error?: string}
* @return array{
* ok: bool,
* tickets?: array<int, array<string, mixed>>,
* 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<string, array<string, mixed>> $rules
* @return array<int, array{rule_id: string, label: string, triggered: bool, value: string, threshold: string}>
* @return array<int, array{rule_id: string, label: string, description: string, triggered: bool, value: string, raw_value: float|int, threshold_value: int, unit: string}>
*/
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' => '%',
];
}

View File

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

View File

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