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:
2026-04-05 16:32:34 +02:00
parent aee9cb10f3
commit 2f0f407268
19 changed files with 1776 additions and 851 deletions

View File

@@ -93,48 +93,93 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
<!-- Support panel -->
<div data-tab-panel="support">
<div id="support-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<div id="support-content" hidden>
<div id="support-loading">
<div class="helpdesk-support-metrics">
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="open">
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-open-tickets"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-open-trend"></p>
</article>
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="">
<p class="helpdesk-support-metric-label"><?php e(t('New (30d)')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-created-30d"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-created-trend"></p>
</article>
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="closed">
<p class="helpdesk-support-metric-label"><?php e(t('Resolved (30d)')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-closed-30d"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-closed-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Avg. age open')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-avg-age"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-age-hint"></p>
</article>
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="helpdesk-skeleton-kpi">
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
</div>
<?php endfor; ?>
</div>
<h3 class="helpdesk-support-section-title"><?php e(t('Analysis')); ?></h3>
<div class="grid grid-2" id="support-analysis-grid">
<div class="app-stats-table" id="support-recommendations-wrapper">
<div class="app-stats-table-header"><?php e(t('System recommendations')); ?></div>
<div id="support-system-recommendations"></div>
<div class="helpdesk-skeleton-divider"></div>
<div class="helpdesk-support-metrics">
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="helpdesk-skeleton-kpi">
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
</div>
<?php endfor; ?>
</div>
<div class="helpdesk-skeleton-divider"></div>
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="helpdesk-skeleton-row">
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
</div>
<div class="app-stats-table" id="support-contracts-wrapper">
<div class="app-stats-table-header"><?php e(t('Contracts & products')); ?></div>
<div id="support-contracts-widget"></div>
<?php endfor; ?>
</div>
<div id="support-content" hidden>
<section>
<h3 class="helpdesk-support-section-title"><?php e(t('Tickets')); ?></h3>
<div class="helpdesk-support-metrics">
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="open">
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-open-tickets"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-open-trend"></p>
</article>
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="">
<p class="helpdesk-support-metric-label"><?php e(t('New (30d)')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-created-30d"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-created-trend"></p>
</article>
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-filter="closed">
<p class="helpdesk-support-metric-label"><?php e(t('Resolved (30d)')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-closed-30d"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-closed-trend"></p>
</article>
<article class="helpdesk-support-metric helpdesk-kpi-clickable" data-kpi-sort="Last_Activity_Date:asc">
<p class="helpdesk-support-metric-label"><?php e(t('Avg. age open')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-avg-age"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-age-hint"></p>
</article>
</div>
</div>
</section>
<div class="app-stats-table" id="support-escalation-wrapper">
<div class="app-stats-table-header"><?php e(t('Escalated tickets')); ?></div>
<div id="support-escalation-widget"></div>
</div>
<section id="support-contract-kpis-wrapper" hidden>
<h3 class="helpdesk-support-section-title"><?php e(t('Contracts & products')); ?></h3>
<div class="helpdesk-support-metrics" id="support-contract-kpis">
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-active-contracts"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-active-contracts-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-monthly-volume"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-monthly-volume-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Support hours')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-support-hours"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-support-hours-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Next invoicing')); ?></p>
<p class="helpdesk-support-metric-value" id="support-kpi-next-invoicing"></p>
<p class="helpdesk-support-metric-trend" id="support-kpi-next-invoicing-trend"></p>
</article>
</div>
</section>
<section id="support-recommendations-wrapper" hidden>
<h3 class="helpdesk-support-section-title"><?php e(t('Attention needed')); ?></h3>
<div id="support-system-recommendations"></div>
</section>
<section class="helpdesk-support-ticket-list">
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket list')); ?></h3>
@@ -149,32 +194,56 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
<!-- Sales panel -->
<div data-tab-panel="sales" hidden>
<div id="sales-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<div id="sales-content" hidden>
<div id="sales-loading">
<div class="helpdesk-support-metrics">
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-monthly-volume">0</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-monthly-volume-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-active-contracts">0</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-active-contracts-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Avg. contract value')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-avg-value">0</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-avg-value-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Next invoicing')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-next-invoicing">&mdash;</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-next-invoicing-trend"></p>
</article>
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="helpdesk-skeleton-kpi">
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
</div>
<?php endfor; ?>
</div>
<div class="helpdesk-skeleton-divider"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-line" style="width:30%;margin-bottom:0.75rem"></div>
<?php for ($i = 0; $i < 3; $i++): ?>
<div class="helpdesk-skeleton-row">
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
</div>
<?php endfor; ?>
</div>
<div id="sales-content" hidden>
<section>
<h3 class="helpdesk-support-section-title"><?php e(t('Contracts & products')); ?></h3>
<div class="helpdesk-support-metrics">
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Monthly volume')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-monthly-volume">0</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-monthly-volume-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Active contracts')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-active-contracts">0</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-active-contracts-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Avg. contract value')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-avg-value">0</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-avg-value-trend"></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Next invoicing')); ?></p>
<p class="helpdesk-support-metric-value" id="sales-kpi-next-invoicing">&mdash;</p>
<p class="helpdesk-support-metric-trend" id="sales-kpi-next-invoicing-trend"></p>
</article>
</div>
</section>
<div id="sales-contracts-detail"></div>
<section id="sales-trend-section">
<h3 class="helpdesk-support-section-title"><?php e(t('Contract trend')); ?></h3>
<div id="sales-trend-chart"></div>
</section>
<section id="sales-contacts-section">
<h3 class="helpdesk-support-section-title"><?php e(t('Contacts')); ?></h3>
@@ -193,8 +262,26 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
<!-- Controlling panel -->
<div data-tab-panel="controlling" hidden>
<div id="controlling-spinner" class="helpdesk-tab-spinner">
<div class="spinner"></div>
<div id="controlling-spinner">
<div class="helpdesk-support-metrics">
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="helpdesk-skeleton-kpi">
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-trend"></div>
</div>
<?php endfor; ?>
</div>
<div class="helpdesk-skeleton-divider"></div>
<div class="helpdesk-skeleton" style="height:10rem;margin-bottom:1rem"></div>
<div class="helpdesk-skeleton-divider"></div>
<?php for ($i = 0; $i < 3; $i++): ?>
<div class="helpdesk-skeleton-row">
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-cell"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
</div>
<?php endfor; ?>
</div>
<div id="controlling-content" hidden>
<div class="helpdesk-segment-control" id="controlling-period-selector">
@@ -237,12 +324,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
</div>
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Support efficiency')); ?></div>
<div id="controlling-efficiency"></div>
</div>
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Risk indicators')); ?></div>
<div class="app-stats-table-header"><?php e(t('Risk assessment')); ?></div>
<div id="controlling-risk-indicators"></div>
</div>
</div>
@@ -261,7 +343,12 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
<p><?php e(t('Last 5 tickets')); ?></p>
</hgroup>
<hr>
<div id="debitor-communication-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<div id="debitor-communication-loading">
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble helpdesk-skeleton-aside-bubble-short"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble helpdesk-skeleton-aside-bubble-alt"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble"></div>
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble helpdesk-skeleton-aside-bubble-alt" style="width:50%"></div>
</div>
<div id="debitor-communication-content" hidden>
<div id="debitor-communication-feed"></div>
</div>
@@ -340,9 +427,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
'Critical (>48h)' => t('Critical (>48h)'),
'Oldest open' => t('Oldest open'),
'Ticket list' => t('Ticket list'),
'System recommendations' => t('System recommendations'),
'Action' => t('Action'),
'Age (h)' => t('Age (h)'),
'Attention needed' => t('Attention needed'),
'No system recommendations right now.' => t('No system recommendations right now.'),
'helpdesk.recommendation.escalation_overdue' => t('Escalation SLA exceeded by {overdue_hours}h ({escalation_code}).'),
'helpdesk.recommendation.high_risk_aging' => t('High-risk ticket ({escalation_code}) is open for {age_hours}h.'),
@@ -353,21 +438,11 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
'No contracts found for this customer.' => t('No contracts found for this customer.'),
'Contract No.' => t('Contract No.'),
'Product type' => t('Product type'),
'Next invoicing' => t('Next invoicing'),
'Amount' => t('Amount'),
'Active contracts' => t('Active contracts'),
'Total contracts' => t('Total contracts'),
'Could not load contracts.' => t('Could not load contracts.'),
'Escalations' => t('Escalations'),
'Escalations are not available yet.' => t('Escalations are not available yet.'),
'Escalated tickets' => t('Escalated tickets'),
'No escalated tickets right now.' => t('No escalated tickets right now.'),
'Could not load escalated tickets.' => t('Could not load escalated tickets.'),
'Escalation code' => t('Escalation code'),
'SLA target' => t('SLA target'),
'Overdue by' => t('Overdue by'),
'day' => t('day'),
'days' => t('days'),
'Ticket volume' => t('Ticket volume'),
'Close rate' => t('Close rate'),
'Avg. resolution' => t('Avg. resolution'),
@@ -376,18 +451,23 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
'Backlog growing' => t('Backlog growing'),
'unassigned' => t('unassigned'),
'Ticket trend' => t('Ticket trend'),
'Support efficiency' => t('Support efficiency'),
'Risk indicators' => t('Risk indicators'),
'Risk assessment' => t('Risk assessment'),
'vs. prev. period' => t('vs. prev. period'),
'Low' => t('Low'),
'Medium' => t('Medium'),
'High' => t('High'),
'flags' => t('flags'),
'All checks passed' => t('All checks passed'),
'of' => t('of'),
'checks flagged' => t('checks flagged'),
'Limit' => t('Limit'),
'No controlling data available.' => t('No controlling data available.'),
'Ticket volume increase' => t('Ticket volume increase'),
'High avg. resolution time' => t('High avg. resolution time'),
'Open ticket aging' => t('Open ticket aging'),
'Unassigned ticket ratio' => t('Unassigned ticket ratio'),
'Resolution time' => t('Resolution time'),
'Oldest open ticket' => t('Oldest open ticket'),
'Unassigned' => t('Unassigned'),
'risk_desc_ticket_volume' => t('risk_desc_ticket_volume'),
'risk_desc_resolution_time' => t('risk_desc_resolution_time'),
'risk_desc_open_aging' => t('risk_desc_open_aging'),
'risk_desc_unassigned' => t('risk_desc_unassigned'),
'Could not load controlling dashboard.' => t('Could not load controlling dashboard.'),
'Without assignee' => t('Without assignee'),
'Created' => t('Created'),
@@ -426,7 +506,11 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
'Monthly volume' => t('Monthly volume'),
'Support hours' => t('Support hours'),
'Next invoicing' => t('Next invoicing'),
'Contract overview' => t('Contract overview'),
'Contract trend' => t('Contract trend'),
'New contracts' => t('New contracts'),
'Ended contracts' => t('Ended contracts'),
'active' => t('active'),
'ended' => t('ended'),
'No contract details available.' => t('No contract details available.'),
'Could not load sales dashboard.' => t('Could not load sales dashboard.'),
'Quantity' => t('Quantity'),
@@ -438,7 +522,6 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
'Salesperson' => t('Salesperson'),
'Contacts' => t('Contacts'),
'positions' => t('positions'),
'per month' => t('per month'),
'Last invoicing' => t('Last invoicing'),
'Close' => t('Close'),
'New (30d)' => t('New (30d)'),

View File

@@ -48,10 +48,22 @@ $cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
$cached = $sessionStore->get($cacheKey);
$allContacts = null;
$cacheUsed = false;
$serviceMeta = [
'source' => 'primary.company_name',
'fallback_used' => false,
'fallback_reason' => '',
];
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
$allContacts = $cached['contacts'] ?? [];
$cacheUsed = true;
if (is_array($cached['service_meta'] ?? null)) {
$serviceMeta = [
'source' => (string) ($cached['service_meta']['source'] ?? $serviceMeta['source']),
'fallback_used' => (bool) ($cached['service_meta']['fallback_used'] ?? false),
'fallback_reason' => (string) ($cached['service_meta']['fallback_reason'] ?? ''),
];
}
}
if ($allContacts === null) {
@@ -59,6 +71,7 @@ if ($allContacts === null) {
$result = $service->loadContacts($customerNo, $customerName);
if (!($result['ok'] ?? false)) {
$resultMeta = is_array($result['meta'] ?? null) ? $result['meta'] : [];
Router::json([
'data' => [],
'total' => 0,
@@ -67,6 +80,9 @@ if ($allContacts === null) {
'cache_used' => $cacheUsed,
'cache_bypassed' => $refreshRequested,
'cache_refreshed' => $refreshRequested,
'source' => (string) ($resultMeta['source'] ?? $serviceMeta['source']),
'fallback_used' => (bool) ($resultMeta['fallback_used'] ?? false),
'fallback_reason' => (string) ($resultMeta['fallback_reason'] ?? ''),
],
]);
@@ -74,9 +90,16 @@ if ($allContacts === null) {
}
$allContacts = $result['contacts'] ?? [];
$resultMeta = is_array($result['meta'] ?? null) ? $result['meta'] : [];
$serviceMeta = [
'source' => (string) ($resultMeta['source'] ?? $serviceMeta['source']),
'fallback_used' => (bool) ($resultMeta['fallback_used'] ?? false),
'fallback_reason' => (string) ($resultMeta['fallback_reason'] ?? ''),
];
$sessionStore->set($cacheKey, [
'contacts' => $allContacts,
'service_meta' => $serviceMeta,
'fetched_at' => time(),
]);
}
@@ -168,5 +191,8 @@ Router::json([
'cache_used' => $cacheUsed,
'cache_bypassed' => $refreshRequested,
'cache_refreshed' => $refreshRequested,
'source' => $serviceMeta['source'],
'fallback_used' => $serviceMeta['fallback_used'],
'fallback_reason' => $serviceMeta['fallback_reason'],
],
]);

View File

@@ -100,7 +100,6 @@ Router::json([
'ok' => true,
'kpis' => $dashboard['kpis'],
'trend' => $dashboard['trend'],
'efficiency' => $dashboard['efficiency'],
'risk_indicators' => $dashboard['risk_indicators'],
'meta' => array_merge($dashboard['meta'], [
'cache_used' => $ticketsCacheUsed,

View File

@@ -211,7 +211,7 @@ Router::json([
'contracts' => $contractsPayload['entries'],
'contracts_summary' => $contractsPayload['summary'],
'contracts_meta' => [
'available' => (bool) ($contractsPayload['ok'] ?? false),
'available' => $contractsPayload['ok'],
'source_cache_used' => $contractsSourceCacheUsed,
'cache_used' => $contractsSourceCacheUsed,
'cache_bypassed' => $refreshRequested,