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:
@@ -19,8 +19,8 @@ final class RouteRegistrar
|
|||||||
{
|
{
|
||||||
$coreRoutePaths = [];
|
$coreRoutePaths = [];
|
||||||
foreach ($catalog->coreRoutes() as $route) {
|
foreach ($catalog->coreRoutes() as $route) {
|
||||||
$path = trim((string) ($route['path'] ?? ''));
|
$path = trim($route['path']);
|
||||||
$target = trim((string) ($route['target'] ?? ''));
|
$target = trim($route['target']);
|
||||||
if ($path === '' || $target === '') {
|
if ($path === '' || $target === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -31,9 +31,9 @@ final class RouteRegistrar
|
|||||||
|
|
||||||
$modulePaths = [];
|
$modulePaths = [];
|
||||||
foreach ($this->moduleRegistry->getRoutes() as $route) {
|
foreach ($this->moduleRegistry->getRoutes() as $route) {
|
||||||
$path = trim((string) ($route['path'] ?? ''));
|
$path = trim($route['path']);
|
||||||
$target = trim((string) ($route['target'] ?? ''));
|
$target = trim($route['target']);
|
||||||
$moduleId = trim((string) ($route['module_id'] ?? ''));
|
$moduleId = trim($route['module_id']);
|
||||||
if ($path === '' || $target === '') {
|
if ($path === '' || $target === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class BcODataGateway
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($query);
|
$escaped = $this->escapeODataStrictUserInput($query);
|
||||||
$upperQuery = mb_strtoupper($escaped);
|
$upperQuery = mb_strtoupper($escaped);
|
||||||
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
|
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
|
||||||
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
||||||
@@ -108,13 +108,13 @@ class BcODataGateway
|
|||||||
|
|
||||||
$cityClause = '';
|
$cityClause = '';
|
||||||
if ($city !== '') {
|
if ($city !== '') {
|
||||||
$escapedCity = $this->escapeODataString($city);
|
$escapedCity = $this->escapeODataStrictUserInput($city);
|
||||||
$cityClause = "startswith(City,'" . $escapedCity . "')";
|
$cityClause = "startswith(City,'" . $escapedCity . "')";
|
||||||
}
|
}
|
||||||
|
|
||||||
$searchStrategies = [''];
|
$searchStrategies = [''];
|
||||||
if ($search !== '') {
|
if ($search !== '') {
|
||||||
$escapedSearch = $this->escapeODataString($search);
|
$escapedSearch = $this->escapeODataStrictUserInput($search);
|
||||||
$upperSearch = mb_strtoupper($escapedSearch);
|
$upperSearch = mb_strtoupper($escapedSearch);
|
||||||
$searchStrategies = [
|
$searchStrategies = [
|
||||||
"contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')",
|
"contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')",
|
||||||
@@ -180,7 +180,7 @@ class BcODataGateway
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($customerNo);
|
$escaped = $this->escapeODataStrictUserInput($customerNo);
|
||||||
$filter = "No eq '" . $escaped . "'";
|
$filter = "No eq '" . $escaped . "'";
|
||||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$filter=' . rawurlencode($filter)
|
||||||
@@ -199,20 +199,205 @@ class BcODataGateway
|
|||||||
/**
|
/**
|
||||||
* Get contacts for a customer by customer name.
|
* 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>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public function getContactsForCustomer(string $customerNo, string $customerName = ''): array
|
public function getContactsForCustomer(string $customerNo, string $customerName = ''): array
|
||||||
{
|
{
|
||||||
|
$result = $this->getContactsForCustomerWithMeta($customerNo, $customerName);
|
||||||
|
|
||||||
|
return $result['contacts'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contacts for a customer with fallback metadata.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* contacts: array<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);
|
$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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($customerName);
|
$escaped = $this->escapeODataTrustedLiteral($value);
|
||||||
$filter = "Company_Name eq '" . $escaped . "'";
|
$filter = $field . " eq '" . $escaped . "'";
|
||||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$filter=' . rawurlencode($filter)
|
||||||
. '&$top=100'
|
. '&$top=100'
|
||||||
@@ -223,25 +408,23 @@ class BcODataGateway
|
|||||||
return [];
|
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
|
* 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>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array
|
private function fetchTicketsByCompanyContactName(string $customerName): array
|
||||||
{
|
{
|
||||||
$customerName = trim($customerName);
|
$customerName = trim($customerName);
|
||||||
if ($customerName === '') {
|
if ($customerName === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($customerName);
|
$escaped = $this->escapeODataTrustedLiteral($customerName);
|
||||||
$filter = "Company_Contact_Name eq '" . $escaped . "'";
|
$filter = "Company_Contact_Name eq '" . $escaped . "'";
|
||||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$filter=' . rawurlencode($filter)
|
||||||
@@ -254,7 +437,59 @@ class BcODataGateway
|
|||||||
return [];
|
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)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_ESCALATION)
|
||||||
. '?$top=200'
|
. '?$top=50'
|
||||||
. '&$select=' . rawurlencode('Code,Description,Target_Time,No_of_Escalation_Level')
|
. '&$select=' . rawurlencode('Code,Description,Target_Time,No_of_Escalation_Level')
|
||||||
. '&$orderby=' . rawurlencode('Code asc');
|
. '&$orderby=' . rawurlencode('Code asc');
|
||||||
|
|
||||||
@@ -305,11 +540,11 @@ class BcODataGateway
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($customerNo);
|
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||||
$filter = "Customer_No eq '" . $escaped . "'";
|
$filter = "Customer_No eq '" . $escaped . "'";
|
||||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$filter=' . rawurlencode($filter)
|
||||||
. '&$top=250'
|
. '&$top=200'
|
||||||
. '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date')
|
. '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date')
|
||||||
. '&$orderby=' . rawurlencode('Last_Activity_Date desc');
|
. '&$orderby=' . rawurlencode('Last_Activity_Date desc');
|
||||||
|
|
||||||
@@ -336,11 +571,11 @@ class BcODataGateway
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($customerNo);
|
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||||
$filter = "Customer_No eq '" . $escaped . "'";
|
$filter = "Customer_No eq '" . $escaped . "'";
|
||||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$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')
|
. '&$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');
|
. '&$orderby=' . rawurlencode('Created_On desc');
|
||||||
|
|
||||||
@@ -418,7 +653,7 @@ class BcODataGateway
|
|||||||
*/
|
*/
|
||||||
private function fetchContractsByCustomerField(string $customerNo, string $customerField, string $select): array
|
private function fetchContractsByCustomerField(string $customerNo, string $customerField, string $select): array
|
||||||
{
|
{
|
||||||
$escaped = $this->escapeODataString($customerNo);
|
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||||
$filter = $customerField . " eq '" . $escaped . "'";
|
$filter = $customerField . " eq '" . $escaped . "'";
|
||||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACTS)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACTS)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$filter=' . rawurlencode($filter)
|
||||||
@@ -446,12 +681,12 @@ class BcODataGateway
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($customerNo);
|
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||||
$filter = "Customer_No eq '" . $escaped . "'";
|
$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';
|
$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)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$filter=' . rawurlencode($filter)
|
||||||
. '&$top=500'
|
. '&$top=300'
|
||||||
. '&$select=' . rawurlencode($select)
|
. '&$select=' . rawurlencode($select)
|
||||||
. '&$orderby=' . rawurlencode('Header_No asc,Line_No asc');
|
. '&$orderby=' . rawurlencode('Header_No asc,Line_No asc');
|
||||||
|
|
||||||
@@ -475,7 +710,7 @@ class BcODataGateway
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($ticketNo);
|
$escaped = $this->escapeODataStrictUserInput($ticketNo);
|
||||||
$filter = "No eq '" . $escaped . "'";
|
$filter = "No eq '" . $escaped . "'";
|
||||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||||
. '?$filter=' . rawurlencode($filter)
|
. '?$filter=' . rawurlencode($filter)
|
||||||
@@ -509,7 +744,7 @@ class BcODataGateway
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escaped = $this->escapeODataString($ticketNo);
|
$escaped = $this->escapeODataStrictUserInput($ticketNo);
|
||||||
$filter = "Support_Ticket_No eq '" . $escaped . "'";
|
$filter = "Support_Ticket_No eq '" . $escaped . "'";
|
||||||
|
|
||||||
// Fetch detailed log (user, time, Record_ID)
|
// Fetch detailed log (user, time, Record_ID)
|
||||||
@@ -529,21 +764,31 @@ class BcODataGateway
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch status subtypes (human-readable state names)
|
// Only fetch status subtypes if detail entries contain status-type rows
|
||||||
$lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV)
|
$hasStatusEntries = false;
|
||||||
. '?$filter=' . rawurlencode($filter)
|
foreach ($entries as $entry) {
|
||||||
. '&$orderby=' . rawurlencode('Entry_No desc')
|
if (($entry['Type'] ?? '') === 'Status') {
|
||||||
. '&$top=100'
|
$hasStatusEntries = true;
|
||||||
. '&$select=Entry_No,State_Subtype';
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$lvResponse = $this->request('GET', $lvUrl);
|
|
||||||
$subtypeMap = [];
|
$subtypeMap = [];
|
||||||
if ($lvResponse !== null) {
|
if ($hasStatusEntries) {
|
||||||
foreach ($this->extractODataValues($lvResponse) as $lv) {
|
$lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV)
|
||||||
$entryNo = $lv['Entry_No'] ?? null;
|
. '?$filter=' . rawurlencode($filter)
|
||||||
$subtype = (string) ($lv['State_Subtype'] ?? '');
|
. '&$orderby=' . rawurlencode('Entry_No desc')
|
||||||
if ($entryNo !== null && $subtype !== '' && $subtype !== '0') {
|
. '&$top=100'
|
||||||
$subtypeMap[$entryNo] = $subtype;
|
. '&$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'];
|
return ['error' => 'Not configured or empty customer number'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$escapedCustomerNo = $this->escapeODataString($customerNo);
|
$escapedCustomerNo = $this->escapeODataStrictUserInput($customerNo);
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
// 1. Customer entity
|
// 1. Customer entity
|
||||||
@@ -656,7 +901,7 @@ class BcODataGateway
|
|||||||
// 2. Contact entity — filter by Company_Name
|
// 2. Contact entity — filter by Company_Name
|
||||||
$contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT);
|
$contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT);
|
||||||
if ($customerName !== '') {
|
if ($customerName !== '') {
|
||||||
$escapedCustomerName = $this->escapeODataString((string) $customerName);
|
$escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName);
|
||||||
$contactFilteredUrl = $contactBaseUrl
|
$contactFilteredUrl = $contactBaseUrl
|
||||||
. '?$filter=' . rawurlencode("Company_Name eq '" . $escapedCustomerName . "'")
|
. '?$filter=' . rawurlencode("Company_Name eq '" . $escapedCustomerName . "'")
|
||||||
. '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title');
|
. '&$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
|
// 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name
|
||||||
$ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS);
|
$ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS);
|
||||||
if ($customerName !== '') {
|
if ($customerName !== '') {
|
||||||
$escapedCustomerName = $this->escapeODataString((string) $customerName);
|
$escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName);
|
||||||
$ticketsFilteredUrl = $ticketsBaseUrl
|
$ticketsFilteredUrl = $ticketsBaseUrl
|
||||||
. '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $escapedCustomerName . "'")
|
. '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $escapedCustomerName . "'")
|
||||||
. '&$top=10&$orderby=' . rawurlencode('Created_On desc')
|
. '&$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.
|
* @param array<int, array<string, mixed>> $rows
|
||||||
*
|
* @return array<int, array<string, mixed>>
|
||||||
* Rejects characters that could manipulate OData filter logic (parentheses,
|
*/
|
||||||
* boolean operators). Only allows letters, digits, spaces, dots, hyphens,
|
private function dedupeRowsByNo(array $rows): array
|
||||||
* underscores, umlauts and common punctuation.
|
{
|
||||||
|
$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.
|
* @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) {
|
if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
|
||||||
throw new \InvalidArgumentException('Search query contains invalid characters.');
|
throw new \InvalidArgumentException('Search query contains invalid characters.');
|
||||||
@@ -904,6 +1169,14 @@ class BcODataGateway
|
|||||||
return str_replace("'", "''", $value);
|
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
|
private function resolveCurrentTenantId(): int
|
||||||
{
|
{
|
||||||
$session = $this->sessionStore->all();
|
$session = $this->sessionStore->all();
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ class BcSoapGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
$parsed = $this->parseGetTicketCommunicationTextResponse($rawBody);
|
$parsed = $this->parseGetTicketCommunicationTextResponse($rawBody);
|
||||||
if (!($parsed['ok'] ?? false)) {
|
if (!$parsed['ok']) {
|
||||||
return [
|
return [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'soap_used' => false,
|
'soap_used' => false,
|
||||||
'return_value' => 0,
|
'return_value' => 0,
|
||||||
'communication_text' => '',
|
'communication_text' => '',
|
||||||
'message_entries' => '',
|
'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).
|
* 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
|
public function loadContacts(string $customerNo, string $customerName): array
|
||||||
{
|
{
|
||||||
@@ -72,18 +81,38 @@ class DebitorDetailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
|
$result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['ok' => false, 'error' => $e->getMessage()];
|
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).
|
* 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
|
public function loadTickets(string $customerNo, string $customerName): array
|
||||||
{
|
{
|
||||||
@@ -99,12 +128,23 @@ class DebitorDetailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
|
$result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['ok' => false, 'error' => $e->getMessage()];
|
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'] : [];
|
$tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : [];
|
||||||
$escalation = $this->loadEscalationHealth($customerNo, $tickets);
|
$escalation = $this->loadEscalationHealth($customerNo, $tickets);
|
||||||
if (!($escalation['ok'] ?? false)) {
|
if (!$escalation['ok']) {
|
||||||
$escalation = null;
|
$escalation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +317,7 @@ class DebitorDetailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$targetSeconds = $targetSecondsByCode[$escalationCode] ?? null;
|
$targetSeconds = $targetSecondsByCode[$escalationCode] ?? null;
|
||||||
if (!is_int($targetSeconds) || $targetSeconds <= 0) {
|
if (!is_int($targetSeconds)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,12 +356,12 @@ class DebitorDetailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
usort($entries, static function (array $a, array $b): int {
|
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) {
|
if ($cmp !== 0) {
|
||||||
return $cmp;
|
return $cmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
return strcmp((string) ($a['ticket_no'] ?? ''), (string) ($b['ticket_no'] ?? ''));
|
return strcmp($a['ticket_no'], $b['ticket_no']);
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -1362,11 +1402,6 @@ class DebitorDetailService
|
|||||||
'backlog_critical' => $criticalOpen,
|
'backlog_critical' => $criticalOpen,
|
||||||
],
|
],
|
||||||
'trend' => array_values($buckets),
|
'trend' => array_values($buckets),
|
||||||
'efficiency' => [
|
|
||||||
'resolution_hours' => $resolutionMedian,
|
|
||||||
'unassigned_count' => $unassignedOpen,
|
|
||||||
'oldest_open_hours' => $oldestOpenAgeHours,
|
|
||||||
],
|
|
||||||
'risk_indicators' => $riskIndicators,
|
'risk_indicators' => $riskIndicators,
|
||||||
'risk_summary' => [
|
'risk_summary' => [
|
||||||
'triggered' => $triggeredCount,
|
'triggered' => $triggeredCount,
|
||||||
@@ -1457,7 +1492,7 @@ class DebitorDetailService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<string, mixed>> $rules
|
* @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(
|
private static function evaluateControllingRisks(
|
||||||
array $rules,
|
array $rules,
|
||||||
@@ -1478,10 +1513,13 @@ class DebitorDetailService
|
|||||||
: ($createdInPeriod > 0 ? 100.0 : 0.0);
|
: ($createdInPeriod > 0 ? 100.0 : 0.0);
|
||||||
$indicators[] = [
|
$indicators[] = [
|
||||||
'rule_id' => 'ticket_volume_increase',
|
'rule_id' => 'ticket_volume_increase',
|
||||||
'label' => 'Ticket volume increase',
|
'label' => 'Ticket volume',
|
||||||
'triggered' => $increase > $threshold,
|
'description' => 'risk_desc_ticket_volume',
|
||||||
|
'triggered' => $increase >= $threshold,
|
||||||
'value' => ($increase > 0 ? '+' : '') . $increase . '%',
|
'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'] : [];
|
$rule = is_array($rules['avg_resolution_high'] ?? null) ? $rules['avg_resolution_high'] : [];
|
||||||
if (($rule['enabled'] ?? true) === true) {
|
if (($rule['enabled'] ?? true) === true) {
|
||||||
$threshold = (int) ($rule['threshold_hours'] ?? 72);
|
$threshold = (int) ($rule['threshold_hours'] ?? 72);
|
||||||
$triggered = $avgResolution !== null && $avgResolution > $threshold;
|
$triggered = $avgResolution !== null && $avgResolution >= $threshold;
|
||||||
|
$rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0;
|
||||||
$indicators[] = [
|
$indicators[] = [
|
||||||
'rule_id' => 'avg_resolution_high',
|
'rule_id' => 'avg_resolution_high',
|
||||||
'label' => 'High avg. resolution time',
|
'label' => 'Resolution time',
|
||||||
|
'description' => 'risk_desc_resolution_time',
|
||||||
'triggered' => $triggered,
|
'triggered' => $triggered,
|
||||||
'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—',
|
'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);
|
$threshold = (int) ($rule['threshold_hours'] ?? 168);
|
||||||
$indicators[] = [
|
$indicators[] = [
|
||||||
'rule_id' => 'open_aging',
|
'rule_id' => 'open_aging',
|
||||||
'label' => 'Open ticket aging',
|
'label' => 'Oldest open ticket',
|
||||||
'triggered' => $oldestOpenAgeHours > $threshold,
|
'description' => 'risk_desc_open_aging',
|
||||||
|
'triggered' => $oldestOpenAgeHours >= $threshold,
|
||||||
'value' => $oldestOpenAgeHours . 'h',
|
'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);
|
$threshold = (int) ($rule['threshold_percent'] ?? 20);
|
||||||
$indicators[] = [
|
$indicators[] = [
|
||||||
'rule_id' => 'unassigned_ratio',
|
'rule_id' => 'unassigned_ratio',
|
||||||
'label' => 'Unassigned ticket ratio',
|
'label' => 'Unassigned',
|
||||||
'triggered' => $unassignedRate > $threshold,
|
'description' => 'risk_desc_unassigned',
|
||||||
|
'triggered' => $unassignedRate >= $threshold,
|
||||||
'value' => $unassignedRate . '%',
|
'value' => $unassignedRate . '%',
|
||||||
'threshold' => $threshold . '%',
|
'raw_value' => $unassignedRate,
|
||||||
|
'threshold_value' => $threshold,
|
||||||
|
'unit' => '%',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ class HelpdeskSettingsGateway
|
|||||||
{
|
{
|
||||||
$normalized = self::normalizeSystemRecommendationsConfig($config);
|
$normalized = self::normalizeSystemRecommendationsConfig($config);
|
||||||
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
if (!is_string($encoded) || $encoded === '') {
|
if (!is_string($encoded)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +617,7 @@ class HelpdeskSettingsGateway
|
|||||||
{
|
{
|
||||||
$normalized = self::normalizeControllingRiskConfig($config);
|
$normalized = self::normalizeControllingRiskConfig($config);
|
||||||
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
if (!is_string($encoded) || $encoded === '') {
|
if (!is_string($encoded)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class SystemRecommendationEngine
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$escalationCode = strtoupper(trim((string) ($match['escalation_code'] ?? '')));
|
$escalationCode = strtoupper(trim($match['escalation_code']));
|
||||||
if ($escalationCode === '' || !isset($codeMap[$escalationCode])) {
|
if ($escalationCode === '' || !isset($codeMap[$escalationCode])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,48 +93,93 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
|
|
||||||
<!-- Support panel -->
|
<!-- Support panel -->
|
||||||
<div data-tab-panel="support">
|
<div data-tab-panel="support">
|
||||||
<div id="support-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
<div id="support-loading">
|
||||||
<div id="support-content" hidden>
|
|
||||||
<div class="helpdesk-support-metrics">
|
<div class="helpdesk-support-metrics">
|
||||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="open">
|
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
|
<div class="helpdesk-skeleton-kpi">
|
||||||
<p class="helpdesk-support-metric-value" id="support-kpi-open-tickets">—</p>
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||||
<p class="helpdesk-support-metric-trend" id="support-kpi-open-trend"></p>
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||||
</article>
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
|
||||||
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="">
|
</div>
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('New (30d)')); ?></p>
|
<?php endfor; ?>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="helpdesk-skeleton-divider"></div>
|
||||||
<h3 class="helpdesk-support-section-title"><?php e(t('Analysis')); ?></h3>
|
<div class="helpdesk-support-metrics">
|
||||||
<div class="grid grid-2" id="support-analysis-grid">
|
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||||
<div class="app-stats-table" id="support-recommendations-wrapper">
|
<div class="helpdesk-skeleton-kpi">
|
||||||
<div class="app-stats-table-header"><?php e(t('System recommendations')); ?></div>
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||||
<div id="support-system-recommendations"></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>
|
||||||
|
<?php endfor; ?>
|
||||||
<div class="app-stats-table" id="support-contracts-wrapper">
|
</div>
|
||||||
<div class="app-stats-table-header"><?php e(t('Contracts & products')); ?></div>
|
<div id="support-content" hidden>
|
||||||
<div id="support-contracts-widget"></div>
|
<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>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="app-stats-table" id="support-escalation-wrapper">
|
<section id="support-contract-kpis-wrapper" hidden>
|
||||||
<div class="app-stats-table-header"><?php e(t('Escalated tickets')); ?></div>
|
<h3 class="helpdesk-support-section-title"><?php e(t('Contracts & products')); ?></h3>
|
||||||
<div id="support-escalation-widget"></div>
|
<div class="helpdesk-support-metrics" id="support-contract-kpis">
|
||||||
</div>
|
<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">
|
<section class="helpdesk-support-ticket-list">
|
||||||
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket list')); ?></h3>
|
<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 -->
|
<!-- Sales panel -->
|
||||||
<div data-tab-panel="sales" hidden>
|
<div data-tab-panel="sales" hidden>
|
||||||
<div id="sales-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
|
<div id="sales-loading">
|
||||||
<div id="sales-content" hidden>
|
|
||||||
<div class="helpdesk-support-metrics">
|
<div class="helpdesk-support-metrics">
|
||||||
<article class="helpdesk-support-metric">
|
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
|
<div class="helpdesk-skeleton-kpi">
|
||||||
<p class="helpdesk-support-metric-value" id="sales-kpi-monthly-volume">0</p>
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
||||||
<p class="helpdesk-support-metric-trend" id="sales-kpi-monthly-volume-trend"></p>
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
||||||
</article>
|
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
|
||||||
<article class="helpdesk-support-metric">
|
</div>
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
|
<?php endfor; ?>
|
||||||
<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>
|
</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">
|
<section id="sales-contacts-section">
|
||||||
<h3 class="helpdesk-support-section-title"><?php e(t('Contacts')); ?></h3>
|
<h3 class="helpdesk-support-section-title"><?php e(t('Contacts')); ?></h3>
|
||||||
@@ -193,8 +262,26 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
|
|
||||||
<!-- Controlling panel -->
|
<!-- Controlling panel -->
|
||||||
<div data-tab-panel="controlling" hidden>
|
<div data-tab-panel="controlling" hidden>
|
||||||
<div id="controlling-spinner" class="helpdesk-tab-spinner">
|
<div id="controlling-spinner">
|
||||||
<div class="spinner"></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>
|
||||||
|
<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>
|
||||||
<div id="controlling-content" hidden>
|
<div id="controlling-content" hidden>
|
||||||
<div class="helpdesk-segment-control" id="controlling-period-selector">
|
<div class="helpdesk-segment-control" id="controlling-period-selector">
|
||||||
@@ -237,12 +324,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="app-stats-table">
|
<div class="app-stats-table">
|
||||||
<div class="app-stats-table-header"><?php e(t('Support efficiency')); ?></div>
|
<div class="app-stats-table-header"><?php e(t('Risk assessment')); ?></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 id="controlling-risk-indicators"></div>
|
<div id="controlling-risk-indicators"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,7 +343,12 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
<p><?php e(t('Last 5 tickets')); ?></p>
|
<p><?php e(t('Last 5 tickets')); ?></p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
<hr>
|
<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-content" hidden>
|
||||||
<div id="debitor-communication-feed"></div>
|
<div id="debitor-communication-feed"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,9 +427,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
'Critical (>48h)' => t('Critical (>48h)'),
|
'Critical (>48h)' => t('Critical (>48h)'),
|
||||||
'Oldest open' => t('Oldest open'),
|
'Oldest open' => t('Oldest open'),
|
||||||
'Ticket list' => t('Ticket list'),
|
'Ticket list' => t('Ticket list'),
|
||||||
'System recommendations' => t('System recommendations'),
|
'Attention needed' => t('Attention needed'),
|
||||||
'Action' => t('Action'),
|
|
||||||
'Age (h)' => t('Age (h)'),
|
|
||||||
'No system recommendations right now.' => t('No system recommendations right now.'),
|
'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.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.'),
|
'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.'),
|
'No contracts found for this customer.' => t('No contracts found for this customer.'),
|
||||||
'Contract No.' => t('Contract No.'),
|
'Contract No.' => t('Contract No.'),
|
||||||
'Product type' => t('Product type'),
|
'Product type' => t('Product type'),
|
||||||
'Next invoicing' => t('Next invoicing'),
|
|
||||||
'Amount' => t('Amount'),
|
'Amount' => t('Amount'),
|
||||||
'Active contracts' => t('Active contracts'),
|
'Active contracts' => t('Active contracts'),
|
||||||
'Total contracts' => t('Total contracts'),
|
'Total contracts' => t('Total contracts'),
|
||||||
'Could not load contracts.' => t('Could not load 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'),
|
'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'),
|
'Ticket volume' => t('Ticket volume'),
|
||||||
'Close rate' => t('Close rate'),
|
'Close rate' => t('Close rate'),
|
||||||
'Avg. resolution' => t('Avg. resolution'),
|
'Avg. resolution' => t('Avg. resolution'),
|
||||||
@@ -376,18 +451,23 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
'Backlog growing' => t('Backlog growing'),
|
'Backlog growing' => t('Backlog growing'),
|
||||||
'unassigned' => t('unassigned'),
|
'unassigned' => t('unassigned'),
|
||||||
'Ticket trend' => t('Ticket trend'),
|
'Ticket trend' => t('Ticket trend'),
|
||||||
'Support efficiency' => t('Support efficiency'),
|
'Risk assessment' => t('Risk assessment'),
|
||||||
'Risk indicators' => t('Risk indicators'),
|
|
||||||
'vs. prev. period' => t('vs. prev. period'),
|
'vs. prev. period' => t('vs. prev. period'),
|
||||||
'Low' => t('Low'),
|
'Low' => t('Low'),
|
||||||
'Medium' => t('Medium'),
|
'Medium' => t('Medium'),
|
||||||
'High' => t('High'),
|
'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.'),
|
'No controlling data available.' => t('No controlling data available.'),
|
||||||
'Ticket volume increase' => t('Ticket volume increase'),
|
'Resolution time' => t('Resolution time'),
|
||||||
'High avg. resolution time' => t('High avg. resolution time'),
|
'Oldest open ticket' => t('Oldest open ticket'),
|
||||||
'Open ticket aging' => t('Open ticket aging'),
|
'Unassigned' => t('Unassigned'),
|
||||||
'Unassigned ticket ratio' => t('Unassigned ticket ratio'),
|
'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.'),
|
'Could not load controlling dashboard.' => t('Could not load controlling dashboard.'),
|
||||||
'Without assignee' => t('Without assignee'),
|
'Without assignee' => t('Without assignee'),
|
||||||
'Created' => t('Created'),
|
'Created' => t('Created'),
|
||||||
@@ -426,7 +506,11 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
'Monthly volume' => t('Monthly volume'),
|
'Monthly volume' => t('Monthly volume'),
|
||||||
'Support hours' => t('Support hours'),
|
'Support hours' => t('Support hours'),
|
||||||
'Next invoicing' => t('Next invoicing'),
|
'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.'),
|
'No contract details available.' => t('No contract details available.'),
|
||||||
'Could not load sales dashboard.' => t('Could not load sales dashboard.'),
|
'Could not load sales dashboard.' => t('Could not load sales dashboard.'),
|
||||||
'Quantity' => t('Quantity'),
|
'Quantity' => t('Quantity'),
|
||||||
@@ -438,7 +522,6 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
'Salesperson' => t('Salesperson'),
|
'Salesperson' => t('Salesperson'),
|
||||||
'Contacts' => t('Contacts'),
|
'Contacts' => t('Contacts'),
|
||||||
'positions' => t('positions'),
|
'positions' => t('positions'),
|
||||||
'per month' => t('per month'),
|
|
||||||
'Last invoicing' => t('Last invoicing'),
|
'Last invoicing' => t('Last invoicing'),
|
||||||
'Close' => t('Close'),
|
'Close' => t('Close'),
|
||||||
'New (30d)' => t('New (30d)'),
|
'New (30d)' => t('New (30d)'),
|
||||||
|
|||||||
@@ -48,10 +48,22 @@ $cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
|||||||
$cached = $sessionStore->get($cacheKey);
|
$cached = $sessionStore->get($cacheKey);
|
||||||
$allContacts = null;
|
$allContacts = null;
|
||||||
$cacheUsed = false;
|
$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) {
|
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||||
$allContacts = $cached['contacts'] ?? [];
|
$allContacts = $cached['contacts'] ?? [];
|
||||||
$cacheUsed = true;
|
$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) {
|
if ($allContacts === null) {
|
||||||
@@ -59,6 +71,7 @@ if ($allContacts === null) {
|
|||||||
$result = $service->loadContacts($customerNo, $customerName);
|
$result = $service->loadContacts($customerNo, $customerName);
|
||||||
|
|
||||||
if (!($result['ok'] ?? false)) {
|
if (!($result['ok'] ?? false)) {
|
||||||
|
$resultMeta = is_array($result['meta'] ?? null) ? $result['meta'] : [];
|
||||||
Router::json([
|
Router::json([
|
||||||
'data' => [],
|
'data' => [],
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
@@ -67,6 +80,9 @@ if ($allContacts === null) {
|
|||||||
'cache_used' => $cacheUsed,
|
'cache_used' => $cacheUsed,
|
||||||
'cache_bypassed' => $refreshRequested,
|
'cache_bypassed' => $refreshRequested,
|
||||||
'cache_refreshed' => $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'] ?? [];
|
$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, [
|
$sessionStore->set($cacheKey, [
|
||||||
'contacts' => $allContacts,
|
'contacts' => $allContacts,
|
||||||
|
'service_meta' => $serviceMeta,
|
||||||
'fetched_at' => time(),
|
'fetched_at' => time(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -168,5 +191,8 @@ Router::json([
|
|||||||
'cache_used' => $cacheUsed,
|
'cache_used' => $cacheUsed,
|
||||||
'cache_bypassed' => $refreshRequested,
|
'cache_bypassed' => $refreshRequested,
|
||||||
'cache_refreshed' => $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,
|
'ok' => true,
|
||||||
'kpis' => $dashboard['kpis'],
|
'kpis' => $dashboard['kpis'],
|
||||||
'trend' => $dashboard['trend'],
|
'trend' => $dashboard['trend'],
|
||||||
'efficiency' => $dashboard['efficiency'],
|
|
||||||
'risk_indicators' => $dashboard['risk_indicators'],
|
'risk_indicators' => $dashboard['risk_indicators'],
|
||||||
'meta' => array_merge($dashboard['meta'], [
|
'meta' => array_merge($dashboard['meta'], [
|
||||||
'cache_used' => $ticketsCacheUsed,
|
'cache_used' => $ticketsCacheUsed,
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ Router::json([
|
|||||||
'contracts' => $contractsPayload['entries'],
|
'contracts' => $contractsPayload['entries'],
|
||||||
'contracts_summary' => $contractsPayload['summary'],
|
'contracts_summary' => $contractsPayload['summary'],
|
||||||
'contracts_meta' => [
|
'contracts_meta' => [
|
||||||
'available' => (bool) ($contractsPayload['ok'] ?? false),
|
'available' => $contractsPayload['ok'],
|
||||||
'source_cache_used' => $contractsSourceCacheUsed,
|
'source_cache_used' => $contractsSourceCacheUsed,
|
||||||
'cache_used' => $contractsSourceCacheUsed,
|
'cache_used' => $contractsSourceCacheUsed,
|
||||||
'cache_bypassed' => $refreshRequested,
|
'cache_bypassed' => $refreshRequested,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class BcODataGatewayTest extends TestCase
|
|||||||
$sessionStore->method('all')->willReturn([
|
$sessionStore->method('all')->willReturn([
|
||||||
'current_tenant' => ['id' => 7],
|
'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' => [
|
'values' => [
|
||||||
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||||
],
|
],
|
||||||
@@ -238,6 +238,34 @@ class BcODataGatewayTest extends TestCase
|
|||||||
$this->assertSame([], $results);
|
$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
|
public function testListCustomersThrowsWhenNotConfigured(): void
|
||||||
{
|
{
|
||||||
$gateway = $this->createGatewayWithSettings(false);
|
$gateway = $this->createGatewayWithSettings(false);
|
||||||
|
|||||||
@@ -190,19 +190,28 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
$contacts = [['No' => 'KT001', 'Name' => 'Max Mustermann']];
|
$contacts = [['No' => 'KT001', 'Name' => 'Max Mustermann']];
|
||||||
|
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$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);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||||
|
|
||||||
$this->assertTrue($result['ok']);
|
$this->assertTrue($result['ok']);
|
||||||
$this->assertSame($contacts, $result['contacts']);
|
$this->assertSame($contacts, $result['contacts']);
|
||||||
|
$this->assertSame('primary.company_name', $result['meta']['source']);
|
||||||
|
$this->assertFalse($result['meta']['fallback_used']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadContactsReturnsErrorOnException(): void
|
public function testLoadContactsReturnsErrorOnException(): void
|
||||||
{
|
{
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$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);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||||
@@ -211,6 +220,29 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
$this->assertSame('API error', $result['error']);
|
$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() ---
|
// --- loadTickets() ---
|
||||||
|
|
||||||
public function testLoadTicketsReturnsFalseForEmptyCustomerNo(): void
|
public function testLoadTicketsReturnsFalseForEmptyCustomerNo(): void
|
||||||
@@ -239,19 +271,28 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
$tickets = [['No' => 'T001', 'Description' => 'Test ticket', 'Ticket_State' => 'Open']];
|
$tickets = [['No' => 'T001', 'Description' => 'Test ticket', 'Ticket_State' => 'Open']];
|
||||||
|
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$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);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||||
|
|
||||||
$this->assertTrue($result['ok']);
|
$this->assertTrue($result['ok']);
|
||||||
$this->assertSame($tickets, $result['tickets']);
|
$this->assertSame($tickets, $result['tickets']);
|
||||||
|
$this->assertSame('primary.company_contact_name', $result['meta']['source']);
|
||||||
|
$this->assertFalse($result['meta']['fallback_used']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoadTicketsReturnsErrorOnException(): void
|
public function testLoadTicketsReturnsErrorOnException(): void
|
||||||
{
|
{
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$gateway = $this->createMock(BcODataGateway::class);
|
||||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||||
|
|
||||||
$service = $this->createService($gateway);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||||
@@ -260,6 +301,29 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
$this->assertSame('Timeout', $result['error']);
|
$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() ---
|
// --- loadContracts() ---
|
||||||
|
|
||||||
public function testLoadContractsReturnsFalseForEmptyCustomerNo(): void
|
public function testLoadContractsReturnsFalseForEmptyCustomerNo(): void
|
||||||
@@ -367,7 +431,14 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
];
|
];
|
||||||
|
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$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);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||||
@@ -381,7 +452,14 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
public function testLoadTicketSummaryHandlesEmptyTicketList(): void
|
public function testLoadTicketSummaryHandlesEmptyTicketList(): void
|
||||||
{
|
{
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$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);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||||
@@ -400,7 +478,14 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
];
|
];
|
||||||
|
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$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);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||||
@@ -413,7 +498,7 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
public function testLoadTicketSummaryReturnsErrorOnException(): void
|
public function testLoadTicketSummaryReturnsErrorOnException(): void
|
||||||
{
|
{
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$gateway = $this->createMock(BcODataGateway::class);
|
||||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||||
|
|
||||||
$service = $this->createService($gateway);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||||
@@ -427,7 +512,7 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
|
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
|
||||||
{
|
{
|
||||||
$gateway = $this->createMock(BcODataGateway::class);
|
$gateway = $this->createMock(BcODataGateway::class);
|
||||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||||
|
|
||||||
$service = $this->createService($gateway);
|
$service = $this->createService($gateway);
|
||||||
$result = $service->loadSupportDashboard('10001', 'Test GmbH');
|
$result = $service->loadSupportDashboard('10001', 'Test GmbH');
|
||||||
@@ -1006,7 +1091,10 @@ class DebitorDetailServiceTest extends TestCase
|
|||||||
$this->assertArrayHasKey('rule_id', $indicator);
|
$this->assertArrayHasKey('rule_id', $indicator);
|
||||||
$this->assertArrayHasKey('triggered', $indicator);
|
$this->assertArrayHasKey('triggered', $indicator);
|
||||||
$this->assertArrayHasKey('value', $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 ---
|
// --- 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;
|
return debitorCommunicationPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateTicketCategoryFilter(config, categories) {
|
/**
|
||||||
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
|
* Hydrate a dynamic select filter with values from the API response.
|
||||||
if (!(categoryFilter instanceof HTMLSelectElement)) return;
|
* 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(
|
const uniqueValues = Array.from(new Set(
|
||||||
(Array.isArray(categories) ? categories : [])
|
(Array.isArray(values) ? values : [])
|
||||||
.map(item => String(item || '').trim())
|
.map(item => String(item || '').trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
|
|
||||||
const currentValue = String(categoryFilter.value || '');
|
const currentValue = String(selectEl.value || '');
|
||||||
categoryFilter.innerHTML = '';
|
selectEl.innerHTML = '';
|
||||||
|
|
||||||
const allOption = document.createElement('option');
|
const allOption = document.createElement('option');
|
||||||
allOption.value = '';
|
allOption.value = '';
|
||||||
allOption.textContent = t('All');
|
allOption.textContent = t('All');
|
||||||
categoryFilter.appendChild(allOption);
|
selectEl.appendChild(allOption);
|
||||||
|
|
||||||
const categoryOptionMap = {};
|
const optionMap = {};
|
||||||
for (const category of uniqueCategories) {
|
for (const value of uniqueValues) {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = category;
|
option.value = value;
|
||||||
option.textContent = category;
|
option.textContent = value;
|
||||||
categoryFilter.appendChild(option);
|
selectEl.appendChild(option);
|
||||||
categoryOptionMap[category] = category;
|
optionMap[value] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) {
|
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(optionMap, currentValue)) {
|
||||||
categoryFilter.value = currentValue;
|
selectEl.value = currentValue;
|
||||||
} else {
|
} else {
|
||||||
categoryFilter.value = '';
|
selectEl.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
|
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
|
||||||
config.filterChipMeta = {};
|
config.filterChipMeta = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object')
|
const currentMeta = (config.filterChipMeta[filterKey] && typeof config.filterChipMeta[filterKey] === 'object')
|
||||||
? config.filterChipMeta.category
|
? config.filterChipMeta[filterKey]
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
config.filterChipMeta.category = {
|
config.filterChipMeta[filterKey] = {
|
||||||
...currentCategoryMeta,
|
...currentMeta,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
default: '',
|
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) {
|
function renderSystemRecommendations(recommendations) {
|
||||||
const entries = Array.isArray(recommendations) ? recommendations : [];
|
const entries = Array.isArray(recommendations) ? recommendations : [];
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
@@ -381,119 +393,106 @@ function init(container) {
|
|||||||
return messageKey || String(entry.rule_id || '—');
|
return messageKey || String(entry.rule_id || '—');
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = '<table><thead><tr>';
|
let html = '<div class="helpdesk-rec-list">';
|
||||||
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
|
||||||
html += `<th>${esc(t('Action'))}</th>`;
|
|
||||||
html += `<th>${esc(t('Age (h)'))}</th>`;
|
|
||||||
html += '</tr></thead><tbody>';
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const ticketNo = String(entry.ticket_no || '');
|
const ticketNo = String(entry.ticket_no || '');
|
||||||
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
||||||
const ageHours = Number.isFinite(Number(entry.age_hours)) ? String(entry.age_hours) : '—';
|
const ruleId = String(entry.rule_id || '');
|
||||||
const rowAttrs = href ? ` class="app-clickable-row" data-href="${esc(href)}" tabindex="0"` : '';
|
const severity = severityMap[ruleId] || { cls: 'low', icon: '🔵' };
|
||||||
html += `<tr${rowAttrs}>`;
|
const ageHours = Number.isFinite(Number(entry.age_hours)) ? Number(entry.age_hours) : null;
|
||||||
html += ticketNo && href
|
const ageLabel = ageHours !== null ? fmtHours(ageHours) : '';
|
||||||
? `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`
|
const message = formatRecommendationMessage(entry);
|
||||||
: `<td>${esc(ticketNo || '—')}</td>`;
|
|
||||||
html += `<td>${esc(formatRecommendationMessage(entry))}</td>`;
|
|
||||||
html += `<td>${esc(ageHours)}</td>`;
|
|
||||||
html += '</tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSupportContracts(entries) {
|
function renderSupportContractKpis(contracts) {
|
||||||
const contracts = Array.isArray(entries) ? entries : [];
|
const entries = Array.isArray(contracts) ? contracts : [];
|
||||||
|
const wrapper = document.getElementById('support-contract-kpis-wrapper');
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
if (contracts.length === 0) {
|
if (entries.length === 0) {
|
||||||
return renderEmptyState({
|
wrapper.hidden = true;
|
||||||
message: t('No contracts found for this customer.'),
|
return;
|
||||||
size: 'compact',
|
|
||||||
align: 'center',
|
|
||||||
icon: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (value) => {
|
const amountFmt = new Intl.NumberFormat(undefined, {
|
||||||
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, {
|
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let html = '<table><thead><tr>';
|
const activeCount = entries.filter(c => String(c.state || '').toLowerCase() !== 'ended').length;
|
||||||
html += `<th>${esc(t('Contract No.'))}</th>`;
|
const totalVolume = entries.reduce((sum, c) => sum + Number(c.total_payoff_amount ?? 0), 0);
|
||||||
html += `<th>${esc(t('Product type'))}</th>`;
|
const totalSupportHours = entries.reduce((sum, c) => sum + Number(c.support_hours ?? 0), 0);
|
||||||
html += `<th>${esc(t('State'))}</th>`;
|
|
||||||
html += `<th>${esc(t('Next invoicing'))}</th>`;
|
|
||||||
html += `<th>${esc(t('Amount'))}</th>`;
|
|
||||||
html += '</tr></thead><tbody>';
|
|
||||||
|
|
||||||
for (const contract of contracts.slice(0, 20)) {
|
// Find nearest next invoicing
|
||||||
const amount = Number(contract.total_payoff_amount ?? 0);
|
const now = Date.now();
|
||||||
html += '<tr>';
|
let nearestInvoicing = null;
|
||||||
html += `<td>${esc(contract.contract_no || '')}</td>`;
|
for (const c of entries) {
|
||||||
html += `<td>${esc(contract.product_type || '—')}</td>`;
|
const raw = String(c.next_invoicing_date || '').trim();
|
||||||
html += `<td>${esc(contract.state || '—')}</td>`;
|
if (!raw || raw.startsWith('0001-01-01')) continue;
|
||||||
html += `<td>${esc(formatDate(contract.next_invoicing_date))}</td>`;
|
const date = new Date(raw);
|
||||||
html += `<td>${esc(amountFormatter.format(Number.isFinite(amount) ? amount : 0))}</td>`;
|
if (!Number.isNaN(date.getTime()) && date.getTime() > now) {
|
||||||
html += '</tr>';
|
if (!nearestInvoicing || date < nearestInvoicing) nearestInvoicing = date;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</tbody></table>';
|
setText('support-kpi-active-contracts', activeCount);
|
||||||
return html;
|
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) {
|
function contractTypeLabel(productType) {
|
||||||
return String(productType || '').trim() || '—';
|
return String(productType || '').trim() || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCENT_COUNT = 8;
|
// Shared formatter for contract rendering (dialog, timeline)
|
||||||
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
|
|
||||||
const contractAmountFmt = new Intl.NumberFormat(undefined, {
|
const contractAmountFmt = new Intl.NumberFormat(undefined, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contractAmountFmtShort = new Intl.NumberFormat(undefined, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR',
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
function contractFormatDate(value) {
|
function contractFormatDate(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
||||||
@@ -502,13 +501,6 @@ function init(container) {
|
|||||||
return date.toLocaleDateString();
|
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) {
|
function contractStateVariant(state) {
|
||||||
const s = String(state || '').toLowerCase();
|
const s = String(state || '').toLowerCase();
|
||||||
if (s === 'aktiv') return 'success';
|
if (s === 'aktiv') return 'success';
|
||||||
@@ -519,55 +511,7 @@ function init(container) {
|
|||||||
/** Store all contracts for dialog access */
|
/** Store all contracts for dialog access */
|
||||||
let salesContracts = [];
|
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) {
|
function renderContractLinesTable(lines) {
|
||||||
if (!Array.isArray(lines) || lines.length === 0) {
|
if (!Array.isArray(lines) || lines.length === 0) {
|
||||||
@@ -674,110 +618,52 @@ function init(container) {
|
|||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate click on contract cards
|
// Delegate click on contract cards and timeline rows
|
||||||
container.addEventListener('click', (e) => {
|
container.addEventListener('click', (e) => {
|
||||||
const card = e.target.closest('.helpdesk-contract-card[data-contract-index]');
|
const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]');
|
||||||
if (!card) return;
|
if (!target) return;
|
||||||
const index = Number(card.dataset.contractIndex);
|
const index = Number(target.dataset.contractIndex);
|
||||||
if (salesContracts[index]) {
|
if (salesContracts[index]) {
|
||||||
openContractDialog(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) => {
|
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;
|
if (!kpi) return;
|
||||||
const filterValue = kpi.dataset.kpiFilter || '';
|
|
||||||
const statusSelect = document.getElementById('helpdesk-ticket-status-filter');
|
// Apply status filter if present
|
||||||
if (statusSelect instanceof HTMLSelectElement) {
|
if (kpi.dataset.kpiFilter !== undefined) {
|
||||||
statusSelect.value = filterValue;
|
const filterValue = kpi.dataset.kpiFilter || '';
|
||||||
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
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');
|
const ticketSection = document.querySelector('.helpdesk-support-ticket-list');
|
||||||
if (ticketSection) {
|
if (ticketSection) {
|
||||||
ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
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) {
|
function setText(id, value) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -804,7 +690,6 @@ function init(container) {
|
|||||||
function showLoading(id) {
|
function showLoading(id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.setAttribute('aria-busy', 'true');
|
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -812,7 +697,6 @@ function init(container) {
|
|||||||
function hideLoading(id) {
|
function hideLoading(id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.removeAttribute('aria-busy');
|
|
||||||
el.hidden = true;
|
el.hidden = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,6 +728,16 @@ function init(container) {
|
|||||||
if (href) {
|
if (href) {
|
||||||
window.location.href = 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 {
|
try {
|
||||||
const categoryResponse = await fetchTicketCategories(config, appBase);
|
const filterOptionsResponse = await fetchTicketCategories(config, appBase);
|
||||||
hydrateTicketCategoryFilter(config, categoryResponse?.categories || []);
|
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 = {
|
const gridOptions = {
|
||||||
gridjs,
|
gridjs,
|
||||||
@@ -953,8 +849,6 @@ function init(container) {
|
|||||||
showContent('support-content');
|
showContent('support-content');
|
||||||
|
|
||||||
const recommendationsEl = document.getElementById('support-system-recommendations');
|
const recommendationsEl = document.getElementById('support-system-recommendations');
|
||||||
const contractsEl = document.getElementById('support-contracts-widget');
|
|
||||||
const escalationEl = document.getElementById('support-escalation-widget');
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
setText('support-kpi-open-tickets', '—');
|
setText('support-kpi-open-tickets', '—');
|
||||||
setText('support-kpi-created-30d', '—');
|
setText('support-kpi-created-30d', '—');
|
||||||
@@ -963,12 +857,6 @@ function init(container) {
|
|||||||
if (recommendationsEl) {
|
if (recommendationsEl) {
|
||||||
recommendationsEl.innerHTML = errorNotice(t('Could not load tickets.'));
|
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;
|
supportTabRendered = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1037,54 +925,21 @@ function init(container) {
|
|||||||
ageHintEl.innerHTML = `<span class="helpdesk-kpi-trend-negative">${criticalTickets} ${t('critical')}</span>`;
|
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) {
|
if (recommendationsEl) {
|
||||||
const recommendations = Array.isArray(result.recommendations)
|
const recommendations = Array.isArray(result.recommendations)
|
||||||
? result.recommendations
|
? result.recommendations
|
||||||
: (Array.isArray(result.actions) ? result.actions : []);
|
: (Array.isArray(result.actions) ? result.actions : []);
|
||||||
if (recommendations.length > 0) {
|
if (recommendations.length > 0) {
|
||||||
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
|
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
|
||||||
} else {
|
if (recommendationsWrapper) recommendationsWrapper.hidden = false;
|
||||||
recommendationsEl.innerHTML = renderEmptyState({
|
} else if (recommendationsWrapper) {
|
||||||
message: t('No system recommendations right now.'),
|
recommendationsWrapper.hidden = true;
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,12 +1043,8 @@ function init(container) {
|
|||||||
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render contracts
|
// Contract timeline (swimlane view — click on bar opens contract dialog)
|
||||||
const detailEl = document.getElementById('sales-contracts-detail');
|
renderContractTimeline(result.contracts || []);
|
||||||
if (detailEl) {
|
|
||||||
detailEl.innerHTML = `<h3 class="helpdesk-support-section-title">${esc(t('Contract overview'))}</h3>`
|
|
||||||
+ renderContractCards(result.contracts || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init contacts grid (server-side, same pattern as tickets)
|
// Init contacts grid (server-side, same pattern as tickets)
|
||||||
initContactsGrid();
|
initContactsGrid();
|
||||||
@@ -1201,6 +1052,144 @@ function init(container) {
|
|||||||
salesTabRendered = true;
|
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() {
|
async function renderDebitorCommunicationAside() {
|
||||||
const feedEl = document.getElementById('debitor-communication-feed');
|
const feedEl = document.getElementById('debitor-communication-feed');
|
||||||
const loadingEl = document.getElementById('debitor-communication-loading');
|
const loadingEl = document.getElementById('debitor-communication-loading');
|
||||||
@@ -1358,8 +1347,7 @@ function init(container) {
|
|||||||
// Trend chart
|
// Trend chart
|
||||||
renderTrendChart(result.trend || []);
|
renderTrendChart(result.trend || []);
|
||||||
|
|
||||||
// Efficiency
|
|
||||||
renderEfficiencyWidget(result.efficiency || {});
|
|
||||||
|
|
||||||
// Risk indicators
|
// Risk indicators
|
||||||
renderControllingRiskIndicators(result.risk_indicators || [], result.risk_summary || {});
|
renderControllingRiskIndicators(result.risk_indicators || [], result.risk_summary || {});
|
||||||
@@ -1371,6 +1359,7 @@ function init(container) {
|
|||||||
const chartEl = document.getElementById('controlling-trend-chart');
|
const chartEl = document.getElementById('controlling-trend-chart');
|
||||||
if (!chartEl || !trend.length) return;
|
if (!chartEl || !trend.length) return;
|
||||||
|
|
||||||
|
// Find max value for Y-axis scaling
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const bucket of trend) {
|
for (const bucket of trend) {
|
||||||
if (bucket.created > max) max = bucket.created;
|
if (bucket.created > max) max = bucket.created;
|
||||||
@@ -1378,74 +1367,102 @@ function init(container) {
|
|||||||
}
|
}
|
||||||
if (max === 0) max = 1;
|
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) {
|
for (const bucket of trend) {
|
||||||
const createdPct = Math.round((bucket.created / max) * 100);
|
const createdPct = ((bucket.created / max) * 100).toFixed(1);
|
||||||
const closedPct = Math.round((bucket.closed / max) * 100);
|
const closedPct = ((bucket.closed / max) * 100).toFixed(1);
|
||||||
rowsHtml += `<div class="helpdesk-hbar-row">
|
barsHtml += `<div class="helpdesk-vchart-group">
|
||||||
<span class="helpdesk-hbar-label">${esc(bucket.label)}</span>
|
<div class="helpdesk-vchart-bars">
|
||||||
<div class="helpdesk-hbar-track">
|
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-created" style="height:${createdPct}%" data-tooltip="${bucket.created}" data-tooltip-pos="top"></div>
|
||||||
<div class="helpdesk-hbar helpdesk-hbar-created" style="width:${createdPct}%">
|
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-closed" style="height:${closedPct}%" data-tooltip="${bucket.closed}" data-tooltip-pos="top"></div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="helpdesk-vchart-label">${esc(bucket.label)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
chartEl.innerHTML = `
|
chartEl.innerHTML = `
|
||||||
<div class="helpdesk-hbar-chart">${rowsHtml}</div>
|
<div class="helpdesk-vchart-area">
|
||||||
<div class="helpdesk-hbar-legend">
|
${gridHtml}
|
||||||
<span class="helpdesk-hbar-legend-item">
|
<div class="helpdesk-vchart-groups">${barsHtml}</div>
|
||||||
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-created"></span>
|
</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'))}
|
${esc(t('Created'))}
|
||||||
</span>
|
</span>
|
||||||
<span class="helpdesk-hbar-legend-item">
|
<span class="helpdesk-vchart-legend-item">
|
||||||
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-closed"></span>
|
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-closed"></span>
|
||||||
${esc(t('Closed'))}
|
${esc(t('Closed'))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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) {
|
function renderControllingRiskIndicators(indicators, summary) {
|
||||||
const el = document.getElementById('controlling-risk-indicators');
|
const el = document.getElementById('controlling-risk-indicators');
|
||||||
if (!el || !indicators.length) return;
|
if (!el || !indicators.length) return;
|
||||||
|
|
||||||
const level = summary.level || 'low';
|
// Count triggered from actual indicator data (single source of truth)
|
||||||
const levelLabel = t(level.charAt(0).toUpperCase() + level.slice(1));
|
const total = indicators.length;
|
||||||
const levelCls = level === 'high' ? 'helpdesk-kpi-trend-negative'
|
const triggered = indicators.filter(ind => !!ind.triggered).length;
|
||||||
: level === 'medium' ? 'helpdesk-kpi-trend-neutral'
|
const summaryLabel = triggered === 0
|
||||||
: 'helpdesk-kpi-trend-positive';
|
? 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">
|
let html = `<div class="helpdesk-risk-header">
|
||||||
<span class="${levelCls}">${esc(levelLabel)}</span>
|
<span class="helpdesk-risk-header-icon ${summaryCls}">${triggered === 0 ? '✓' : '⚠'}</span>
|
||||||
<span class="helpdesk-controlling-risk-summary-count">${summary.triggered ?? 0}/${summary.total ?? 0} ${esc(t('flags'))}</span>
|
<span class="helpdesk-risk-header-label">${esc(summaryLabel)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
html += '<div class="helpdesk-controlling-risk-list">';
|
html += '<div class="helpdesk-risk-checks">';
|
||||||
for (const ind of indicators) {
|
for (const ind of indicators) {
|
||||||
const dotCls = ind.triggered ? 'helpdesk-controlling-risk-dot-triggered' : 'helpdesk-controlling-risk-dot-ok';
|
const icon = ind.triggered ? '⚠' : '✓';
|
||||||
html += `<div class="helpdesk-controlling-risk-row">
|
const iconCls = ind.triggered ? 'helpdesk-risk-icon-warn' : 'helpdesk-risk-icon-ok';
|
||||||
<span class="helpdesk-controlling-risk-dot ${dotCls}"></span>
|
const barCls = ind.triggered ? 'helpdesk-risk-bar-over' : 'helpdesk-risk-bar-ok';
|
||||||
<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>
|
// 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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ $renderErrorTable = static function (array $rows): void {
|
|||||||
</a>
|
</a>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<form id="imports-upload-form" method="post" enctype="multipart/form-data">
|
<form id="imports-upload-form" method="post" enctype="multipart/form-data" data-standard-detail-form="1">
|
||||||
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
||||||
<input type="hidden" name="stage" value="analyze">
|
<input type="hidden" name="stage" value="analyze">
|
||||||
<input type="hidden" name="type" value="<?php e($selectedType); ?>">
|
<input type="hidden" name="type" value="<?php e($selectedType); ?>">
|
||||||
@@ -104,7 +104,7 @@ $renderErrorTable = static function (array $rows): void {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<?php elseif ($step === 'mapping'): ?>
|
<?php elseif ($step === 'mapping'): ?>
|
||||||
<form id="imports-mapping-form" method="post">
|
<form id="imports-mapping-form" method="post" data-standard-detail-form="1">
|
||||||
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
||||||
<input type="hidden" name="stage" value="preview">
|
<input type="hidden" name="stage" value="preview">
|
||||||
<input type="hidden" name="type" value="<?php e($selectedType); ?>">
|
<input type="hidden" name="type" value="<?php e($selectedType); ?>">
|
||||||
@@ -179,7 +179,7 @@ $renderErrorTable = static function (array $rows): void {
|
|||||||
<h3><?php e(t('Row issues')); ?></h3>
|
<h3><?php e(t('Row issues')); ?></h3>
|
||||||
<?php $renderErrorTable(is_array($preview['errors'] ?? null) ? $preview['errors'] : []); ?>
|
<?php $renderErrorTable(is_array($preview['errors'] ?? null) ? $preview['errors'] : []); ?>
|
||||||
<hr>
|
<hr>
|
||||||
<form id="imports-commit-form" method="post">
|
<form id="imports-commit-form" method="post" data-standard-detail-form="1">
|
||||||
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
||||||
<input type="hidden" name="stage" value="commit">
|
<input type="hidden" name="stage" value="commit">
|
||||||
<input type="hidden" name="type" value="<?php e($selectedType); ?>">
|
<input type="hidden" name="type" value="<?php e($selectedType); ?>">
|
||||||
|
|||||||
@@ -44,24 +44,26 @@ $hasAdminPanel = layoutHasAdminPanel($layoutAuth);
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if ($hasAdminPanel): ?>
|
</ul>
|
||||||
|
<div class="aside-icon-footer">
|
||||||
|
<ul role="list">
|
||||||
<li>
|
<li>
|
||||||
<button type="button" id="aside-tab-admin" data-aside-target="admin" role="tab" aria-selected="false"
|
<a href="<?php e($accountUrl); ?>" aria-label="<?php e(t('Account')); ?>"
|
||||||
aria-controls="aside-panel-admin" aria-label="<?php e(t('Admin')); ?>"
|
data-tooltip="<?php e($accountTooltip); ?>" data-tooltip-pos="right" data-aside-shortcut="account"><i
|
||||||
data-tooltip="<?php e(t('Admin')); ?>" data-tooltip-pos="right">
|
class="bi bi-person-circle"></i></a>
|
||||||
<i class="bi bi-shield-lock"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
</li>
|
||||||
|
</ul>
|
||||||
|
<?php if ($hasAdminPanel): ?>
|
||||||
|
<ul class="aside-icon-group" role="tablist" aria-orientation="vertical">
|
||||||
|
<li>
|
||||||
|
<button type="button" id="aside-tab-admin" data-aside-target="admin" role="tab" aria-selected="false"
|
||||||
|
aria-controls="aside-panel-admin" aria-label="<?php e(t('Admin')); ?>"
|
||||||
|
data-tooltip="<?php e(t('Admin')); ?>" data-tooltip-pos="right">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</ul>
|
</div>
|
||||||
<ul role="list">
|
|
||||||
<li>
|
|
||||||
<a href="<?php e($accountUrl); ?>" aria-label="<?php e(t('Account')); ?>"
|
|
||||||
data-tooltip="<?php e($accountTooltip); ?>" data-tooltip-pos="right" data-aside-shortcut="account"><i
|
|
||||||
class="bi bi-person-circle"></i></a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -114,14 +114,14 @@ final class AgentSystemEnforcementContractTest extends TestCase
|
|||||||
$content = $this->readProjectFile($relativePath);
|
$content = $this->readProjectFile($relativePath);
|
||||||
|
|
||||||
preg_match_all('/\bGR-(CORE|TEST|UI|SEC)-[A-Z0-9]{3,}\b/', $content, $guardMatches);
|
preg_match_all('/\bGR-(CORE|TEST|UI|SEC)-[A-Z0-9]{3,}\b/', $content, $guardMatches);
|
||||||
foreach (array_unique($guardMatches[0] ?? []) as $id) {
|
foreach (array_unique($guardMatches[0]) as $id) {
|
||||||
if (!isset($knownGuardIds[$id])) {
|
if (!isset($knownGuardIds[$id])) {
|
||||||
$unknown[] = $relativePath . ': ' . $id;
|
$unknown[] = $relativePath . ': ' . $id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preg_match_all('/\bQG-[0-9]{3}\b/', $content, $gateMatches);
|
preg_match_all('/\bQG-[0-9]{3}\b/', $content, $gateMatches);
|
||||||
foreach (array_unique($gateMatches[0] ?? []) as $id) {
|
foreach (array_unique($gateMatches[0]) as $id) {
|
||||||
if (!isset($knownGateIds[$id])) {
|
if (!isset($knownGateIds[$id])) {
|
||||||
$unknown[] = $relativePath . ': ' . $id;
|
$unknown[] = $relativePath . ': ' . $id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ aside.aside-icon-bar nav {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aside.aside-icon-bar .aside-icon-footer {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
aside.aside-icon-bar ul,
|
aside.aside-icon-bar ul,
|
||||||
aside.aside-icon-bar li {
|
aside.aside-icon-bar li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -94,6 +98,11 @@ aside.aside-icon-bar li button:focus-visible {
|
|||||||
gap: var(--app-spacing);
|
gap: var(--app-spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aside.aside-icon-bar .aside-icon-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--app-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
aside.aside-icon-bar ul {
|
aside.aside-icon-bar ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 10px; /* icon-font metric — intentional px */
|
||||||
width: 1.45rem;
|
width: 1.45rem;
|
||||||
height: 1.45rem;
|
height: 1.45rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user