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>
234 lines
9.4 KiB
PHP
234 lines
9.4 KiB
PHP
<?php
|
|
|
|
use MintyPHP\Http\SessionStoreInterface;
|
|
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
|
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
|
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
|
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
|
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
|
use MintyPHP\Router;
|
|
use MintyPHP\Support\Guard;
|
|
|
|
Guard::requireLogin();
|
|
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
|
|
|
gridRequireGetRequest();
|
|
|
|
$request = requestInput();
|
|
$customerNo = trim((string) $request->query('customerNo', ''));
|
|
$customerName = trim((string) $request->query('customerName', ''));
|
|
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
|
|
|
|
if ($customerNo === '' || $customerName === '') {
|
|
http_response_code(400);
|
|
Router::json(['ok' => false, 'error' => 'Missing parameters']);
|
|
|
|
return;
|
|
}
|
|
|
|
$sessionStore = app(SessionStoreInterface::class);
|
|
$session = $sessionStore->all();
|
|
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
|
|
if ($refreshRequested) {
|
|
DebitorCacheControl::invalidateDebitorCaches($sessionStore, $tenantScope, $customerNo, true);
|
|
}
|
|
|
|
$cacheKey = DebitorCacheControl::ticketsKey($tenantScope, $customerNo);
|
|
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
|
|
$cached = $sessionStore->get($cacheKey);
|
|
$tickets = null;
|
|
$sourceCacheUsed = false;
|
|
|
|
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
|
$tickets = $cached['tickets'] ?? [];
|
|
$sourceCacheUsed = true;
|
|
}
|
|
|
|
if ($tickets === null) {
|
|
$service = app(DebitorDetailService::class);
|
|
$ticketsResult = $service->loadTickets($customerNo, $customerName);
|
|
|
|
if (!($ticketsResult['ok'] ?? false)) {
|
|
Router::json([
|
|
'ok' => false,
|
|
'error' => (string) ($ticketsResult['error'] ?? 'Failed to load tickets'),
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
$tickets = $ticketsResult['tickets'] ?? [];
|
|
if (!is_array($tickets)) {
|
|
$tickets = [];
|
|
}
|
|
|
|
$sessionStore->set($cacheKey, [
|
|
'tickets' => $tickets,
|
|
'fetched_at' => time(),
|
|
]);
|
|
}
|
|
|
|
if (!is_array($tickets)) {
|
|
$tickets = [];
|
|
}
|
|
|
|
$service = app(DebitorDetailService::class);
|
|
$escalationCacheKey = DebitorCacheControl::escalationKey($tenantScope, $customerNo);
|
|
$escalationCached = $sessionStore->get($escalationCacheKey);
|
|
$escalationSourceCacheUsed = false;
|
|
$escalationPayload = null;
|
|
|
|
if (!$refreshRequested && is_array($escalationCached) && isset($escalationCached['fetched_at']) && (time() - (int) $escalationCached['fetched_at']) < $cacheTtl) {
|
|
$escalationPayload = [
|
|
'ok' => (bool) ($escalationCached['ok'] ?? false),
|
|
'overdue_count' => (int) ($escalationCached['overdue_count'] ?? 0),
|
|
'entries' => is_array($escalationCached['entries'] ?? null) ? $escalationCached['entries'] : [],
|
|
'matches' => is_array($escalationCached['matches'] ?? null) ? $escalationCached['matches'] : [],
|
|
'tickets_considered' => (int) ($escalationCached['tickets_considered'] ?? 0),
|
|
'tickets_matched' => (int) ($escalationCached['tickets_matched'] ?? 0),
|
|
'definitions_count' => (int) ($escalationCached['definitions_count'] ?? 0),
|
|
'error' => (string) ($escalationCached['error'] ?? ''),
|
|
];
|
|
$escalationSourceCacheUsed = true;
|
|
}
|
|
|
|
if ($escalationPayload === null) {
|
|
$escalationResult = $service->loadEscalationHealth($customerNo, $tickets);
|
|
$escalationPayload = $escalationResult;
|
|
|
|
$sessionStore->set($escalationCacheKey, [
|
|
'ok' => (bool) ($escalationResult['ok'] ?? false),
|
|
'overdue_count' => (int) ($escalationResult['overdue_count'] ?? 0),
|
|
'entries' => is_array($escalationResult['entries'] ?? null) ? $escalationResult['entries'] : [],
|
|
'matches' => is_array($escalationResult['matches'] ?? null) ? $escalationResult['matches'] : [],
|
|
'tickets_considered' => (int) ($escalationResult['tickets_considered'] ?? 0),
|
|
'tickets_matched' => (int) ($escalationResult['tickets_matched'] ?? 0),
|
|
'definitions_count' => (int) ($escalationResult['definitions_count'] ?? 0),
|
|
'error' => (string) ($escalationResult['error'] ?? ''),
|
|
'fetched_at' => time(),
|
|
]);
|
|
}
|
|
|
|
$dashboard = DebitorDetailService::buildSupportDashboard(
|
|
$tickets,
|
|
48,
|
|
(($escalationPayload['ok'] ?? false) ? $escalationPayload : null)
|
|
);
|
|
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
|
$recommendationConfigEnvelope = $settingsGateway->getSystemRecommendationsConfigEnvelope();
|
|
$recommendationConfig = is_array($recommendationConfigEnvelope['config'] ?? null)
|
|
? $recommendationConfigEnvelope['config']
|
|
: [];
|
|
$recommendationEngine = app(SystemRecommendationEngine::class);
|
|
$recommendationResult = $recommendationEngine->buildRecommendations(
|
|
$tickets,
|
|
(($escalationPayload['ok'] ?? false) ? $escalationPayload : null),
|
|
$recommendationConfig
|
|
);
|
|
$recommendations = is_array($recommendationResult['recommendations'] ?? null)
|
|
? $recommendationResult['recommendations']
|
|
: [];
|
|
$recommendationsMeta = is_array($recommendationResult['meta'] ?? null)
|
|
? $recommendationResult['meta']
|
|
: [];
|
|
$meta = $dashboard['meta'];
|
|
$meta['source_cache_used'] = $sourceCacheUsed;
|
|
$meta['escalation_source_cache_used'] = $escalationSourceCacheUsed;
|
|
$meta['cache_used'] = ($sourceCacheUsed || $escalationSourceCacheUsed);
|
|
$meta['cache_bypassed'] = $refreshRequested;
|
|
$meta['cache_refreshed'] = $refreshRequested;
|
|
|
|
$contractsCacheKey = DebitorCacheControl::contractsKey($tenantScope, $customerNo);
|
|
$contractsCached = $sessionStore->get($contractsCacheKey);
|
|
$contractsSourceCacheUsed = false;
|
|
$contractsPayload = null;
|
|
|
|
if (!$refreshRequested && is_array($contractsCached) && isset($contractsCached['fetched_at']) && (time() - (int) $contractsCached['fetched_at']) < $cacheTtl) {
|
|
$contractsPayload = [
|
|
'ok' => true,
|
|
'entries' => is_array($contractsCached['entries'] ?? null) ? $contractsCached['entries'] : [],
|
|
'summary' => is_array($contractsCached['summary'] ?? null) ? $contractsCached['summary'] : [
|
|
'total_contracts' => 0,
|
|
'active_contracts' => 0,
|
|
'product_types' => [],
|
|
],
|
|
];
|
|
$contractsSourceCacheUsed = true;
|
|
}
|
|
|
|
if ($contractsPayload === null) {
|
|
$contractsResult = $service->loadContracts($customerNo);
|
|
if ($contractsResult['ok'] ?? false) {
|
|
$entries = is_array($contractsResult['entries'] ?? null) ? $contractsResult['entries'] : [];
|
|
$summary = is_array($contractsResult['summary'] ?? null) ? $contractsResult['summary'] : [
|
|
'total_contracts' => 0,
|
|
'active_contracts' => 0,
|
|
'product_types' => [],
|
|
];
|
|
|
|
$contractsPayload = [
|
|
'ok' => true,
|
|
'entries' => $entries,
|
|
'summary' => $summary,
|
|
];
|
|
|
|
$sessionStore->set($contractsCacheKey, [
|
|
'entries' => $entries,
|
|
'summary' => $summary,
|
|
'fetched_at' => time(),
|
|
]);
|
|
} else {
|
|
$contractsPayload = [
|
|
'ok' => false,
|
|
'entries' => [],
|
|
'summary' => [
|
|
'total_contracts' => 0,
|
|
'active_contracts' => 0,
|
|
'product_types' => [],
|
|
],
|
|
'error' => (string) ($contractsResult['error'] ?? 'Failed to load contracts'),
|
|
];
|
|
}
|
|
}
|
|
|
|
$meta['cache_used'] = ($sourceCacheUsed || $escalationSourceCacheUsed || $contractsSourceCacheUsed);
|
|
|
|
Router::json([
|
|
'ok' => true,
|
|
'health' => $dashboard['health'],
|
|
'recommendations' => $recommendations,
|
|
'recommendations_meta' => [
|
|
'config_source' => (string) ($recommendationConfigEnvelope['source'] ?? 'default'),
|
|
'applied_rules' => is_array($recommendationsMeta['applied_rules'] ?? null) ? $recommendationsMeta['applied_rules'] : [],
|
|
'max_items' => (int) ($recommendationsMeta['max_items'] ?? (($recommendationConfig['max_items'] ?? 5))),
|
|
'escalation_data_available' => (bool) ($recommendationsMeta['escalation_data_available'] ?? false),
|
|
'cache_used' => ($sourceCacheUsed || $escalationSourceCacheUsed || $contractsSourceCacheUsed),
|
|
'cache_bypassed' => $refreshRequested,
|
|
'cache_refreshed' => $refreshRequested,
|
|
],
|
|
'actions' => $recommendations,
|
|
'meta' => $meta,
|
|
'contracts' => $contractsPayload['entries'],
|
|
'contracts_summary' => $contractsPayload['summary'],
|
|
'contracts_meta' => [
|
|
'available' => $contractsPayload['ok'],
|
|
'source_cache_used' => $contractsSourceCacheUsed,
|
|
'cache_used' => $contractsSourceCacheUsed,
|
|
'cache_bypassed' => $refreshRequested,
|
|
'cache_refreshed' => $refreshRequested,
|
|
'error' => (string) ($contractsPayload['error'] ?? ''),
|
|
],
|
|
'escalation_entries' => is_array($escalationPayload['entries'] ?? null) ? $escalationPayload['entries'] : [],
|
|
'escalation_meta' => [
|
|
'available' => (bool) ($escalationPayload['ok'] ?? false),
|
|
'source_cache_used' => $escalationSourceCacheUsed,
|
|
'cache_used' => $escalationSourceCacheUsed,
|
|
'cache_bypassed' => $refreshRequested,
|
|
'cache_refreshed' => $refreshRequested,
|
|
'error' => (string) ($escalationPayload['error'] ?? ''),
|
|
'tickets_considered' => (int) ($escalationPayload['tickets_considered'] ?? 0),
|
|
'tickets_matched' => (int) ($escalationPayload['tickets_matched'] ?? 0),
|
|
'definitions_count' => (int) ($escalationPayload['definitions_count'] ?? 0),
|
|
],
|
|
]);
|