feat(helpdesk): redesign team dashboard with agent cards and ticket details
Replace KPI tiles + table with visual agent cards showing workload at a glance. Each card has the agent name, ticket count badge (warning-colored when critical), a proportional load bar, and the actual open tickets listed underneath (number, description, age). Critical tickets are highlighted with warning color. Queue (unassigned) shown as dashed card at top. Performance tab uses compact cards with success-colored bars. Backend now returns open_ticket_details per member (sorted: critical first, then by age descending). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -296,5 +296,6 @@
|
|||||||
"Could not load team dashboard.": "Team-Dashboard konnte nicht geladen werden.",
|
"Could not load team dashboard.": "Team-Dashboard konnte nicht geladen werden.",
|
||||||
"No team data available.": "Keine Team-Daten verfügbar.",
|
"No team data available.": "Keine Team-Daten verfügbar.",
|
||||||
"Current": "Aktuell",
|
"Current": "Aktuell",
|
||||||
"Performance": "Leistung"
|
"Performance": "Leistung",
|
||||||
|
"Queue": "Warteschlange"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,5 +296,6 @@
|
|||||||
"Could not load team dashboard.": "Could not load team dashboard.",
|
"Could not load team dashboard.": "Could not load team dashboard.",
|
||||||
"No team data available.": "No team data available.",
|
"No team data available.": "No team data available.",
|
||||||
"Current": "Current",
|
"Current": "Current",
|
||||||
"Performance": "Performance"
|
"Performance": "Performance",
|
||||||
|
"Queue": "Queue"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1593,7 +1593,7 @@ class DebitorDetailService
|
|||||||
$periodStart = $nowTs - ($periodDays * 86400);
|
$periodStart = $nowTs - ($periodDays * 86400);
|
||||||
$criticalThresholdHours = 48;
|
$criticalThresholdHours = 48;
|
||||||
|
|
||||||
/** @var array<string, array{display_name: string, support_user: string, open_tickets: int, critical_tickets: int, age_hours_sum: int, resolved_in_period: int}> $agents */
|
/** @var array<string, array{display_name: string, support_user: string, open_tickets: int, critical_tickets: int, age_hours_sum: int, resolved_in_period: int, open_ticket_details: list<array{no: string, description: string, age_hours: int, critical: bool, customer: string}>}> $agents */
|
||||||
$agents = [];
|
$agents = [];
|
||||||
$globalOpen = 0;
|
$globalOpen = 0;
|
||||||
$globalUnassigned = 0;
|
$globalUnassigned = 0;
|
||||||
@@ -1619,6 +1619,7 @@ class DebitorDetailService
|
|||||||
'critical_tickets' => 0,
|
'critical_tickets' => 0,
|
||||||
'age_hours_sum' => 0,
|
'age_hours_sum' => 0,
|
||||||
'resolved_in_period' => 0,
|
'resolved_in_period' => 0,
|
||||||
|
'open_ticket_details' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,17 +1636,28 @@ class DebitorDetailService
|
|||||||
$globalUnassigned++;
|
$globalUnassigned++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ageHours = 0;
|
||||||
|
$isCritical = false;
|
||||||
if ($activityTs !== null) {
|
if ($activityTs !== null) {
|
||||||
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
|
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
|
||||||
$agents[$normalizedUser]['age_hours_sum'] += $ageHours;
|
$agents[$normalizedUser]['age_hours_sum'] += $ageHours;
|
||||||
|
|
||||||
if ($ageHours > $criticalThresholdHours) {
|
if ($ageHours > $criticalThresholdHours) {
|
||||||
$agents[$normalizedUser]['critical_tickets']++;
|
$agents[$normalizedUser]['critical_tickets']++;
|
||||||
|
$isCritical = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$globalAgeSum += $ageHours;
|
$globalAgeSum += $ageHours;
|
||||||
$globalOpenCount++;
|
$globalOpenCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$agents[$normalizedUser]['open_ticket_details'][] = [
|
||||||
|
'no' => trim((string) ($ticket['No'] ?? '')),
|
||||||
|
'description' => trim((string) ($ticket['Description'] ?? '')),
|
||||||
|
'age_hours' => $ageHours,
|
||||||
|
'critical' => $isCritical,
|
||||||
|
'customer' => trim((string) ($ticket['Customer_No'] ?? '')),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1655,6 +1667,16 @@ class DebitorDetailService
|
|||||||
? (int) round($agent['age_hours_sum'] / $agent['open_tickets'])
|
? (int) round($agent['age_hours_sum'] / $agent['open_tickets'])
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
// Sort ticket details: critical first, then by age descending
|
||||||
|
$details = $agent['open_ticket_details'];
|
||||||
|
usort($details, static function (array $a, array $b): int {
|
||||||
|
if ($a['critical'] !== $b['critical']) {
|
||||||
|
return $b['critical'] <=> $a['critical'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $b['age_hours'] <=> $a['age_hours'];
|
||||||
|
});
|
||||||
|
|
||||||
$members[] = [
|
$members[] = [
|
||||||
'support_user' => $agent['support_user'],
|
'support_user' => $agent['support_user'],
|
||||||
'display_name' => $agent['display_name'],
|
'display_name' => $agent['display_name'],
|
||||||
@@ -1662,6 +1684,7 @@ class DebitorDetailService
|
|||||||
'critical_tickets' => $agent['critical_tickets'],
|
'critical_tickets' => $agent['critical_tickets'],
|
||||||
'avg_age_hours' => $avgAge,
|
'avg_age_hours' => $avgAge,
|
||||||
'resolved_in_period' => $agent['resolved_in_period'],
|
'resolved_in_period' => $agent['resolved_in_period'],
|
||||||
|
'open_ticket_details' => $details,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Team workload dashboard — shows support agent metrics aggregated from BC tickets.
|
* Team workload dashboard — visual workload map for support team leads.
|
||||||
* Tab 1 "Current": snapshot of open tickets per agent (no period filter).
|
* Tab "Current": agent cards with open tickets, queue at top.
|
||||||
* Tab 2 "Performance": resolved tickets per agent in a selectable period.
|
* Tab "Performance": resolved per agent in a selectable period.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$periods = [30, 90, 180, 365];
|
$periods = [30, 90, 180, 365];
|
||||||
@@ -17,6 +17,8 @@ $periodLabels = [
|
|||||||
<div class="app-details-container"
|
<div class="app-details-container"
|
||||||
data-team-workload-url="<?php e(lurl('helpdesk/team-workload-data')); ?>"
|
data-team-workload-url="<?php e(lurl('helpdesk/team-workload-data')); ?>"
|
||||||
data-label-not-assigned="<?php e(t('Not assigned')); ?>"
|
data-label-not-assigned="<?php e(t('Not assigned')); ?>"
|
||||||
|
data-label-queue="<?php e(t('Queue')); ?>"
|
||||||
|
data-label-tickets="<?php e(t('Tickets')); ?>"
|
||||||
data-label-error="<?php e(t('Could not load team dashboard.')); ?>"
|
data-label-error="<?php e(t('Could not load team dashboard.')); ?>"
|
||||||
data-label-empty="<?php e(t('No team data available.')); ?>"
|
data-label-empty="<?php e(t('No team data available.')); ?>"
|
||||||
>
|
>
|
||||||
@@ -41,21 +43,8 @@ $periodLabels = [
|
|||||||
|
|
||||||
<div class="helpdesk-team-dashboard">
|
<div class="helpdesk-team-dashboard">
|
||||||
<div id="team-loading" aria-busy="true">
|
<div id="team-loading" aria-busy="true">
|
||||||
<div class="helpdesk-support-metrics">
|
<?php for ($i = 0; $i < 3; $i++): ?>
|
||||||
<?php for ($i = 0; $i < 3; $i++): ?>
|
<div class="helpdesk-skeleton" style="height: 5rem; margin-bottom: 0.75rem; border-radius: var(--app-radius, 0.375rem);"></div>
|
||||||
<div class="helpdesk-skeleton-kpi">
|
|
||||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-label"></div>
|
|
||||||
<div class="helpdesk-skeleton helpdesk-skeleton-kpi-value"></div>
|
|
||||||
</div>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</div>
|
|
||||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
|
||||||
<div class="helpdesk-skeleton-row">
|
|
||||||
<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 class="helpdesk-skeleton helpdesk-skeleton-cell helpdesk-skeleton-cell-narrow"></div>
|
|
||||||
</div>
|
|
||||||
<?php endfor; ?>
|
<?php endfor; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,33 +64,7 @@ $periodLabels = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tab-panel="current">
|
<div data-tab-panel="current">
|
||||||
<div class="helpdesk-support-metrics" id="team-kpis-current">
|
<div id="team-current-cards"></div>
|
||||||
<article class="helpdesk-support-metric">
|
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('Open tickets')); ?></p>
|
|
||||||
<p class="helpdesk-support-metric-value" id="team-kpi-open">—</p>
|
|
||||||
</article>
|
|
||||||
<article class="helpdesk-support-metric">
|
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('Unassigned')); ?></p>
|
|
||||||
<p class="helpdesk-support-metric-value" id="team-kpi-unassigned">—</p>
|
|
||||||
</article>
|
|
||||||
<article class="helpdesk-support-metric">
|
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('Avg. age (h)')); ?></p>
|
|
||||||
<p class="helpdesk-support-metric-value" id="team-kpi-avg-age">—</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="helpdesk-team-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col"><?php e(t('Support agent')); ?></th>
|
|
||||||
<th scope="col"><?php e(t('Open')); ?></th>
|
|
||||||
<th scope="col"><?php e(t('Critical (>48h open)')); ?></th>
|
|
||||||
<th scope="col"><?php e(t('Avg. age (h)')); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="team-table-current">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tab-panel="performance" class="helpdesk-team-performance-panel">
|
<div data-tab-panel="performance" class="helpdesk-team-performance-panel">
|
||||||
@@ -112,23 +75,7 @@ $periodLabels = [
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="helpdesk-support-metrics" id="team-kpis-performance">
|
<div id="team-performance-cards"></div>
|
||||||
<article class="helpdesk-support-metric">
|
|
||||||
<p class="helpdesk-support-metric-label"><?php e(t('Resolved in period')); ?></p>
|
|
||||||
<p class="helpdesk-support-metric-value" id="team-kpi-resolved">—</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="helpdesk-team-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col"><?php e(t('Support agent')); ?></th>
|
|
||||||
<th scope="col"><?php e(t('Resolved in period')); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="team-table-performance">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -126,114 +126,141 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Team workload dashboard */
|
/* Team workload dashboard — agent cards */
|
||||||
.helpdesk-team-table {
|
.helpdesk-team-card {
|
||||||
width: 100%;
|
border: 1px solid var(--app-muted-border-color);
|
||||||
margin-top: calc(var(--app-spacing) * 0.75);
|
border-radius: var(--app-radius, 0.375rem);
|
||||||
border-collapse: separate;
|
padding: calc(var(--app-spacing) * 0.65) calc(var(--app-spacing) * 0.75);
|
||||||
border-spacing: 0;
|
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.helpdesk-team-table th {
|
.helpdesk-team-card-queue {
|
||||||
text-align: left;
|
border-style: dashed;
|
||||||
padding: calc(var(--app-spacing) * 0.3) calc(var(--app-spacing) * 0.5);
|
background: color-mix(in srgb, var(--app-muted-border-color) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: calc(var(--app-spacing) * 0.5);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-name {
|
||||||
|
font-weight: 600;
|
||||||
font-size: var(--text-sm, 0.875rem);
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-queue .helpdesk-team-card-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--app-muted-color);
|
color: var(--app-muted-color);
|
||||||
border-bottom: 1px solid var(--app-muted-border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.helpdesk-team-table td {
|
.helpdesk-team-card-count {
|
||||||
text-align: left;
|
font-variant-numeric: tabular-nums;
|
||||||
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.5);
|
font-weight: 600;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--app-muted-border-color) 50%, transparent);
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
padding: 0.1em 0.55em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--app-primary, #0d6efd) 12%, transparent);
|
||||||
|
color: var(--app-primary, #0d6efd);
|
||||||
}
|
}
|
||||||
|
|
||||||
.helpdesk-team-table th:not(:first-child),
|
.helpdesk-team-card-count-warning {
|
||||||
.helpdesk-team-table td:not(:first-child) {
|
background: color-mix(in srgb, var(--app-warning, #f59e0b) 15%, transparent);
|
||||||
text-align: right;
|
color: var(--app-warning, #f59e0b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.helpdesk-team-table tbody tr:last-child td {
|
.helpdesk-team-card-count-success {
|
||||||
border-bottom: none;
|
background: color-mix(in srgb, var(--app-success, #198754) 12%, transparent);
|
||||||
|
color: var(--app-success, #198754);
|
||||||
}
|
}
|
||||||
|
|
||||||
.helpdesk-team-agent-name {
|
.helpdesk-team-card-bar-track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--app-muted-border-color) 60%, transparent);
|
||||||
|
margin-bottom: calc(var(--app-spacing) * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--app-primary, #0d6efd);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-bar-muted {
|
||||||
|
background: var(--app-muted-color);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-bar-success {
|
||||||
|
background: var(--app-success, #198754);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-tickets {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-ticket {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: calc(var(--app-spacing) * 0.4);
|
||||||
|
padding: calc(var(--app-spacing) * 0.2) 0;
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--app-muted-border-color) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-ticket:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-ticket-no {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
.helpdesk-team-row-unassigned {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpdesk-team-row-unassigned .helpdesk-team-agent-name {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--app-muted-color);
|
color: var(--app-muted-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-ticket-desc {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-ticket-age {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--app-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-ticket-critical .helpdesk-team-card-ticket-age {
|
||||||
|
color: var(--app-warning, #f59e0b);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-ticket-critical .helpdesk-team-card-ticket-no {
|
||||||
|
color: var(--app-warning, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-compact {
|
||||||
|
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpdesk-team-card-compact .helpdesk-team-card-bar-track {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.helpdesk-team-performance-panel > .helpdesk-segment-control {
|
.helpdesk-team-performance-panel > .helpdesk-segment-control {
|
||||||
margin-bottom: calc(var(--app-spacing) * 0.75);
|
margin-bottom: calc(var(--app-spacing) * 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Muted zero values — de-emphasize non-information */
|
|
||||||
.helpdesk-team-muted {
|
|
||||||
color: var(--app-muted-color);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline proportional bar — Stripe-style visual comparison */
|
|
||||||
.helpdesk-team-bar-cell {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: calc(var(--app-spacing) * 0.35);
|
|
||||||
min-width: 5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpdesk-team-bar {
|
|
||||||
height: 0.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 2px;
|
|
||||||
width: 0;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpdesk-team-bar-primary {
|
|
||||||
background: var(--app-primary, #0d6efd);
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpdesk-team-bar-success {
|
|
||||||
background: var(--app-success, #198754);
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpdesk-team-bar-muted {
|
|
||||||
background: var(--app-muted-color);
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpdesk-team-bar-value {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
min-width: 2ch;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Critical badge — draws attention to non-zero values */
|
|
||||||
.helpdesk-team-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 1.5em;
|
|
||||||
padding: 0.1em 0.45em;
|
|
||||||
font-size: var(--text-sm, 0.875rem);
|
|
||||||
font-weight: 600;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.3;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: color-mix(in srgb, var(--app-warning, #f59e0b) 15%, transparent);
|
|
||||||
color: var(--app-warning, #f59e0b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clickable rows — shared between search results and ticket list */
|
/* Clickable rows — shared between search results and ticket list */
|
||||||
.app-clickable-row {
|
.app-clickable-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Team workload dashboard — fetches and renders team metrics.
|
* Team workload dashboard — visual workload map.
|
||||||
* Tab "Current": snapshot of open tickets per agent.
|
* Current tab: agent cards with load bars and ticket details.
|
||||||
* Tab "Performance": resolved tickets per agent in a selectable period.
|
* Performance tab: resolved-count cards per agent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const container = document.querySelector('.app-details-container[data-team-workload-url]');
|
const container = document.querySelector('.app-details-container[data-team-workload-url]');
|
||||||
if (container) {
|
if (container) {
|
||||||
const dataUrl = container.dataset.teamWorkloadUrl;
|
const dataUrl = container.dataset.teamWorkloadUrl;
|
||||||
const labelNotAssigned = container.dataset.labelNotAssigned || '—';
|
const labelNotAssigned = container.dataset.labelNotAssigned || '—';
|
||||||
|
const labelQueue = container.dataset.labelQueue || 'Queue';
|
||||||
|
const labelTickets = container.dataset.labelTickets || 'Tickets';
|
||||||
|
|
||||||
const elLoading = document.getElementById('team-loading');
|
const elLoading = document.getElementById('team-loading');
|
||||||
const elError = document.getElementById('team-error');
|
const elError = document.getElementById('team-error');
|
||||||
const elEmpty = document.getElementById('team-empty');
|
const elEmpty = document.getElementById('team-empty');
|
||||||
const elContent = document.getElementById('team-content');
|
const elContent = document.getElementById('team-content');
|
||||||
const elCurrentBody = document.getElementById('team-table-current');
|
const elCurrentCards = document.getElementById('team-current-cards');
|
||||||
const elPerformanceBody = document.getElementById('team-table-performance');
|
const elPerformanceCards = document.getElementById('team-performance-cards');
|
||||||
const elRefresh = container.querySelector('button[name="team-refresh"]');
|
const elRefresh = container.querySelector('button[name="team-refresh"]');
|
||||||
const periodSelector = document.getElementById('team-period-selector');
|
const periodSelector = document.getElementById('team-period-selector');
|
||||||
|
|
||||||
@@ -28,110 +30,113 @@ if (container) {
|
|||||||
if (elLoading) elLoading.setAttribute('aria-busy', state === 'loading' ? 'true' : 'false');
|
if (elLoading) elLoading.setAttribute('aria-busy', state === 'loading' ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setText(id, value) {
|
function el(tag, className, text) {
|
||||||
const el = document.getElementById(id);
|
const e = document.createElement(tag);
|
||||||
if (el) el.textContent = value;
|
if (className) e.className = className;
|
||||||
|
if (text !== undefined) e.textContent = text;
|
||||||
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentName(m) {
|
function formatAge(hours) {
|
||||||
return m.support_user === '' ? labelNotAssigned : m.display_name;
|
if (hours < 24) return hours + 'h';
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return days + 'd';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a table cell with an inline proportional bar behind the value.
|
* Build a single agent card for the Current tab.
|
||||||
* Stripe-style: the bar gives instant visual comparison across rows.
|
|
||||||
*/
|
*/
|
||||||
function barCell(value, max, variant) {
|
function buildAgentCard(m, maxOpen) {
|
||||||
const td = document.createElement('td');
|
const isQueue = m.support_user === '';
|
||||||
const pct = max > 0 ? Math.round((value / max) * 100) : 0;
|
const name = isQueue ? labelQueue : m.display_name;
|
||||||
|
const pct = maxOpen > 0 ? Math.round((m.open_tickets / maxOpen) * 100) : 0;
|
||||||
|
|
||||||
const wrap = document.createElement('span');
|
const card = el('article', 'helpdesk-team-card');
|
||||||
wrap.className = 'helpdesk-team-bar-cell';
|
if (isQueue) card.classList.add('helpdesk-team-card-queue');
|
||||||
|
|
||||||
const bar = document.createElement('span');
|
// Header row: name + count badge
|
||||||
bar.className = 'helpdesk-team-bar';
|
const header = el('div', 'helpdesk-team-card-header');
|
||||||
if (variant) bar.classList.add('helpdesk-team-bar-' + variant);
|
const nameEl = el('span', 'helpdesk-team-card-name', name);
|
||||||
bar.style.width = pct + '%';
|
const countEl = el('span', 'helpdesk-team-card-count', String(m.open_tickets));
|
||||||
|
if (m.critical_tickets > 0) countEl.classList.add('helpdesk-team-card-count-warning');
|
||||||
|
header.appendChild(nameEl);
|
||||||
|
header.appendChild(countEl);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
const label = document.createElement('span');
|
// Load bar
|
||||||
label.className = 'helpdesk-team-bar-value';
|
const barTrack = el('div', 'helpdesk-team-card-bar-track');
|
||||||
label.textContent = String(value);
|
const barFill = el('div', 'helpdesk-team-card-bar-fill');
|
||||||
if (value === 0) label.classList.add('helpdesk-team-muted');
|
if (isQueue) barFill.classList.add('helpdesk-team-card-bar-muted');
|
||||||
|
barFill.style.width = pct + '%';
|
||||||
|
barTrack.appendChild(barFill);
|
||||||
|
card.appendChild(barTrack);
|
||||||
|
|
||||||
wrap.appendChild(bar);
|
// Ticket list
|
||||||
wrap.appendChild(label);
|
const tickets = m.open_ticket_details || [];
|
||||||
td.appendChild(wrap);
|
if (tickets.length > 0) {
|
||||||
return td;
|
const list = el('ul', 'helpdesk-team-card-tickets');
|
||||||
}
|
for (const t of tickets) {
|
||||||
|
const li = el('li', 'helpdesk-team-card-ticket');
|
||||||
|
if (t.critical) li.classList.add('helpdesk-team-card-ticket-critical');
|
||||||
|
|
||||||
/**
|
const no = el('span', 'helpdesk-team-card-ticket-no', t.no);
|
||||||
* Create a badge cell — small pill for critical counts.
|
const desc = el('span', 'helpdesk-team-card-ticket-desc', t.description || '—');
|
||||||
* 0 = muted text, >0 = warning badge.
|
const age = el('span', 'helpdesk-team-card-ticket-age', formatAge(t.age_hours));
|
||||||
*/
|
|
||||||
function badgeCell(value) {
|
li.appendChild(no);
|
||||||
const td = document.createElement('td');
|
li.appendChild(desc);
|
||||||
if (value === 0) {
|
li.appendChild(age);
|
||||||
td.textContent = '0';
|
list.appendChild(li);
|
||||||
td.classList.add('helpdesk-team-muted');
|
}
|
||||||
} else {
|
card.appendChild(list);
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = 'helpdesk-team-badge';
|
|
||||||
badge.textContent = String(value);
|
|
||||||
td.appendChild(badge);
|
|
||||||
}
|
}
|
||||||
return td;
|
|
||||||
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a plain text cell, muted when value is 0.
|
* Build a single agent card for the Performance tab.
|
||||||
*/
|
*/
|
||||||
function valueCell(value, unit) {
|
function buildPerformanceCard(m, maxResolved) {
|
||||||
const td = document.createElement('td');
|
const isQueue = m.support_user === '';
|
||||||
td.textContent = unit ? value + unit : String(value);
|
const name = isQueue ? labelQueue : m.display_name;
|
||||||
if (value === 0) td.classList.add('helpdesk-team-muted');
|
const pct = maxResolved > 0 ? Math.round((m.resolved_in_period / maxResolved) * 100) : 0;
|
||||||
return td;
|
|
||||||
|
const card = el('article', 'helpdesk-team-card helpdesk-team-card-compact');
|
||||||
|
if (isQueue) card.classList.add('helpdesk-team-card-queue');
|
||||||
|
|
||||||
|
const header = el('div', 'helpdesk-team-card-header');
|
||||||
|
header.appendChild(el('span', 'helpdesk-team-card-name', name));
|
||||||
|
header.appendChild(el('span', 'helpdesk-team-card-count helpdesk-team-card-count-success', String(m.resolved_in_period)));
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
const barTrack = el('div', 'helpdesk-team-card-bar-track');
|
||||||
|
const barFill = el('div', 'helpdesk-team-card-bar-fill helpdesk-team-card-bar-success');
|
||||||
|
barFill.style.width = pct + '%';
|
||||||
|
barTrack.appendChild(barFill);
|
||||||
|
card.appendChild(barTrack);
|
||||||
|
|
||||||
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCurrentTab(kpis, members) {
|
function renderCurrentTab(members) {
|
||||||
setText('team-kpi-open', kpis.open_tickets ?? 0);
|
if (!elCurrentCards) return;
|
||||||
setText('team-kpi-unassigned', kpis.unassigned ?? 0);
|
elCurrentCards.replaceChildren();
|
||||||
setText('team-kpi-avg-age', kpis.avg_age_hours ?? 0);
|
|
||||||
|
|
||||||
if (!elCurrentBody) return;
|
|
||||||
elCurrentBody.replaceChildren();
|
|
||||||
|
|
||||||
const visible = members.filter(m => m.open_tickets > 0 || m.critical_tickets > 0);
|
const visible = members.filter(m => m.open_tickets > 0 || m.critical_tickets > 0);
|
||||||
const maxOpen = Math.max(1, ...visible.map(m => m.open_tickets));
|
const maxOpen = Math.max(1, ...visible.map(m => m.open_tickets));
|
||||||
|
|
||||||
for (const m of visible) {
|
// Queue (unassigned) first if present
|
||||||
const tr = document.createElement('tr');
|
const queue = visible.find(m => m.support_user === '');
|
||||||
const isUnassigned = m.support_user === '';
|
const agents = visible.filter(m => m.support_user !== '');
|
||||||
|
|
||||||
// Agent name
|
if (queue) elCurrentCards.appendChild(buildAgentCard(queue, maxOpen));
|
||||||
const nameCell = document.createElement('td');
|
for (const m of agents) elCurrentCards.appendChild(buildAgentCard(m, maxOpen));
|
||||||
nameCell.className = 'helpdesk-team-agent-name';
|
|
||||||
nameCell.textContent = agentName(m);
|
|
||||||
tr.appendChild(nameCell);
|
|
||||||
|
|
||||||
// Open tickets with proportional bar
|
|
||||||
tr.appendChild(barCell(m.open_tickets, maxOpen, isUnassigned ? 'muted' : 'primary'));
|
|
||||||
|
|
||||||
// Critical with badge
|
|
||||||
tr.appendChild(badgeCell(m.critical_tickets));
|
|
||||||
|
|
||||||
// Avg age
|
|
||||||
tr.appendChild(valueCell(m.avg_age_hours, 'h'));
|
|
||||||
|
|
||||||
if (isUnassigned) tr.classList.add('helpdesk-team-row-unassigned');
|
|
||||||
elCurrentBody.appendChild(tr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPerformanceTab(kpis, members) {
|
function renderPerformanceTab(members) {
|
||||||
setText('team-kpi-resolved', kpis.resolved_in_period ?? 0);
|
if (!elPerformanceCards) return;
|
||||||
|
elPerformanceCards.replaceChildren();
|
||||||
if (!elPerformanceBody) return;
|
|
||||||
elPerformanceBody.replaceChildren();
|
|
||||||
|
|
||||||
const sorted = [...members]
|
const sorted = [...members]
|
||||||
.filter(m => m.resolved_in_period > 0)
|
.filter(m => m.resolved_in_period > 0)
|
||||||
@@ -142,26 +147,7 @@ if (container) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const maxResolved = Math.max(1, ...sorted.map(m => m.resolved_in_period));
|
const maxResolved = Math.max(1, ...sorted.map(m => m.resolved_in_period));
|
||||||
|
for (const m of sorted) elPerformanceCards.appendChild(buildPerformanceCard(m, maxResolved));
|
||||||
for (const m of sorted) {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
const isUnassigned = m.support_user === '';
|
|
||||||
|
|
||||||
const nameCell = document.createElement('td');
|
|
||||||
nameCell.className = 'helpdesk-team-agent-name';
|
|
||||||
nameCell.textContent = agentName(m);
|
|
||||||
tr.appendChild(nameCell);
|
|
||||||
|
|
||||||
tr.appendChild(barCell(m.resolved_in_period, maxResolved, isUnassigned ? 'muted' : 'success'));
|
|
||||||
|
|
||||||
if (isUnassigned) tr.classList.add('helpdesk-team-row-unassigned');
|
|
||||||
elPerformanceBody.appendChild(tr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAll(data) {
|
|
||||||
renderCurrentTab(data.kpis, data.members);
|
|
||||||
renderPerformanceTab(data.kpis, data.members);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchData(refresh = false) {
|
async function fetchData(refresh = false) {
|
||||||
@@ -184,7 +170,8 @@ if (container) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAll(json);
|
renderCurrentTab(json.members);
|
||||||
|
renderPerformanceTab(json.members);
|
||||||
showState('success');
|
showState('success');
|
||||||
} catch {
|
} catch {
|
||||||
showState('error');
|
showState('error');
|
||||||
|
|||||||
Reference in New Issue
Block a user