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:
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => '%',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -93,48 +93,93 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
|
||||
<!-- Support panel -->
|
||||
<div data-tab-panel="support">
|
||||
<div id="support-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
<div id="support-content" hidden>
|
||||
<div id="support-loading">
|
||||
<div class="helpdesk-support-metrics">
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="open">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-open-tickets">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-open-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('New (30d)')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-created-30d">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-created-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="closed">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Resolved (30d)')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-closed-30d">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-closed-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Avg. age open')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-avg-age">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-age-hint"></p>
|
||||
</article>
|
||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||
<div class="helpdesk-skeleton-kpi">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Analysis')); ?></h3>
|
||||
<div class="grid grid-2" id="support-analysis-grid">
|
||||
<div class="app-stats-table" id="support-recommendations-wrapper">
|
||||
<div class="app-stats-table-header"><?php e(t('System recommendations')); ?></div>
|
||||
<div id="support-system-recommendations"></div>
|
||||
<div class="helpdesk-skeleton-divider"></div>
|
||||
<div class="helpdesk-support-metrics">
|
||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||
<div class="helpdesk-skeleton-kpi">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div class="helpdesk-skeleton-divider"></div>
|
||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||
<div class="helpdesk-skeleton-row">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-stats-table" id="support-contracts-wrapper">
|
||||
<div class="app-stats-table-header"><?php e(t('Contracts & products')); ?></div>
|
||||
<div id="support-contracts-widget"></div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div id="support-content" hidden>
|
||||
<section>
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Tickets')); ?></h3>
|
||||
<div class="helpdesk-support-metrics">
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="open">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-open-tickets">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-open-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('New (30d)')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-created-30d">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-created-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="closed">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Resolved (30d)')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-closed-30d">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-closed-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-sort="Last_Activity_Date:asc">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Avg. age open')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-avg-age">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-age-hint"></p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="app-stats-table" id="support-escalation-wrapper">
|
||||
<div class="app-stats-table-header"><?php e(t('Escalated tickets')); ?></div>
|
||||
<div id="support-escalation-widget"></div>
|
||||
</div>
|
||||
<section id="support-contract-kpis-wrapper" hidden>
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Contracts & products')); ?></h3>
|
||||
<div class="helpdesk-support-metrics" id="support-contract-kpis">
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-active-contracts">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-active-contracts-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-monthly-volume">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-monthly-volume-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Support hours')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-support-hours">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-support-hours-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Next invoicing')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="support-kpi-next-invoicing">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="support-kpi-next-invoicing-trend"></p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="support-recommendations-wrapper" hidden>
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Attention needed')); ?></h3>
|
||||
<div id="support-system-recommendations"></div>
|
||||
</section>
|
||||
|
||||
<section class="helpdesk-support-ticket-list">
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket list')); ?></h3>
|
||||
@@ -149,32 +194,56 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
|
||||
<!-- Sales panel -->
|
||||
<div data-tab-panel="sales" hidden>
|
||||
<div id="sales-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
||||
<div id="sales-content" hidden>
|
||||
<div id="sales-loading">
|
||||
<div class="helpdesk-support-metrics">
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-monthly-volume">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-monthly-volume-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-active-contracts">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-active-contracts-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Avg. contract value')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-avg-value">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-avg-value-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Next invoicing')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-next-invoicing">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-next-invoicing-trend"></p>
|
||||
</article>
|
||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||
<div class="helpdesk-skeleton-kpi">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div class="helpdesk-skeleton-divider"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-line" style="width:30%;margin-bottom:0.75rem"></div>
|
||||
<?php for ($i = 0; $i < 3; $i++): ?>
|
||||
<div class="helpdesk-skeleton-row">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div id="sales-content" hidden>
|
||||
<section>
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Contracts & products')); ?></h3>
|
||||
<div class="helpdesk-support-metrics">
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-monthly-volume">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-monthly-volume-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-active-contracts">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-active-contracts-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Avg. contract value')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-avg-value">0</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-avg-value-trend"></p>
|
||||
</article>
|
||||
<article class="helpdesk-support-metric">
|
||||
<p class="helpdesk-support-metric-label"><?php e(t('Next invoicing')); ?></p>
|
||||
<p class="helpdesk-support-metric-value" id="sales-kpi-next-invoicing">—</p>
|
||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-next-invoicing-trend"></p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="sales-contracts-detail"></div>
|
||||
<section id="sales-trend-section">
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Contract trend')); ?></h3>
|
||||
<div id="sales-trend-chart"></div>
|
||||
</section>
|
||||
|
||||
<section id="sales-contacts-section">
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Contacts')); ?></h3>
|
||||
@@ -193,8 +262,26 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
|
||||
<!-- Controlling panel -->
|
||||
<div data-tab-panel="controlling" hidden>
|
||||
<div id="controlling-spinner" class="helpdesk-tab-spinner">
|
||||
<div class="spinner"></div>
|
||||
<div id="controlling-spinner">
|
||||
<div class="helpdesk-support-metrics">
|
||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||
<div class="helpdesk-skeleton-kpi">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div class="helpdesk-skeleton-divider"></div>
|
||||
<div class="helpdesk-skeleton" style="height:10rem;margin-bottom:1rem"></div>
|
||||
<div class="helpdesk-skeleton-divider"></div>
|
||||
<?php for ($i = 0; $i < 3; $i++): ?>
|
||||
<div class="helpdesk-skeleton-row">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div id="controlling-content" hidden>
|
||||
<div class="helpdesk-segment-control" id="controlling-period-selector">
|
||||
@@ -237,12 +324,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
</div>
|
||||
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Support efficiency')); ?></div>
|
||||
<div id="controlling-efficiency"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Risk indicators')); ?></div>
|
||||
<div class="app-stats-table-header"><?php e(t('Risk assessment')); ?></div>
|
||||
<div id="controlling-risk-indicators"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +343,12 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
<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-loading">
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble helpdesk-skeleton-aside-bubble-short"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble helpdesk-skeleton-aside-bubble-alt"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble"></div>
|
||||
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble helpdesk-skeleton-aside-bubble-alt" style="width:50%"></div>
|
||||
</div>
|
||||
<div id="debitor-communication-content" hidden>
|
||||
<div id="debitor-communication-feed"></div>
|
||||
</div>
|
||||
@@ -340,9 +427,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
'Critical (>48h)' => t('Critical (>48h)'),
|
||||
'Oldest open' => t('Oldest open'),
|
||||
'Ticket list' => t('Ticket list'),
|
||||
'System recommendations' => t('System recommendations'),
|
||||
'Action' => t('Action'),
|
||||
'Age (h)' => t('Age (h)'),
|
||||
'Attention needed' => t('Attention needed'),
|
||||
'No system recommendations right now.' => t('No system recommendations right now.'),
|
||||
'helpdesk.recommendation.escalation_overdue' => t('Escalation SLA exceeded by {overdue_hours}h ({escalation_code}).'),
|
||||
'helpdesk.recommendation.high_risk_aging' => t('High-risk ticket ({escalation_code}) is open for {age_hours}h.'),
|
||||
@@ -353,21 +438,11 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
'No contracts found for this customer.' => t('No contracts found for this customer.'),
|
||||
'Contract No.' => t('Contract No.'),
|
||||
'Product type' => t('Product type'),
|
||||
'Next invoicing' => t('Next invoicing'),
|
||||
'Amount' => t('Amount'),
|
||||
'Active contracts' => t('Active contracts'),
|
||||
'Total contracts' => t('Total contracts'),
|
||||
'Could not load contracts.' => t('Could not load contracts.'),
|
||||
'Escalations' => t('Escalations'),
|
||||
'Escalations are not available yet.' => t('Escalations are not available yet.'),
|
||||
'Escalated tickets' => t('Escalated tickets'),
|
||||
'No escalated tickets right now.' => t('No escalated tickets right now.'),
|
||||
'Could not load escalated tickets.' => t('Could not load escalated tickets.'),
|
||||
'Escalation code' => t('Escalation code'),
|
||||
'SLA target' => t('SLA target'),
|
||||
'Overdue by' => t('Overdue by'),
|
||||
'day' => t('day'),
|
||||
'days' => t('days'),
|
||||
'Ticket volume' => t('Ticket volume'),
|
||||
'Close rate' => t('Close rate'),
|
||||
'Avg. resolution' => t('Avg. resolution'),
|
||||
@@ -376,18 +451,23 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
'Backlog growing' => t('Backlog growing'),
|
||||
'unassigned' => t('unassigned'),
|
||||
'Ticket trend' => t('Ticket trend'),
|
||||
'Support efficiency' => t('Support efficiency'),
|
||||
'Risk indicators' => t('Risk indicators'),
|
||||
'Risk assessment' => t('Risk assessment'),
|
||||
'vs. prev. period' => t('vs. prev. period'),
|
||||
'Low' => t('Low'),
|
||||
'Medium' => t('Medium'),
|
||||
'High' => t('High'),
|
||||
'flags' => t('flags'),
|
||||
'All checks passed' => t('All checks passed'),
|
||||
'of' => t('of'),
|
||||
'checks flagged' => t('checks flagged'),
|
||||
'Limit' => t('Limit'),
|
||||
'No controlling data available.' => t('No controlling data available.'),
|
||||
'Ticket volume increase' => t('Ticket volume increase'),
|
||||
'High avg. resolution time' => t('High avg. resolution time'),
|
||||
'Open ticket aging' => t('Open ticket aging'),
|
||||
'Unassigned ticket ratio' => t('Unassigned ticket ratio'),
|
||||
'Resolution time' => t('Resolution time'),
|
||||
'Oldest open ticket' => t('Oldest open ticket'),
|
||||
'Unassigned' => t('Unassigned'),
|
||||
'risk_desc_ticket_volume' => t('risk_desc_ticket_volume'),
|
||||
'risk_desc_resolution_time' => t('risk_desc_resolution_time'),
|
||||
'risk_desc_open_aging' => t('risk_desc_open_aging'),
|
||||
'risk_desc_unassigned' => t('risk_desc_unassigned'),
|
||||
'Could not load controlling dashboard.' => t('Could not load controlling dashboard.'),
|
||||
'Without assignee' => t('Without assignee'),
|
||||
'Created' => t('Created'),
|
||||
@@ -426,7 +506,11 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
'Monthly volume' => t('Monthly volume'),
|
||||
'Support hours' => t('Support hours'),
|
||||
'Next invoicing' => t('Next invoicing'),
|
||||
'Contract overview' => t('Contract overview'),
|
||||
'Contract trend' => t('Contract trend'),
|
||||
'New contracts' => t('New contracts'),
|
||||
'Ended contracts' => t('Ended contracts'),
|
||||
'active' => t('active'),
|
||||
'ended' => t('ended'),
|
||||
'No contract details available.' => t('No contract details available.'),
|
||||
'Could not load sales dashboard.' => t('Could not load sales dashboard.'),
|
||||
'Quantity' => t('Quantity'),
|
||||
@@ -438,7 +522,6 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
'Salesperson' => t('Salesperson'),
|
||||
'Contacts' => t('Contacts'),
|
||||
'positions' => t('positions'),
|
||||
'per month' => t('per month'),
|
||||
'Last invoicing' => t('Last invoicing'),
|
||||
'Close' => t('Close'),
|
||||
'New (30d)' => t('New (30d)'),
|
||||
|
||||
@@ -48,10 +48,22 @@ $cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$allContacts = null;
|
||||
$cacheUsed = false;
|
||||
$serviceMeta = [
|
||||
'source' => 'primary.company_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
];
|
||||
|
||||
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$allContacts = $cached['contacts'] ?? [];
|
||||
$cacheUsed = true;
|
||||
if (is_array($cached['service_meta'] ?? null)) {
|
||||
$serviceMeta = [
|
||||
'source' => (string) ($cached['service_meta']['source'] ?? $serviceMeta['source']),
|
||||
'fallback_used' => (bool) ($cached['service_meta']['fallback_used'] ?? false),
|
||||
'fallback_reason' => (string) ($cached['service_meta']['fallback_reason'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($allContacts === null) {
|
||||
@@ -59,6 +71,7 @@ if ($allContacts === null) {
|
||||
$result = $service->loadContacts($customerNo, $customerName);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
$resultMeta = is_array($result['meta'] ?? null) ? $result['meta'] : [];
|
||||
Router::json([
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
@@ -67,6 +80,9 @@ if ($allContacts === null) {
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
'source' => (string) ($resultMeta['source'] ?? $serviceMeta['source']),
|
||||
'fallback_used' => (bool) ($resultMeta['fallback_used'] ?? false),
|
||||
'fallback_reason' => (string) ($resultMeta['fallback_reason'] ?? ''),
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -74,9 +90,16 @@ if ($allContacts === null) {
|
||||
}
|
||||
|
||||
$allContacts = $result['contacts'] ?? [];
|
||||
$resultMeta = is_array($result['meta'] ?? null) ? $result['meta'] : [];
|
||||
$serviceMeta = [
|
||||
'source' => (string) ($resultMeta['source'] ?? $serviceMeta['source']),
|
||||
'fallback_used' => (bool) ($resultMeta['fallback_used'] ?? false),
|
||||
'fallback_reason' => (string) ($resultMeta['fallback_reason'] ?? ''),
|
||||
];
|
||||
|
||||
$sessionStore->set($cacheKey, [
|
||||
'contacts' => $allContacts,
|
||||
'service_meta' => $serviceMeta,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
@@ -168,5 +191,8 @@ Router::json([
|
||||
'cache_used' => $cacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
'cache_refreshed' => $refreshRequested,
|
||||
'source' => $serviceMeta['source'],
|
||||
'fallback_used' => $serviceMeta['fallback_used'],
|
||||
'fallback_reason' => $serviceMeta['fallback_reason'],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -100,7 +100,6 @@ Router::json([
|
||||
'ok' => true,
|
||||
'kpis' => $dashboard['kpis'],
|
||||
'trend' => $dashboard['trend'],
|
||||
'efficiency' => $dashboard['efficiency'],
|
||||
'risk_indicators' => $dashboard['risk_indicators'],
|
||||
'meta' => array_merge($dashboard['meta'], [
|
||||
'cache_used' => $ticketsCacheUsed,
|
||||
|
||||
@@ -211,7 +211,7 @@ Router::json([
|
||||
'contracts' => $contractsPayload['entries'],
|
||||
'contracts_summary' => $contractsPayload['summary'],
|
||||
'contracts_meta' => [
|
||||
'available' => (bool) ($contractsPayload['ok'] ?? false),
|
||||
'available' => $contractsPayload['ok'],
|
||||
'source_cache_used' => $contractsSourceCacheUsed,
|
||||
'cache_used' => $contractsSourceCacheUsed,
|
||||
'cache_bypassed' => $refreshRequested,
|
||||
|
||||
@@ -131,7 +131,7 @@ class BcODataGatewayTest extends TestCase
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 7],
|
||||
]);
|
||||
$sessionStore->method('get')->with('module.helpdesk.debitor.v1.escalation-definitions.7')->willReturn([
|
||||
$sessionStore->expects($this->any())->method('get')->with('module.helpdesk.debitor.v1.escalation-definitions.7')->willReturn([
|
||||
'values' => [
|
||||
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
],
|
||||
@@ -238,6 +238,34 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetContactsForCustomerAllowsSpecialCharactersInCompanyName(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
try {
|
||||
$results = $gateway->getContactsForCustomer('10636', 'Behindertenwerkstätten Oberpfalz – Betreuungs GmbH');
|
||||
$this->assertIsArray($results);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->fail('Trusted customer literals should not be rejected for contact loads.');
|
||||
} catch (\RuntimeException) {
|
||||
// Expected: cURL fails against fake URL.
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetTicketsForCustomerAllowsSpecialCharactersInCompanyName(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
try {
|
||||
$results = $gateway->getTicketsForCustomer('10494', "J.G. Knopf´s Sohn Gmbh & Co. KG");
|
||||
$this->assertIsArray($results);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->fail('Trusted customer literals should not be rejected for ticket loads.');
|
||||
} catch (\RuntimeException) {
|
||||
// Expected: cURL fails against fake URL.
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testListCustomersThrowsWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
|
||||
@@ -190,19 +190,28 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$contacts = [['No' => 'KT001', 'Name' => 'Max Mustermann']];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomer')->willReturn($contacts);
|
||||
$gateway->method('getContactsForCustomerWithMeta')->willReturn([
|
||||
'contacts' => $contacts,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($contacts, $result['contacts']);
|
||||
$this->assertSame('primary.company_name', $result['meta']['source']);
|
||||
$this->assertFalse($result['meta']['fallback_used']);
|
||||
}
|
||||
|
||||
public function testLoadContactsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomer')->willThrowException(new \RuntimeException('API error'));
|
||||
$gateway->method('getContactsForCustomerWithMeta')->willThrowException(new \RuntimeException('API error'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||
@@ -211,6 +220,29 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('API error', $result['error']);
|
||||
}
|
||||
|
||||
public function testLoadContactsExposesFallbackMeta(): void
|
||||
{
|
||||
$contacts = [['No' => 'KT001', 'Name' => 'Fallback Contact']];
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomerWithMeta')->willReturn([
|
||||
'contacts' => $contacts,
|
||||
'meta' => [
|
||||
'source' => 'fallback.integration_customer_no',
|
||||
'fallback_used' => true,
|
||||
'fallback_reason' => 'primary_query_failed',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Müller & Söhne GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($contacts, $result['contacts']);
|
||||
$this->assertSame('fallback.integration_customer_no', $result['meta']['source']);
|
||||
$this->assertTrue($result['meta']['fallback_used']);
|
||||
$this->assertSame('primary_query_failed', $result['meta']['fallback_reason']);
|
||||
}
|
||||
|
||||
// --- loadTickets() ---
|
||||
|
||||
public function testLoadTicketsReturnsFalseForEmptyCustomerNo(): void
|
||||
@@ -239,19 +271,28 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$tickets = [['No' => 'T001', 'Description' => 'Test ticket', 'Ticket_State' => 'Open']];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn($tickets);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($tickets, $result['tickets']);
|
||||
$this->assertSame('primary.company_contact_name', $result['meta']['source']);
|
||||
$this->assertFalse($result['meta']['fallback_used']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||
@@ -260,6 +301,29 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsExposesFallbackMeta(): void
|
||||
{
|
||||
$tickets = [['No' => 'T001', 'Description' => '', 'Ticket_State' => 'Open']];
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'fallback.customer_no_lv',
|
||||
'fallback_used' => true,
|
||||
'fallback_reason' => 'primary_empty',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Müller & Söhne GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($tickets, $result['tickets']);
|
||||
$this->assertSame('fallback.customer_no_lv', $result['meta']['source']);
|
||||
$this->assertTrue($result['meta']['fallback_used']);
|
||||
$this->assertSame('primary_empty', $result['meta']['fallback_reason']);
|
||||
}
|
||||
|
||||
// --- loadContracts() ---
|
||||
|
||||
public function testLoadContractsReturnsFalseForEmptyCustomerNo(): void
|
||||
@@ -367,7 +431,14 @@ class DebitorDetailServiceTest extends TestCase
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn($tickets);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -381,7 +452,14 @@ class DebitorDetailServiceTest extends TestCase
|
||||
public function testLoadTicketSummaryHandlesEmptyTicketList(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn([]);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => [],
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -400,7 +478,14 @@ class DebitorDetailServiceTest extends TestCase
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn($tickets);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -413,7 +498,7 @@ class DebitorDetailServiceTest extends TestCase
|
||||
public function testLoadTicketSummaryReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -427,7 +512,7 @@ class DebitorDetailServiceTest extends TestCase
|
||||
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadSupportDashboard('10001', 'Test GmbH');
|
||||
@@ -1006,7 +1091,10 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertArrayHasKey('rule_id', $indicator);
|
||||
$this->assertArrayHasKey('triggered', $indicator);
|
||||
$this->assertArrayHasKey('value', $indicator);
|
||||
$this->assertArrayHasKey('threshold', $indicator);
|
||||
$this->assertArrayHasKey('raw_value', $indicator);
|
||||
$this->assertArrayHasKey('threshold_value', $indicator);
|
||||
$this->assertArrayHasKey('unit', $indicator);
|
||||
$this->assertArrayHasKey('description', $indicator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,21 +1123,4 @@ class DebitorDetailServiceTest extends TestCase
|
||||
}
|
||||
|
||||
// --- Contract metrics ---
|
||||
|
||||
// --- Efficiency ---
|
||||
|
||||
public function testBuildControllingDashboardEfficiencyMetrics(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 48, 'NKS'),
|
||||
self::openTicket('T002', 24, ''),
|
||||
self::closedTicket('T003', 72, 20),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(20.0, $result['efficiency']['resolution_hours']);
|
||||
$this->assertSame(1, $result['efficiency']['unassigned_count']);
|
||||
$this->assertIsInt($result['efficiency']['oldest_open_hours']);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -139,52 +139,56 @@ function init(container) {
|
||||
return debitorCommunicationPromise;
|
||||
}
|
||||
|
||||
function hydrateTicketCategoryFilter(config, categories) {
|
||||
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
|
||||
if (!(categoryFilter instanceof HTMLSelectElement)) return;
|
||||
/**
|
||||
* Hydrate a dynamic select filter with values from the API response.
|
||||
* Works for category, support user, and contact filters.
|
||||
*/
|
||||
function hydrateSelectFilter(config, selectorId, filterKey, values) {
|
||||
const selectEl = document.querySelector(selectorId);
|
||||
if (!(selectEl instanceof HTMLSelectElement)) return;
|
||||
|
||||
const uniqueCategories = Array.from(new Set(
|
||||
(Array.isArray(categories) ? categories : [])
|
||||
const uniqueValues = Array.from(new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map(item => String(item || '').trim())
|
||||
.filter(Boolean),
|
||||
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
const currentValue = String(categoryFilter.value || '');
|
||||
categoryFilter.innerHTML = '';
|
||||
const currentValue = String(selectEl.value || '');
|
||||
selectEl.innerHTML = '';
|
||||
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = '';
|
||||
allOption.textContent = t('All');
|
||||
categoryFilter.appendChild(allOption);
|
||||
selectEl.appendChild(allOption);
|
||||
|
||||
const categoryOptionMap = {};
|
||||
for (const category of uniqueCategories) {
|
||||
const optionMap = {};
|
||||
for (const value of uniqueValues) {
|
||||
const option = document.createElement('option');
|
||||
option.value = category;
|
||||
option.textContent = category;
|
||||
categoryFilter.appendChild(option);
|
||||
categoryOptionMap[category] = category;
|
||||
option.value = value;
|
||||
option.textContent = value;
|
||||
selectEl.appendChild(option);
|
||||
optionMap[value] = value;
|
||||
}
|
||||
|
||||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) {
|
||||
categoryFilter.value = currentValue;
|
||||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(optionMap, currentValue)) {
|
||||
selectEl.value = currentValue;
|
||||
} else {
|
||||
categoryFilter.value = '';
|
||||
selectEl.value = '';
|
||||
}
|
||||
|
||||
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
|
||||
config.filterChipMeta = {};
|
||||
}
|
||||
|
||||
const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object')
|
||||
? config.filterChipMeta.category
|
||||
const currentMeta = (config.filterChipMeta[filterKey] && typeof config.filterChipMeta[filterKey] === 'object')
|
||||
? config.filterChipMeta[filterKey]
|
||||
: {};
|
||||
|
||||
config.filterChipMeta.category = {
|
||||
...currentCategoryMeta,
|
||||
config.filterChipMeta[filterKey] = {
|
||||
...currentMeta,
|
||||
type: 'select',
|
||||
default: '',
|
||||
options: categoryOptionMap,
|
||||
options: optionMap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,6 +360,14 @@ function init(container) {
|
||||
});
|
||||
}
|
||||
|
||||
const severityMap = {
|
||||
escalation_overdue: { cls: 'critical', icon: '🔴' },
|
||||
high_risk_aging: { cls: 'high', icon: '🟠' },
|
||||
unassigned_open: { cls: 'medium', icon: '🟡' },
|
||||
stale_open: { cls: 'low', icon: '🔵' },
|
||||
customer_backlog: { cls: 'low', icon: '🔵' },
|
||||
};
|
||||
|
||||
function renderSystemRecommendations(recommendations) {
|
||||
const entries = Array.isArray(recommendations) ? recommendations : [];
|
||||
if (entries.length === 0) {
|
||||
@@ -381,119 +393,106 @@ function init(container) {
|
||||
return messageKey || String(entry.rule_id || '—');
|
||||
};
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
||||
html += `<th>${esc(t('Action'))}</th>`;
|
||||
html += `<th>${esc(t('Age (h)'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
let html = '<div class="helpdesk-rec-list">';
|
||||
for (const entry of entries) {
|
||||
const ticketNo = String(entry.ticket_no || '');
|
||||
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
||||
const ageHours = Number.isFinite(Number(entry.age_hours)) ? String(entry.age_hours) : '—';
|
||||
const rowAttrs = href ? ` class="app-clickable-row" data-href="${esc(href)}" tabindex="0"` : '';
|
||||
html += `<tr${rowAttrs}>`;
|
||||
html += ticketNo && href
|
||||
? `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`
|
||||
: `<td>${esc(ticketNo || '—')}</td>`;
|
||||
html += `<td>${esc(formatRecommendationMessage(entry))}</td>`;
|
||||
html += `<td>${esc(ageHours)}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
const ruleId = String(entry.rule_id || '');
|
||||
const severity = severityMap[ruleId] || { cls: 'low', icon: '🔵' };
|
||||
const ageHours = Number.isFinite(Number(entry.age_hours)) ? Number(entry.age_hours) : null;
|
||||
const ageLabel = ageHours !== null ? fmtHours(ageHours) : '';
|
||||
const message = formatRecommendationMessage(entry);
|
||||
|
||||
html += '</tbody></table>';
|
||||
const tag = href ? 'a' : 'div';
|
||||
const hrefAttr = href ? ` href="${esc(href)}"` : '';
|
||||
html += `<${tag}${hrefAttr} class="helpdesk-rec-card helpdesk-rec-${severity.cls}" tabindex="0">`;
|
||||
html += `<div class="helpdesk-rec-card-header">`;
|
||||
html += `<span class="helpdesk-rec-card-ticket">${esc(ticketNo || '—')}</span>`;
|
||||
if (ageLabel) html += `<span class="helpdesk-rec-card-age">${esc(ageLabel)}</span>`;
|
||||
html += `</div>`;
|
||||
html += `<div class="helpdesk-rec-card-message">${esc(message)}</div>`;
|
||||
html += `</${tag}>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSupportContracts(entries) {
|
||||
const contracts = Array.isArray(entries) ? entries : [];
|
||||
function renderSupportContractKpis(contracts) {
|
||||
const entries = Array.isArray(contracts) ? contracts : [];
|
||||
const wrapper = document.getElementById('support-contract-kpis-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
if (contracts.length === 0) {
|
||||
return renderEmptyState({
|
||||
message: t('No contracts found for this customer.'),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
if (entries.length === 0) {
|
||||
wrapper.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const formatDate = (value) => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const amountFormatter = new Intl.NumberFormat(undefined, {
|
||||
const amountFmt = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Contract No.'))}</th>`;
|
||||
html += `<th>${esc(t('Product type'))}</th>`;
|
||||
html += `<th>${esc(t('State'))}</th>`;
|
||||
html += `<th>${esc(t('Next invoicing'))}</th>`;
|
||||
html += `<th>${esc(t('Amount'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
const activeCount = entries.filter(c => String(c.state || '').toLowerCase() !== 'ended').length;
|
||||
const totalVolume = entries.reduce((sum, c) => sum + Number(c.total_payoff_amount ?? 0), 0);
|
||||
const totalSupportHours = entries.reduce((sum, c) => sum + Number(c.support_hours ?? 0), 0);
|
||||
|
||||
for (const contract of contracts.slice(0, 20)) {
|
||||
const amount = Number(contract.total_payoff_amount ?? 0);
|
||||
html += '<tr>';
|
||||
html += `<td>${esc(contract.contract_no || '')}</td>`;
|
||||
html += `<td>${esc(contract.product_type || '—')}</td>`;
|
||||
html += `<td>${esc(contract.state || '—')}</td>`;
|
||||
html += `<td>${esc(formatDate(contract.next_invoicing_date))}</td>`;
|
||||
html += `<td>${esc(amountFormatter.format(Number.isFinite(amount) ? amount : 0))}</td>`;
|
||||
html += '</tr>';
|
||||
// Find nearest next invoicing
|
||||
const now = Date.now();
|
||||
let nearestInvoicing = null;
|
||||
for (const c of entries) {
|
||||
const raw = String(c.next_invoicing_date || '').trim();
|
||||
if (!raw || raw.startsWith('0001-01-01')) continue;
|
||||
const date = new Date(raw);
|
||||
if (!Number.isNaN(date.getTime()) && date.getTime() > now) {
|
||||
if (!nearestInvoicing || date < nearestInvoicing) nearestInvoicing = date;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
setText('support-kpi-active-contracts', activeCount);
|
||||
const totalContracts = entries.length;
|
||||
if (totalContracts > activeCount) {
|
||||
setHtml('support-kpi-active-contracts-trend',
|
||||
`<span class="helpdesk-kpi-trend-neutral">${esc(t('of {total} total').replace('{total}', totalContracts))}</span>`);
|
||||
}
|
||||
|
||||
setText('support-kpi-monthly-volume', amountFmt.format(totalVolume));
|
||||
|
||||
if (totalSupportHours > 0) {
|
||||
setText('support-kpi-support-hours',
|
||||
(Number.isInteger(totalSupportHours) ? totalSupportHours : totalSupportHours.toFixed(1)) + 'h');
|
||||
} else {
|
||||
setText('support-kpi-support-hours', '—');
|
||||
}
|
||||
|
||||
if (nearestInvoicing) {
|
||||
setText('support-kpi-next-invoicing', nearestInvoicing.toLocaleDateString());
|
||||
const daysUntil = Math.ceil((nearestInvoicing.getTime() - now) / 86400000);
|
||||
if (daysUntil >= 0) {
|
||||
const cls = daysUntil <= 7 ? 'helpdesk-kpi-trend-negative'
|
||||
: daysUntil <= 30 ? 'helpdesk-kpi-trend-neutral'
|
||||
: 'helpdesk-kpi-trend-positive';
|
||||
setHtml('support-kpi-next-invoicing-trend',
|
||||
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
||||
}
|
||||
} else {
|
||||
setText('support-kpi-next-invoicing', '—');
|
||||
}
|
||||
|
||||
wrapper.hidden = false;
|
||||
}
|
||||
|
||||
function contractTypeLabel(productType) {
|
||||
return String(productType || '').trim() || '—';
|
||||
}
|
||||
|
||||
const ACCENT_COUNT = 8;
|
||||
const accentTypeMap = new Map();
|
||||
|
||||
function contractAccentClass(productType) {
|
||||
const key = String(productType || '').trim().toUpperCase();
|
||||
if (key === '') return 'accent-0';
|
||||
if (!accentTypeMap.has(key)) {
|
||||
accentTypeMap.set(key, accentTypeMap.size % ACCENT_COUNT);
|
||||
}
|
||||
return 'accent-' + accentTypeMap.get(key);
|
||||
}
|
||||
|
||||
function contractIcon(productType) {
|
||||
const type = String(productType || '').trim().toLowerCase();
|
||||
if (type === 'wa') return 'bi-tools';
|
||||
if (type === 'fb') return 'bi-cash-stack';
|
||||
if (type === 'lz') return 'bi-key';
|
||||
if (type === 'ho') return 'bi-cloud';
|
||||
if (type === 'su') return 'bi-headset';
|
||||
if (type === 'sw') return 'bi-code-square';
|
||||
return 'bi-file-earmark-text';
|
||||
}
|
||||
|
||||
// Shared formatters for contract rendering
|
||||
// Shared formatter for contract rendering (dialog, timeline)
|
||||
const contractAmountFmt = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const contractAmountFmtShort = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
function contractFormatDate(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
||||
@@ -502,13 +501,6 @@ function init(container) {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function contractStateClass(state) {
|
||||
const s = String(state || '').toLowerCase();
|
||||
if (s === 'aktiv') return 'state-active';
|
||||
if (s === 'vorbereitung') return 'state-prep';
|
||||
return 'state-ended';
|
||||
}
|
||||
|
||||
function contractStateVariant(state) {
|
||||
const s = String(state || '').toLowerCase();
|
||||
if (s === 'aktiv') return 'success';
|
||||
@@ -519,55 +511,7 @@ function init(container) {
|
||||
/** Store all contracts for dialog access */
|
||||
let salesContracts = [];
|
||||
|
||||
function renderContractCards(contracts) {
|
||||
if (!Array.isArray(contracts) || contracts.length === 0) {
|
||||
return renderEmptyState({
|
||||
message: t('No contract details available.'),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
}
|
||||
|
||||
salesContracts = contracts;
|
||||
|
||||
let html = '<div class="helpdesk-contract-cards">';
|
||||
for (let i = 0; i < contracts.length; i++) {
|
||||
const contract = contracts[i];
|
||||
const accent = contractAccentClass(contract.product_type);
|
||||
const stateClass = contractStateClass(contract.state);
|
||||
const monthlyAmt = Number(contract.monthly_amount ?? 0);
|
||||
const lines = Array.isArray(contract.lines) ? contract.lines : [];
|
||||
const typeLabel = contractTypeLabel(contract.product_type);
|
||||
|
||||
html += `<button type="button" class="helpdesk-contract-card ${accent} ${stateClass}" data-contract-index="${i}">`;
|
||||
|
||||
// Status badge — top-right
|
||||
html += `<span class="helpdesk-contract-card-status">`;
|
||||
html += `<span class="helpdesk-contract-card-dot"></span>${esc(contract.state || '—')}`;
|
||||
html += `</span>`;
|
||||
|
||||
// Type label (uppercase, prominent)
|
||||
html += `<span class="helpdesk-contract-card-type">${esc(typeLabel)}</span>`;
|
||||
|
||||
// Amount hero — or last invoicing date for inactive contracts
|
||||
if (monthlyAmt > 0) {
|
||||
html += `<span class="helpdesk-contract-card-amount">${esc(contractAmountFmtShort.format(monthlyAmt))}</span>`;
|
||||
html += `<span class="helpdesk-contract-card-period">${esc(t('per month'))}</span>`;
|
||||
} else if (stateClass === 'state-ended' && contract.next_invoicing_date) {
|
||||
const dateStr = contractFormatDate(contract.next_invoicing_date);
|
||||
if (dateStr !== '—') {
|
||||
html += `<span class="helpdesk-contract-card-amount helpdesk-contract-card-amount-muted">${esc(dateStr)}</span>`;
|
||||
html += `<span class="helpdesk-contract-card-period">${esc(t('Last invoicing'))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderContractLinesTable(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
@@ -674,110 +618,52 @@ function init(container) {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// Delegate click on contract cards
|
||||
// Delegate click on contract cards and timeline rows
|
||||
container.addEventListener('click', (e) => {
|
||||
const card = e.target.closest('.helpdesk-contract-card[data-contract-index]');
|
||||
if (!card) return;
|
||||
const index = Number(card.dataset.contractIndex);
|
||||
const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]');
|
||||
if (!target) return;
|
||||
const index = Number(target.dataset.contractIndex);
|
||||
if (salesContracts[index]) {
|
||||
openContractDialog(salesContracts[index]);
|
||||
}
|
||||
});
|
||||
|
||||
// KPI tile click → scroll to ticket list and set status filter
|
||||
// KPI tile click → scroll to ticket list and apply filter or sort
|
||||
container.addEventListener('click', (e) => {
|
||||
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter]');
|
||||
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]');
|
||||
if (!kpi) return;
|
||||
const filterValue = kpi.dataset.kpiFilter || '';
|
||||
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
|
||||
if (statusSelect instanceof HTMLSelectElement) {
|
||||
statusSelect.value = filterValue;
|
||||
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
// Apply status filter if present
|
||||
if (kpi.dataset.kpiFilter !== undefined) {
|
||||
const filterValue = kpi.dataset.kpiFilter || '';
|
||||
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
|
||||
if (statusSelect instanceof HTMLSelectElement) {
|
||||
statusSelect.value = filterValue;
|
||||
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sort if present (format: "column:dir")
|
||||
if (kpi.dataset.kpiSort) {
|
||||
const [col, dir] = kpi.dataset.kpiSort.split(':');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('order', col);
|
||||
url.searchParams.set('dir', dir || 'asc');
|
||||
// Also set open filter for "avg age" to show only open tickets
|
||||
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
|
||||
if (statusSelect instanceof HTMLSelectElement) {
|
||||
statusSelect.value = 'open';
|
||||
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
const ticketSection = document.querySelector('.helpdesk-support-ticket-list');
|
||||
if (ticketSection) {
|
||||
ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
function renderEscalatedTickets(entries, meta) {
|
||||
const escalatedEntries = Array.isArray(entries) ? entries : [];
|
||||
const escalationMeta = (meta && typeof meta === 'object') ? meta : {};
|
||||
|
||||
if (escalationMeta.available === false) {
|
||||
return errorNotice(t('Could not load escalated tickets.'));
|
||||
}
|
||||
|
||||
if (escalatedEntries.length === 0) {
|
||||
return renderEmptyState({
|
||||
message: t('No escalated tickets right now.'),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
}
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatDuration = (secondsValue) => {
|
||||
const totalSeconds = Number(secondsValue);
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const seconds = Math.floor(totalSeconds);
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
const dayLabel = days === 1 ? t('day') : t('days');
|
||||
return hours > 0 ? `${days} ${dayLabel} ${hours} ${t('hours')}` : `${days} ${dayLabel}`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours} ${t('hours')}`;
|
||||
}
|
||||
|
||||
return `${Math.max(1, minutes)} min`;
|
||||
};
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
||||
html += `<th>${esc(t('Escalation code'))}</th>`;
|
||||
html += `<th>${esc(t('SLA target'))}</th>`;
|
||||
html += `<th>${esc(t('Last activity'))}</th>`;
|
||||
html += `<th>${esc(t('Overdue by'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const entry of escalatedEntries.slice(0, 25)) {
|
||||
const ticketNo = String(entry.ticket_no || '');
|
||||
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
||||
html += `<tr class="app-clickable-row" data-href="${esc(href)}" tabindex="0">`;
|
||||
html += `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`;
|
||||
html += `<td>${esc(String(entry.escalation_code || '—'))}</td>`;
|
||||
html += `<td>${esc(formatDuration(entry.target_seconds))}</td>`;
|
||||
html += `<td>${esc(formatDateTime(entry.last_activity_iso))}</td>`;
|
||||
html += `<td>${esc(formatDuration(entry.overdue_seconds))}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function updateBadge(id, count) {
|
||||
const badge = document.getElementById(id);
|
||||
if (badge) {
|
||||
badge.textContent = String(count);
|
||||
}
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -804,7 +690,6 @@ function init(container) {
|
||||
function showLoading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.setAttribute('aria-busy', 'true');
|
||||
el.hidden = false;
|
||||
}
|
||||
}
|
||||
@@ -812,7 +697,6 @@ function init(container) {
|
||||
function hideLoading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.removeAttribute('aria-busy');
|
||||
el.hidden = true;
|
||||
}
|
||||
}
|
||||
@@ -844,6 +728,16 @@ function init(container) {
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Timeline row keyboard activation
|
||||
const tlRow = e.target.closest('.helpdesk-tl-row-clickable[data-contract-index]');
|
||||
if (tlRow) {
|
||||
e.preventDefault();
|
||||
const index = Number(tlRow.dataset.contractIndex);
|
||||
if (salesContracts[index]) {
|
||||
openContractDialog(salesContracts[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -875,8 +769,10 @@ function init(container) {
|
||||
}
|
||||
|
||||
try {
|
||||
const categoryResponse = await fetchTicketCategories(config, appBase);
|
||||
hydrateTicketCategoryFilter(config, categoryResponse?.categories || []);
|
||||
const filterOptionsResponse = await fetchTicketCategories(config, appBase);
|
||||
hydrateSelectFilter(config, '#helpdesk-ticket-category-filter', 'category', filterOptionsResponse?.categories || []);
|
||||
hydrateSelectFilter(config, '#helpdesk-ticket-support-filter', 'support', filterOptionsResponse?.support_users || []);
|
||||
hydrateSelectFilter(config, '#helpdesk-ticket-contact-filter', 'contact', filterOptionsResponse?.contacts || []);
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
@@ -953,8 +849,6 @@ function init(container) {
|
||||
showContent('support-content');
|
||||
|
||||
const recommendationsEl = document.getElementById('support-system-recommendations');
|
||||
const contractsEl = document.getElementById('support-contracts-widget');
|
||||
const escalationEl = document.getElementById('support-escalation-widget');
|
||||
if (!result?.ok) {
|
||||
setText('support-kpi-open-tickets', '—');
|
||||
setText('support-kpi-created-30d', '—');
|
||||
@@ -963,12 +857,6 @@ function init(container) {
|
||||
if (recommendationsEl) {
|
||||
recommendationsEl.innerHTML = errorNotice(t('Could not load tickets.'));
|
||||
}
|
||||
if (contractsEl) {
|
||||
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
|
||||
}
|
||||
if (escalationEl) {
|
||||
escalationEl.innerHTML = errorNotice(t('Could not load escalated tickets.'));
|
||||
}
|
||||
supportTabRendered = true;
|
||||
return;
|
||||
}
|
||||
@@ -1037,54 +925,21 @@ function init(container) {
|
||||
ageHintEl.innerHTML = `<span class="helpdesk-kpi-trend-negative">${criticalTickets} ${t('critical')}</span>`;
|
||||
}
|
||||
|
||||
// Recommendations widget
|
||||
// Contract KPIs (second row)
|
||||
const contracts = Array.isArray(result.contracts) ? result.contracts : [];
|
||||
renderSupportContractKpis(contracts);
|
||||
|
||||
// Recommendations + escalations merged — only show section when there are entries
|
||||
const recommendationsWrapper = document.getElementById('support-recommendations-wrapper');
|
||||
if (recommendationsEl) {
|
||||
const recommendations = Array.isArray(result.recommendations)
|
||||
? result.recommendations
|
||||
: (Array.isArray(result.actions) ? result.actions : []);
|
||||
if (recommendations.length > 0) {
|
||||
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
|
||||
} else {
|
||||
recommendationsEl.innerHTML = renderEmptyState({
|
||||
message: t('No system recommendations right now.'),
|
||||
size: 'compact',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Contracts widget
|
||||
if (contractsEl) {
|
||||
const contracts = Array.isArray(result.contracts) ? result.contracts : [];
|
||||
const contractsMeta = (result.contracts_meta && typeof result.contracts_meta === 'object')
|
||||
? result.contracts_meta
|
||||
: {};
|
||||
|
||||
if (contracts.length === 0 && contractsMeta.available === false) {
|
||||
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
|
||||
} else if (contracts.length > 0) {
|
||||
contractsEl.innerHTML = renderSupportContracts(contracts);
|
||||
} else {
|
||||
contractsEl.innerHTML = renderEmptyState({
|
||||
message: t('No contracts found for this customer.'),
|
||||
size: 'compact',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Escalation widget
|
||||
if (escalationEl) {
|
||||
const escalationEntries = Array.isArray(result.escalation_entries) ? result.escalation_entries : [];
|
||||
const escalationMeta = (result.escalation_meta && typeof result.escalation_meta === 'object')
|
||||
? result.escalation_meta
|
||||
: {};
|
||||
|
||||
if (escalationEntries.length > 0 || escalationMeta.available === false) {
|
||||
escalationEl.innerHTML = renderEscalatedTickets(escalationEntries, escalationMeta);
|
||||
} else {
|
||||
escalationEl.innerHTML = renderEmptyState({
|
||||
message: t('No escalated tickets right now.'),
|
||||
size: 'compact',
|
||||
});
|
||||
if (recommendationsWrapper) recommendationsWrapper.hidden = false;
|
||||
} else if (recommendationsWrapper) {
|
||||
recommendationsWrapper.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1188,12 +1043,8 @@ function init(container) {
|
||||
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
||||
}
|
||||
|
||||
// Render contracts
|
||||
const detailEl = document.getElementById('sales-contracts-detail');
|
||||
if (detailEl) {
|
||||
detailEl.innerHTML = `<h3 class="helpdesk-support-section-title">${esc(t('Contract overview'))}</h3>`
|
||||
+ renderContractCards(result.contracts || []);
|
||||
}
|
||||
// Contract timeline (swimlane view — click on bar opens contract dialog)
|
||||
renderContractTimeline(result.contracts || []);
|
||||
|
||||
// Init contacts grid (server-side, same pattern as tickets)
|
||||
initContactsGrid();
|
||||
@@ -1201,6 +1052,144 @@ function init(container) {
|
||||
salesTabRendered = true;
|
||||
}
|
||||
|
||||
function renderContractTimeline(contracts) {
|
||||
const chartEl = document.getElementById('sales-trend-chart');
|
||||
if (!chartEl || !contracts.length) return;
|
||||
|
||||
// Store contracts for dialog access
|
||||
salesContracts = contracts;
|
||||
|
||||
const amountFmt = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const nowDate = new Date();
|
||||
|
||||
// Parse dates and compute timeline range
|
||||
const items = [];
|
||||
let globalMin = Infinity;
|
||||
let globalMax = -Infinity;
|
||||
|
||||
for (let i = 0; i < contracts.length; i++) {
|
||||
const c = contracts[i];
|
||||
const startRaw = String(c.starting_date || '').trim();
|
||||
if (!startRaw || startRaw.startsWith('0001')) continue;
|
||||
|
||||
const startTs = new Date(startRaw).getTime();
|
||||
if (Number.isNaN(startTs)) continue;
|
||||
|
||||
const stateLower = String(c.state || '').toLowerCase();
|
||||
const isActive = stateLower === 'aktiv';
|
||||
|
||||
let endTs;
|
||||
if (isActive) {
|
||||
endTs = nowDate.getTime();
|
||||
} else {
|
||||
const endRaw = String(c.next_invoicing_date || '').trim();
|
||||
endTs = (endRaw && !endRaw.startsWith('0001')) ? new Date(endRaw).getTime() : startTs;
|
||||
if (Number.isNaN(endTs)) endTs = startTs;
|
||||
}
|
||||
|
||||
if (startTs < globalMin) globalMin = startTs;
|
||||
if (endTs > globalMax) globalMax = endTs;
|
||||
if (isActive && nowDate.getTime() > globalMax) globalMax = nowDate.getTime();
|
||||
|
||||
items.push({
|
||||
contract_index: i,
|
||||
contract_no: String(c.contract_no || ''),
|
||||
product_type: String(c.product_type || '').trim(),
|
||||
monthly_amount: Number(c.monthly_amount ?? 0),
|
||||
is_active: isActive,
|
||||
start_ts: startTs,
|
||||
end_ts: endTs,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
// Sort: active first, then by start date
|
||||
items.sort((a, b) => {
|
||||
if (a.is_active !== b.is_active) return a.is_active ? -1 : 1;
|
||||
return a.start_ts - b.start_ts;
|
||||
});
|
||||
|
||||
// Extend range to full years so all year ticks are always visible
|
||||
const firstYear = new Date(globalMin).getFullYear();
|
||||
const lastYear = new Date(globalMax).getFullYear();
|
||||
const yearStartMin = new Date(firstYear, 0, 1).getTime();
|
||||
const yearStartMax = new Date(lastYear, 0, 1).getTime();
|
||||
const rangeMin = Math.min(globalMin, yearStartMin);
|
||||
const rangeMax = Math.max(globalMax, yearStartMax);
|
||||
const range = rangeMax - rangeMin || 1;
|
||||
const padding = range * 0.02;
|
||||
const timeMin = rangeMin - padding;
|
||||
const timeMax = rangeMax + padding;
|
||||
const timeRange = timeMax - timeMin;
|
||||
|
||||
// Build year tick marks + vertical guide lines
|
||||
const minYear = new Date(globalMin).getFullYear();
|
||||
const maxYear = new Date(globalMax).getFullYear();
|
||||
let ticksHtml = '';
|
||||
let guidesHtml = '';
|
||||
for (let y = minYear; y <= maxYear; y++) {
|
||||
const yearTs = new Date(y, 0, 1).getTime();
|
||||
const pct = ((yearTs - timeMin) / timeRange) * 100;
|
||||
if (pct >= 0 && pct <= 100) {
|
||||
ticksHtml += `<div class="helpdesk-tl-tick" style="left:${pct.toFixed(2)}%">
|
||||
<span class="helpdesk-tl-tick-label">${y}</span>
|
||||
</div>`;
|
||||
guidesHtml += `<div class="helpdesk-tl-guide" style="left:${pct.toFixed(2)}%"></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build swimlane rows
|
||||
let rowsHtml = '';
|
||||
for (const item of items) {
|
||||
const leftPct = ((item.start_ts - timeMin) / timeRange) * 100;
|
||||
const widthPct = ((item.end_ts - item.start_ts) / timeRange) * 100;
|
||||
const barCls = item.is_active ? 'helpdesk-tl-bar-active' : 'helpdesk-tl-bar-ended';
|
||||
const typeLabel = item.product_type ? item.product_type.toUpperCase() : '';
|
||||
const amtLabel = item.monthly_amount > 0 ? amountFmt.format(item.monthly_amount) : '';
|
||||
|
||||
const startDate = new Date(item.start_ts).toLocaleDateString();
|
||||
const endLabel = item.is_active ? t('active') : new Date(item.end_ts).toLocaleDateString();
|
||||
const tooltip = `${item.contract_no} · ${startDate} – ${endLabel}` + (amtLabel ? ` · ${amtLabel}/M` : '');
|
||||
|
||||
rowsHtml += `<div class="helpdesk-tl-row helpdesk-tl-row-clickable" data-contract-index="${item.contract_index}" tabindex="0">
|
||||
<div class="helpdesk-tl-label">
|
||||
<span class="helpdesk-tl-type">${esc(typeLabel || item.contract_no)}</span>
|
||||
<span class="helpdesk-tl-amount">${esc(amtLabel ? amtLabel + '/M' : '')}</span>
|
||||
</div>
|
||||
<div class="helpdesk-tl-track">
|
||||
<div class="helpdesk-tl-bar ${barCls}" style="left:${leftPct.toFixed(2)}%;width:${Math.max(widthPct, 0.5).toFixed(2)}%"
|
||||
data-tooltip="${esc(tooltip)}" data-tooltip-pos="top">${item.is_active ? '<span class="helpdesk-tl-bar-arrow"></span>' : ''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
chartEl.innerHTML = `
|
||||
<div class="helpdesk-tl">
|
||||
<div class="helpdesk-tl-body">
|
||||
<div class="helpdesk-tl-guides">${guidesHtml}</div>
|
||||
<div class="helpdesk-tl-rows">${rowsHtml}</div>
|
||||
</div>
|
||||
<div class="helpdesk-tl-axis">${ticksHtml}</div>
|
||||
</div>
|
||||
<div class="helpdesk-tl-legend">
|
||||
<span class="helpdesk-tl-legend-item">
|
||||
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-active"></span>
|
||||
${esc(t('active'))}
|
||||
</span>
|
||||
<span class="helpdesk-tl-legend-item">
|
||||
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-ended"></span>
|
||||
${esc(t('ended'))}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderDebitorCommunicationAside() {
|
||||
const feedEl = document.getElementById('debitor-communication-feed');
|
||||
const loadingEl = document.getElementById('debitor-communication-loading');
|
||||
@@ -1358,8 +1347,7 @@ function init(container) {
|
||||
// Trend chart
|
||||
renderTrendChart(result.trend || []);
|
||||
|
||||
// Efficiency
|
||||
renderEfficiencyWidget(result.efficiency || {});
|
||||
|
||||
|
||||
// Risk indicators
|
||||
renderControllingRiskIndicators(result.risk_indicators || [], result.risk_summary || {});
|
||||
@@ -1371,6 +1359,7 @@ function init(container) {
|
||||
const chartEl = document.getElementById('controlling-trend-chart');
|
||||
if (!chartEl || !trend.length) return;
|
||||
|
||||
// Find max value for Y-axis scaling
|
||||
let max = 0;
|
||||
for (const bucket of trend) {
|
||||
if (bucket.created > max) max = bucket.created;
|
||||
@@ -1378,74 +1367,102 @@ function init(container) {
|
||||
}
|
||||
if (max === 0) max = 1;
|
||||
|
||||
let rowsHtml = '';
|
||||
// Compute nice grid lines (2-3 lines)
|
||||
const gridStep = max <= 5 ? 1 : max <= 15 ? 5 : max <= 50 ? 10 : Math.ceil(max / 4 / 10) * 10;
|
||||
let gridHtml = '';
|
||||
for (let v = gridStep; v <= max; v += gridStep) {
|
||||
const pct = ((v / max) * 100).toFixed(1);
|
||||
gridHtml += `<div class="helpdesk-vchart-grid-line" style="bottom:${pct}%">
|
||||
<span class="helpdesk-vchart-grid-label">${v}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Bar groups
|
||||
let barsHtml = '';
|
||||
for (const bucket of trend) {
|
||||
const createdPct = Math.round((bucket.created / max) * 100);
|
||||
const closedPct = Math.round((bucket.closed / max) * 100);
|
||||
rowsHtml += `<div class="helpdesk-hbar-row">
|
||||
<span class="helpdesk-hbar-label">${esc(bucket.label)}</span>
|
||||
<div class="helpdesk-hbar-track">
|
||||
<div class="helpdesk-hbar helpdesk-hbar-created" style="width:${createdPct}%">
|
||||
<span class="helpdesk-hbar-value">${bucket.created}</span>
|
||||
</div>
|
||||
<div class="helpdesk-hbar helpdesk-hbar-closed" style="width:${closedPct}%">
|
||||
<span class="helpdesk-hbar-value">${bucket.closed}</span>
|
||||
</div>
|
||||
const createdPct = ((bucket.created / max) * 100).toFixed(1);
|
||||
const closedPct = ((bucket.closed / max) * 100).toFixed(1);
|
||||
barsHtml += `<div class="helpdesk-vchart-group">
|
||||
<div class="helpdesk-vchart-bars">
|
||||
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-created" style="height:${createdPct}%" data-tooltip="${bucket.created}" data-tooltip-pos="top"></div>
|
||||
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-closed" style="height:${closedPct}%" data-tooltip="${bucket.closed}" data-tooltip-pos="top"></div>
|
||||
</div>
|
||||
<span class="helpdesk-vchart-label">${esc(bucket.label)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
chartEl.innerHTML = `
|
||||
<div class="helpdesk-hbar-chart">${rowsHtml}</div>
|
||||
<div class="helpdesk-hbar-legend">
|
||||
<span class="helpdesk-hbar-legend-item">
|
||||
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-created"></span>
|
||||
<div class="helpdesk-vchart-area">
|
||||
${gridHtml}
|
||||
<div class="helpdesk-vchart-groups">${barsHtml}</div>
|
||||
</div>
|
||||
<div class="helpdesk-vchart-legend">
|
||||
<span class="helpdesk-vchart-legend-item">
|
||||
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-created"></span>
|
||||
${esc(t('Created'))}
|
||||
</span>
|
||||
<span class="helpdesk-hbar-legend-item">
|
||||
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-closed"></span>
|
||||
<span class="helpdesk-vchart-legend-item">
|
||||
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-closed"></span>
|
||||
${esc(t('Closed'))}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEfficiencyWidget(efficiency) {
|
||||
const el = document.getElementById('controlling-efficiency');
|
||||
if (!el) return;
|
||||
|
||||
const rows = [
|
||||
[t('Avg. resolution'), efficiency.resolution_hours != null ? fmtHours(efficiency.resolution_hours) : '—'],
|
||||
[t('Oldest open'), efficiency.oldest_open_hours != null ? fmtHours(efficiency.oldest_open_hours) : '—'],
|
||||
[t('Without assignee'), String(efficiency.unassigned_count ?? 0)],
|
||||
];
|
||||
|
||||
el.innerHTML = '<table class="helpdesk-controlling-kv-table">' +
|
||||
rows.map(([label, value]) => `<tr><td>${esc(label)}</td><td>${esc(value)}</td></tr>`).join('') +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function renderControllingRiskIndicators(indicators, summary) {
|
||||
const el = document.getElementById('controlling-risk-indicators');
|
||||
if (!el || !indicators.length) return;
|
||||
|
||||
const level = summary.level || 'low';
|
||||
const levelLabel = t(level.charAt(0).toUpperCase() + level.slice(1));
|
||||
const levelCls = level === 'high' ? 'helpdesk-kpi-trend-negative'
|
||||
: level === 'medium' ? 'helpdesk-kpi-trend-neutral'
|
||||
: 'helpdesk-kpi-trend-positive';
|
||||
// Count triggered from actual indicator data (single source of truth)
|
||||
const total = indicators.length;
|
||||
const triggered = indicators.filter(ind => !!ind.triggered).length;
|
||||
const summaryLabel = triggered === 0
|
||||
? t('All checks passed')
|
||||
: triggered + ' ' + t('of') + ' ' + total + ' ' + t('checks flagged');
|
||||
const summaryCls = triggered === 0 ? 'helpdesk-risk-summary-ok'
|
||||
: triggered <= 2 ? 'helpdesk-risk-summary-warn'
|
||||
: 'helpdesk-risk-summary-critical';
|
||||
|
||||
let html = `<div class="helpdesk-controlling-risk-summary">
|
||||
<span class="${levelCls}">${esc(levelLabel)}</span>
|
||||
<span class="helpdesk-controlling-risk-summary-count">${summary.triggered ?? 0}/${summary.total ?? 0} ${esc(t('flags'))}</span>
|
||||
let html = `<div class="helpdesk-risk-header">
|
||||
<span class="helpdesk-risk-header-icon ${summaryCls}">${triggered === 0 ? '✓' : '⚠'}</span>
|
||||
<span class="helpdesk-risk-header-label">${esc(summaryLabel)}</span>
|
||||
</div>`;
|
||||
html += '<div class="helpdesk-controlling-risk-list">';
|
||||
html += '<div class="helpdesk-risk-checks">';
|
||||
for (const ind of indicators) {
|
||||
const dotCls = ind.triggered ? 'helpdesk-controlling-risk-dot-triggered' : 'helpdesk-controlling-risk-dot-ok';
|
||||
html += `<div class="helpdesk-controlling-risk-row">
|
||||
<span class="helpdesk-controlling-risk-dot ${dotCls}"></span>
|
||||
<span class="helpdesk-controlling-risk-label">${esc(t(ind.label) || ind.label)}</span>
|
||||
<span class="helpdesk-controlling-risk-value">${esc(ind.value)} / ${esc(ind.threshold)}</span>
|
||||
const icon = ind.triggered ? '⚠' : '✓';
|
||||
const iconCls = ind.triggered ? 'helpdesk-risk-icon-warn' : 'helpdesk-risk-icon-ok';
|
||||
const barCls = ind.triggered ? 'helpdesk-risk-bar-over' : 'helpdesk-risk-bar-ok';
|
||||
|
||||
// Threshold bar: normalised to whichever is larger (value or threshold)
|
||||
// so the bar always fits inside the track and the limit marker stays visible.
|
||||
const rawVal = ind.raw_value ?? 0;
|
||||
const thresholdVal = ind.threshold_value ?? 1;
|
||||
const scale = Math.max(rawVal, thresholdVal, 1); // prevent division by 0
|
||||
const fillPct = Math.min(100, (rawVal / scale) * 100);
|
||||
const limitPct = Math.min(100, (thresholdVal / scale) * 100);
|
||||
|
||||
// Format display value with fmtHours for hour-based units
|
||||
const displayValue = ind.unit === 'h' && typeof rawVal === 'number' && rawVal > 0
|
||||
? fmtHours(rawVal)
|
||||
: esc(ind.value);
|
||||
const limitLabel = ind.unit === 'h' ? t('Limit') + ' ' + fmtHours(thresholdVal) : t('Limit') + ' ' + thresholdVal + ind.unit;
|
||||
|
||||
const descText = ind.description ? t(ind.description) : '';
|
||||
|
||||
html += `<div class="helpdesk-risk-check">
|
||||
<div class="helpdesk-risk-check-header">
|
||||
<span class="helpdesk-risk-icon ${iconCls}">${icon}</span>
|
||||
<span class="helpdesk-risk-check-label">${esc(t(ind.label) || ind.label)}</span>
|
||||
</div>
|
||||
<div class="helpdesk-risk-check-value">${displayValue}</div>
|
||||
${descText ? `<div class="helpdesk-risk-check-desc">${esc(descText)}</div>` : ''}
|
||||
<div class="helpdesk-risk-bar-wrapper">
|
||||
<div class="helpdesk-risk-bar-track">
|
||||
<div class="helpdesk-risk-bar-fill ${barCls}" style="width:${fillPct}%"></div>
|
||||
</div>
|
||||
<div class="helpdesk-risk-bar-limit" style="left:${limitPct}%"></div>
|
||||
</div>
|
||||
<div class="helpdesk-risk-bar-legend">${esc(limitLabel)}</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
Reference in New Issue
Block a user