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:
@@ -56,7 +56,16 @@ class DebitorDetailService
|
||||
/**
|
||||
* Load contacts for a customer (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, contacts?: array<int, array<string, mixed>>, error?: string}
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* contacts?: array<int, array<string, mixed>>,
|
||||
* meta?: array{
|
||||
* source: string,
|
||||
* fallback_used: bool,
|
||||
* fallback_reason: string
|
||||
* },
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function loadContacts(string $customerNo, string $customerName): array
|
||||
{
|
||||
@@ -72,18 +81,38 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
try {
|
||||
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
|
||||
$result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'contacts' => $contacts];
|
||||
$contacts = $result['contacts'];
|
||||
$meta = $result['meta'];
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'contacts' => $contacts,
|
||||
'meta' => [
|
||||
'source' => $meta['source'],
|
||||
'fallback_used' => $meta['fallback_used'],
|
||||
'fallback_reason' => $meta['fallback_reason'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tickets for a customer (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, tickets?: array<int, array<string, mixed>>, error?: string}
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* tickets?: array<int, array<string, mixed>>,
|
||||
* meta?: array{
|
||||
* source: string,
|
||||
* fallback_used: bool,
|
||||
* fallback_reason: string
|
||||
* },
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function loadTickets(string $customerNo, string $customerName): array
|
||||
{
|
||||
@@ -99,12 +128,23 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
try {
|
||||
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
|
||||
$result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tickets' => $tickets];
|
||||
$tickets = $result['tickets'];
|
||||
$meta = $result['meta'];
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'tickets' => $tickets,
|
||||
'meta' => [
|
||||
'source' => $meta['source'],
|
||||
'fallback_used' => $meta['fallback_used'],
|
||||
'fallback_reason' => $meta['fallback_reason'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +202,7 @@ class DebitorDetailService
|
||||
|
||||
$tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : [];
|
||||
$escalation = $this->loadEscalationHealth($customerNo, $tickets);
|
||||
if (!($escalation['ok'] ?? false)) {
|
||||
if (!$escalation['ok']) {
|
||||
$escalation = null;
|
||||
}
|
||||
|
||||
@@ -277,7 +317,7 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
$targetSeconds = $targetSecondsByCode[$escalationCode] ?? null;
|
||||
if (!is_int($targetSeconds) || $targetSeconds <= 0) {
|
||||
if (!is_int($targetSeconds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -316,12 +356,12 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
$cmp = ((int) ($b['overdue_seconds'] ?? 0)) <=> ((int) ($a['overdue_seconds'] ?? 0));
|
||||
$cmp = $b['overdue_seconds'] <=> $a['overdue_seconds'];
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['ticket_no'] ?? ''), (string) ($b['ticket_no'] ?? ''));
|
||||
return strcmp($a['ticket_no'], $b['ticket_no']);
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -1362,11 +1402,6 @@ class DebitorDetailService
|
||||
'backlog_critical' => $criticalOpen,
|
||||
],
|
||||
'trend' => array_values($buckets),
|
||||
'efficiency' => [
|
||||
'resolution_hours' => $resolutionMedian,
|
||||
'unassigned_count' => $unassignedOpen,
|
||||
'oldest_open_hours' => $oldestOpenAgeHours,
|
||||
],
|
||||
'risk_indicators' => $riskIndicators,
|
||||
'risk_summary' => [
|
||||
'triggered' => $triggeredCount,
|
||||
@@ -1457,7 +1492,7 @@ class DebitorDetailService
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $rules
|
||||
* @return array<int, array{rule_id: string, label: string, triggered: bool, value: string, threshold: string}>
|
||||
* @return array<int, array{rule_id: string, label: string, description: string, triggered: bool, value: string, raw_value: float|int, threshold_value: int, unit: string}>
|
||||
*/
|
||||
private static function evaluateControllingRisks(
|
||||
array $rules,
|
||||
@@ -1478,10 +1513,13 @@ class DebitorDetailService
|
||||
: ($createdInPeriod > 0 ? 100.0 : 0.0);
|
||||
$indicators[] = [
|
||||
'rule_id' => 'ticket_volume_increase',
|
||||
'label' => 'Ticket volume increase',
|
||||
'triggered' => $increase > $threshold,
|
||||
'label' => 'Ticket volume',
|
||||
'description' => 'risk_desc_ticket_volume',
|
||||
'triggered' => $increase >= $threshold,
|
||||
'value' => ($increase > 0 ? '+' : '') . $increase . '%',
|
||||
'threshold' => $threshold . '%',
|
||||
'raw_value' => max(0, $increase),
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => '%',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1489,13 +1527,17 @@ class DebitorDetailService
|
||||
$rule = is_array($rules['avg_resolution_high'] ?? null) ? $rules['avg_resolution_high'] : [];
|
||||
if (($rule['enabled'] ?? true) === true) {
|
||||
$threshold = (int) ($rule['threshold_hours'] ?? 72);
|
||||
$triggered = $avgResolution !== null && $avgResolution > $threshold;
|
||||
$triggered = $avgResolution !== null && $avgResolution >= $threshold;
|
||||
$rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0;
|
||||
$indicators[] = [
|
||||
'rule_id' => 'avg_resolution_high',
|
||||
'label' => 'High avg. resolution time',
|
||||
'label' => 'Resolution time',
|
||||
'description' => 'risk_desc_resolution_time',
|
||||
'triggered' => $triggered,
|
||||
'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—',
|
||||
'threshold' => $threshold . 'h',
|
||||
'raw_value' => $rawValue,
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => 'h',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1505,10 +1547,13 @@ class DebitorDetailService
|
||||
$threshold = (int) ($rule['threshold_hours'] ?? 168);
|
||||
$indicators[] = [
|
||||
'rule_id' => 'open_aging',
|
||||
'label' => 'Open ticket aging',
|
||||
'triggered' => $oldestOpenAgeHours > $threshold,
|
||||
'label' => 'Oldest open ticket',
|
||||
'description' => 'risk_desc_open_aging',
|
||||
'triggered' => $oldestOpenAgeHours >= $threshold,
|
||||
'value' => $oldestOpenAgeHours . 'h',
|
||||
'threshold' => $threshold . 'h',
|
||||
'raw_value' => $oldestOpenAgeHours,
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => 'h',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1518,10 +1563,13 @@ class DebitorDetailService
|
||||
$threshold = (int) ($rule['threshold_percent'] ?? 20);
|
||||
$indicators[] = [
|
||||
'rule_id' => 'unassigned_ratio',
|
||||
'label' => 'Unassigned ticket ratio',
|
||||
'triggered' => $unassignedRate > $threshold,
|
||||
'label' => 'Unassigned',
|
||||
'description' => 'risk_desc_unassigned',
|
||||
'triggered' => $unassignedRate >= $threshold,
|
||||
'value' => $unassignedRate . '%',
|
||||
'threshold' => $threshold . '%',
|
||||
'raw_value' => $unassignedRate,
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => '%',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user