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:
@@ -324,6 +324,23 @@ kbd {
|
|||||||
.lt-stats-grid { grid-template-columns: 1fr; }
|
.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 */
|
/* Loading overlay — wraps lt-spinner for element-level loading states */
|
||||||
.has-lt-overlay { position: relative; }
|
.has-lt-overlay { position: relative; }
|
||||||
.lt-loading-overlay {
|
.lt-loading-overlay {
|
||||||
|
|||||||
+11
-6
@@ -25,16 +25,17 @@ class StatsModel {
|
|||||||
/**
|
/**
|
||||||
* Get tickets by assignee (top 5)
|
* Get tickets by assignee (top 5)
|
||||||
*/
|
*/
|
||||||
public function getTicketsByAssignee(int $limit = 5): array {
|
public function getTicketsByAssignee(int $limit = 8): array {
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
|
u.user_id,
|
||||||
u.display_name,
|
u.display_name,
|
||||||
u.username,
|
u.username,
|
||||||
COUNT(t.ticket_id) as ticket_count
|
COUNT(t.ticket_id) as open_count
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
LEFT JOIN users u ON t.assigned_to = u.user_id
|
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
|
GROUP BY t.assigned_to
|
||||||
ORDER BY ticket_count DESC
|
ORDER BY open_count DESC
|
||||||
LIMIT ?";
|
LIMIT ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $limit);
|
$stmt->bind_param('i', $limit);
|
||||||
@@ -42,8 +43,12 @@ class StatsModel {
|
|||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$data = [];
|
$data = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$name = $row['display_name'] ?: $row['username'];
|
$data[] = [
|
||||||
$data[$name] = (int)$row['ticket_count'];
|
'user_id' => (int)$row['user_id'],
|
||||||
|
'display_name' => $row['display_name'] ?: $row['username'],
|
||||||
|
'username' => $row['username'],
|
||||||
|
'open_count' => (int)$row['open_count'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $data;
|
return $data;
|
||||||
|
|||||||
+79
-5
@@ -82,12 +82,26 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<?php if (isset($stats)): ?>
|
<?php if (isset($stats)): ?>
|
||||||
<div class="lt-stats-grid" id="statsGrid">
|
<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"
|
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
||||||
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
|
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
|
||||||
title="Click to filter by active tickets" aria-label="Open tickets">
|
title="Click to filter by active tickets" aria-label="Open tickets">
|
||||||
<div class="lt-stat-icon">[ # ]</div>
|
<div class="lt-stat-icon">[ # ]</div>
|
||||||
<div class="lt-stat-info">
|
<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 class="lt-stat-label">Open Tickets</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +111,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
|
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-icon lt-text-danger">[ ! ]</div>
|
||||||
<div class="lt-stat-info">
|
<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 class="lt-stat-label">Critical (P1)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +124,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
|
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
|
||||||
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
|
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
|
||||||
<div class="lt-stat-info">
|
<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 class="lt-stat-label">Unassigned</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +136,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
title="Tickets created today" aria-label="Tickets created today">
|
title="Tickets created today" aria-label="Tickets created today">
|
||||||
<div class="lt-stat-icon lt-text-cyan">[ + ]</div>
|
<div class="lt-stat-icon lt-text-cyan">[ + ]</div>
|
||||||
<div class="lt-stat-info">
|
<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 class="lt-stat-label">Created Today</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +149,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
title="Click to filter closed tickets" aria-label="Closed tickets today">
|
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-icon lt-text-muted">[ OK ]</div>
|
||||||
<div class="lt-stat-info">
|
<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 class="lt-stat-label">Closed Today</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,6 +168,54 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?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>▸</span> Team Workload
|
||||||
|
<span class="lt-text-xs lt-text-muted" style="font-weight:normal">— 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
|
VIEW TABS (Table / Kanban) — dual-purpose: lt.tabs + dashboard.js set-view-mode
|
||||||
═══════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════ -->
|
||||||
|
|||||||
Reference in New Issue
Block a user