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:
@@ -131,7 +131,7 @@ class BcODataGatewayTest extends TestCase
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 7],
|
||||
]);
|
||||
$sessionStore->method('get')->with('module.helpdesk.debitor.v1.escalation-definitions.7')->willReturn([
|
||||
$sessionStore->expects($this->any())->method('get')->with('module.helpdesk.debitor.v1.escalation-definitions.7')->willReturn([
|
||||
'values' => [
|
||||
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
],
|
||||
@@ -238,6 +238,34 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetContactsForCustomerAllowsSpecialCharactersInCompanyName(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
try {
|
||||
$results = $gateway->getContactsForCustomer('10636', 'Behindertenwerkstätten Oberpfalz – Betreuungs GmbH');
|
||||
$this->assertIsArray($results);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->fail('Trusted customer literals should not be rejected for contact loads.');
|
||||
} catch (\RuntimeException) {
|
||||
// Expected: cURL fails against fake URL.
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetTicketsForCustomerAllowsSpecialCharactersInCompanyName(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
try {
|
||||
$results = $gateway->getTicketsForCustomer('10494', "J.G. Knopf´s Sohn Gmbh & Co. KG");
|
||||
$this->assertIsArray($results);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->fail('Trusted customer literals should not be rejected for ticket loads.');
|
||||
} catch (\RuntimeException) {
|
||||
// Expected: cURL fails against fake URL.
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testListCustomersThrowsWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
|
||||
@@ -190,19 +190,28 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$contacts = [['No' => 'KT001', 'Name' => 'Max Mustermann']];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomer')->willReturn($contacts);
|
||||
$gateway->method('getContactsForCustomerWithMeta')->willReturn([
|
||||
'contacts' => $contacts,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($contacts, $result['contacts']);
|
||||
$this->assertSame('primary.company_name', $result['meta']['source']);
|
||||
$this->assertFalse($result['meta']['fallback_used']);
|
||||
}
|
||||
|
||||
public function testLoadContactsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomer')->willThrowException(new \RuntimeException('API error'));
|
||||
$gateway->method('getContactsForCustomerWithMeta')->willThrowException(new \RuntimeException('API error'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||
@@ -211,6 +220,29 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('API error', $result['error']);
|
||||
}
|
||||
|
||||
public function testLoadContactsExposesFallbackMeta(): void
|
||||
{
|
||||
$contacts = [['No' => 'KT001', 'Name' => 'Fallback Contact']];
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomerWithMeta')->willReturn([
|
||||
'contacts' => $contacts,
|
||||
'meta' => [
|
||||
'source' => 'fallback.integration_customer_no',
|
||||
'fallback_used' => true,
|
||||
'fallback_reason' => 'primary_query_failed',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Müller & Söhne GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($contacts, $result['contacts']);
|
||||
$this->assertSame('fallback.integration_customer_no', $result['meta']['source']);
|
||||
$this->assertTrue($result['meta']['fallback_used']);
|
||||
$this->assertSame('primary_query_failed', $result['meta']['fallback_reason']);
|
||||
}
|
||||
|
||||
// --- loadTickets() ---
|
||||
|
||||
public function testLoadTicketsReturnsFalseForEmptyCustomerNo(): void
|
||||
@@ -239,19 +271,28 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$tickets = [['No' => 'T001', 'Description' => 'Test ticket', 'Ticket_State' => 'Open']];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn($tickets);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($tickets, $result['tickets']);
|
||||
$this->assertSame('primary.company_contact_name', $result['meta']['source']);
|
||||
$this->assertFalse($result['meta']['fallback_used']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||
@@ -260,6 +301,29 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsExposesFallbackMeta(): void
|
||||
{
|
||||
$tickets = [['No' => 'T001', 'Description' => '', 'Ticket_State' => 'Open']];
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'fallback.customer_no_lv',
|
||||
'fallback_used' => true,
|
||||
'fallback_reason' => 'primary_empty',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Müller & Söhne GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($tickets, $result['tickets']);
|
||||
$this->assertSame('fallback.customer_no_lv', $result['meta']['source']);
|
||||
$this->assertTrue($result['meta']['fallback_used']);
|
||||
$this->assertSame('primary_empty', $result['meta']['fallback_reason']);
|
||||
}
|
||||
|
||||
// --- loadContracts() ---
|
||||
|
||||
public function testLoadContractsReturnsFalseForEmptyCustomerNo(): void
|
||||
@@ -367,7 +431,14 @@ class DebitorDetailServiceTest extends TestCase
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn($tickets);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -381,7 +452,14 @@ class DebitorDetailServiceTest extends TestCase
|
||||
public function testLoadTicketSummaryHandlesEmptyTicketList(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn([]);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => [],
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -400,7 +478,14 @@ class DebitorDetailServiceTest extends TestCase
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn($tickets);
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -413,7 +498,7 @@ class DebitorDetailServiceTest extends TestCase
|
||||
public function testLoadTicketSummaryReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
||||
@@ -427,7 +512,7 @@ class DebitorDetailServiceTest extends TestCase
|
||||
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadSupportDashboard('10001', 'Test GmbH');
|
||||
@@ -1006,7 +1091,10 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertArrayHasKey('rule_id', $indicator);
|
||||
$this->assertArrayHasKey('triggered', $indicator);
|
||||
$this->assertArrayHasKey('value', $indicator);
|
||||
$this->assertArrayHasKey('threshold', $indicator);
|
||||
$this->assertArrayHasKey('raw_value', $indicator);
|
||||
$this->assertArrayHasKey('threshold_value', $indicator);
|
||||
$this->assertArrayHasKey('unit', $indicator);
|
||||
$this->assertArrayHasKey('description', $indicator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,21 +1123,4 @@ class DebitorDetailServiceTest extends TestCase
|
||||
}
|
||||
|
||||
// --- Contract metrics ---
|
||||
|
||||
// --- Efficiency ---
|
||||
|
||||
public function testBuildControllingDashboardEfficiencyMetrics(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 48, 'NKS'),
|
||||
self::openTicket('T002', 24, ''),
|
||||
self::closedTicket('T003', 72, 20),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(20.0, $result['efficiency']['resolution_hours']);
|
||||
$this->assertSame(1, $result['efficiency']['unassigned_count']);
|
||||
$this->assertIsInt($result['efficiency']['oldest_open_hours']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user