feat: trend dots on stat cards, team workload panel, stat model improvement

- Dashboard stat cards now show lt-dot trend indicators (up/warn/idle) based on
  created_today vs closed_today flow — no extra DB query needed
- Add collapsible Team Workload panel showing assignee open ticket counts with
  progress bars (green/cyan/red by load), avatar, and name
- StatsModel.getTicketsByAssignee() now returns proper objects with user_id,
  display_name, open_count (was name-keyed flat array); limit raised to 8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:04:41 -04:00
parent fca4896e0d
commit 0d8edc9d34
3 changed files with 107 additions and 11 deletions
+17
View File
@@ -324,6 +324,23 @@ kbd {
.lt-stats-grid { grid-template-columns: 1fr; }
}
/* ── Team Workload Panel ─────────────────────────────────────── */
.workload-panel summary { user-select: none; }
.workload-panel[open] summary span:first-child { display: inline-block; transform: rotate(90deg); }
.workload-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem;
}
.workload-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.workload-info { flex: 1; min-width: 0; }
.workload-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.workload-count { min-width: 1.5rem; text-align: right; flex-shrink: 0; }
/* Loading overlay — wraps lt-spinner for element-level loading states */
.has-lt-overlay { position: relative; }
.lt-loading-overlay {
+11 -6
View File
@@ -25,16 +25,17 @@ class StatsModel {
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee(int $limit = 5): array {
public function getTicketsByAssignee(int $limit = 8): array {
$sql = "SELECT
u.user_id,
u.display_name,
u.username,
COUNT(t.ticket_id) as ticket_count
COUNT(t.ticket_id) as open_count
FROM tickets t
LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed'
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL
GROUP BY t.assigned_to
ORDER BY ticket_count DESC
ORDER BY open_count DESC
LIMIT ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
@@ -42,8 +43,12 @@ class StatsModel {
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) {
$name = $row['display_name'] ?: $row['username'];
$data[$name] = (int)$row['ticket_count'];
$data[] = [
'user_id' => (int)$row['user_id'],
'display_name' => $row['display_name'] ?: $row['username'],
'username' => $row['username'],
'open_count' => (int)$row['open_count'],
];
}
$stmt->close();
return $data;
+79 -5
View File
@@ -82,12 +82,26 @@ include __DIR__ . '/layout_header.php';
<?php if (isset($stats)): ?>
<div class="lt-stats-grid" id="statsGrid">
<?php
// Trend indicators — derived from existing stats without extra DB query
// Logic: if more closed today than created → improving (green), if more created → warn, else idle
$trendOpen = ($stats['closed_today'] > $stats['created_today']) ? 'lt-dot-up' :
($stats['created_today'] > $stats['closed_today'] ? 'lt-dot-warn' : 'lt-dot-idle');
$trendCrit = ($stats['critical'] > 0) ? 'lt-dot-warn' : 'lt-dot-up';
$trendUnassi = ($stats['unassigned'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
$trendToday = ($stats['created_today'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
$trendClosed = ($stats['closed_today'] > 0) ? 'lt-dot-up' : 'lt-dot-idle';
?>
<div class="lt-stat-card stat-open" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
title="Click to filter by active tickets" aria-label="Open tickets">
<div class="lt-stat-icon">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= (int)$stats['open_tickets'] ?></div>
<div class="lt-stat-value">
<?= (int)$stats['open_tickets'] ?>
<span class="lt-dot <?= $trendOpen ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Open Tickets</div>
</div>
</div>
@@ -97,7 +111,10 @@ include __DIR__ . '/layout_header.php';
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= (int)$stats['critical'] ?></div>
<div class="lt-stat-value">
<?= (int)$stats['critical'] ?>
<span class="lt-dot <?= $trendCrit ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Critical (P1)</div>
</div>
</div>
@@ -107,7 +124,10 @@ include __DIR__ . '/layout_header.php';
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= (int)$stats['unassigned'] ?></div>
<div class="lt-stat-value">
<?= (int)$stats['unassigned'] ?>
<span class="lt-dot <?= $trendUnassi ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Unassigned</div>
</div>
</div>
@@ -116,7 +136,10 @@ include __DIR__ . '/layout_header.php';
title="Tickets created today" aria-label="Tickets created today">
<div class="lt-stat-icon lt-text-cyan">[ + ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= (int)$stats['created_today'] ?></div>
<div class="lt-stat-value">
<?= (int)$stats['created_today'] ?>
<span class="lt-dot <?= $trendToday ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Created Today</div>
</div>
</div>
@@ -126,7 +149,10 @@ include __DIR__ . '/layout_header.php';
title="Click to filter closed tickets" aria-label="Closed tickets today">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= (int)$stats['closed_today'] ?></div>
<div class="lt-stat-value">
<?= (int)$stats['closed_today'] ?>
<span class="lt-dot <?= $trendClosed ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Closed Today</div>
</div>
</div>
@@ -142,6 +168,54 @@ include __DIR__ . '/layout_header.php';
</div>
<?php endif ?>
<?php if (!empty($stats['by_assignee'])): ?>
<!-- ═══════════════════════════════════════════════════════════
TEAM WORKLOAD PANEL
═══════════════════════════════════════════════════════════ -->
<details class="lt-frame workload-panel" style="margin-bottom:0.75rem" id="workloadPanel">
<summary class="lt-section-header" style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:0.5rem">
<span>&#x25B8;</span> Team Workload
<span class="lt-text-xs lt-text-muted" style="font-weight:normal">&mdash; open tickets by assignee</span>
</summary>
<div class="lt-section-body">
<?php
$byAssignee = $stats['by_assignee'];
$maxLoad = max(array_column($byAssignee, 'open_count') ?: [1]);
?>
<div class="workload-grid">
<?php foreach ($byAssignee as $a):
$count = (int)$a['open_count'];
$name = $a['display_name'] ?? $a['username'] ?? 'Unknown';
$pct = $maxLoad > 0 ? round(($count / $maxLoad) * 100) : 0;
$barClass = $pct >= 80 ? 'lt-progress--red' : ($pct >= 50 ? 'lt-progress--cyan' : 'lt-progress--green');
$words = array_filter(explode(' ', $name));
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$avatarColor = $avatarColors[abs(crc32($name)) % count($avatarColors)];
$userId = (int)($a['user_id'] ?? 0);
?>
<div class="workload-item">
<div class="lt-avatar lt-avatar--sm <?= $avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($name) ?>">
<?php if ($userId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $userId ?>" alt="" class="lt-avatar-img" onerror="this.style.display='none'">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div>
<div class="workload-info">
<div class="workload-name lt-text-xs"><?= htmlspecialchars($name) ?></div>
<div class="lt-progress lt-progress--sm <?= $barClass ?>" style="margin:0.2rem 0"
aria-label="<?= $count ?> open tickets" title="<?= $count ?> open">
<div class="lt-progress-bar" style="width:<?= max(4, $pct) ?>%"></div>
</div>
</div>
<span class="lt-text-xs lt-text-muted workload-count"><?= $count ?></span>
</div>
<?php endforeach ?>
</div>
</div>
</details>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
VIEW TABS (Table / Kanban) — dual-purpose: lt.tabs + dashboard.js set-view-mode
═══════════════════════════════════════════════════════════ -->