Add UI enhancements and new features

Keyboard Navigation:
- Add J/K keys for Gmail-style ticket list navigation
- Add N key for new ticket, C for comment focus
- Add G then D for go to dashboard (vim-style)
- Add 1-4 number keys for quick status changes on ticket page
- Add Enter to open selected ticket
- Update keyboard help modal with all new shortcuts

Ticket Age Indicator:
- Show "Last activity: X days ago" on ticket view
- Visual warning (yellow pulse) for tickets idle >5 days
- Critical warning (red pulse) for tickets idle >10 days

Ticket Clone Feature:
- Add "Clone" button on ticket view
- Creates copy with [CLONE] prefix in title
- Preserves description, priority, category, type, visibility
- Automatically creates "relates_to" dependency to original

Active Filter Badges:
- Show visual badges above ticket table for active filters
- Click X on badge to remove individual filter
- "Clear All" button to reset all filters
- Color-coded by filter type (status, priority, search)

Visual Enhancements:
- Add keyboard-selected row highlighting for J/K navigation
- Smooth animations for filter badges

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 19:21:36 -05:00
parent 9b40a714ed
commit 1c1eb19876
7 changed files with 565 additions and 6 deletions

View File

@@ -356,6 +356,49 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<?php endif; ?>
<!-- Active Filters Display -->
<?php
$activeFilters = [];
if (!empty($_GET['status'])) {
$statuses = explode(',', $_GET['status']);
foreach ($statuses as $s) {
$activeFilters[] = ['type' => 'status', 'value' => trim($s), 'label' => 'Status: ' . trim($s)];
}
}
if (!empty($_GET['priority'])) {
$priorities = is_array($_GET['priority']) ? $_GET['priority'] : explode(',', $_GET['priority']);
foreach ($priorities as $p) {
$activeFilters[] = ['type' => 'priority', 'value' => trim($p), 'label' => 'Priority: P' . trim($p)];
}
}
if (!empty($_GET['category'])) {
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . $_GET['category']];
}
if (!empty($_GET['type'])) {
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . $_GET['type']];
}
if (!empty($_GET['assigned_to'])) {
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned To: ' . ($_GET['assigned_to'] === 'unassigned' ? 'Unassigned' : 'User #' . $_GET['assigned_to'])];
}
if (!empty($_GET['search'])) {
$activeFilters[] = ['type' => 'search', 'value' => $_GET['search'], 'label' => 'Search: "' . htmlspecialchars(substr($_GET['search'], 0, 20)) . (strlen($_GET['search']) > 20 ? '...' : '') . '"'];
}
?>
<?php if (!empty($activeFilters)): ?>
<div class="active-filters-bar">
<span class="active-filters-label">Active Filters:</span>
<div class="active-filters-list">
<?php foreach ($activeFilters as $filter): ?>
<span class="filter-badge" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>">
<?php echo htmlspecialchars($filter['label']); ?>
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter">&times;</button>
</span>
<?php endforeach; ?>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">Clear All</button>
</div>
<?php endif; ?>
<!-- Table -->
<div class="table-wrapper">
<table>

View File

@@ -104,6 +104,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ticket-subheader">
<div class="ticket-metadata">
<div class="ticket-id">UUID <?php echo $ticket['ticket_id']; ?></div>
<?php
// Calculate ticket age
$lastUpdate = !empty($ticket['updated_at']) ? strtotime($ticket['updated_at']) : strtotime($ticket['created_at']);
$ageSeconds = time() - $lastUpdate;
$ageDays = floor($ageSeconds / 86400);
$ageHours = floor(($ageSeconds % 86400) / 3600);
// Determine age class for styling
$ageClass = 'age-normal';
if ($ticket['status'] !== 'Closed') {
if ($ageDays >= 10) {
$ageClass = 'age-critical';
} elseif ($ageDays >= 5) {
$ageClass = 'age-warning';
}
}
// Format age string
if ($ageDays > 0) {
$ageStr = $ageDays . ' day' . ($ageDays != 1 ? 's' : '');
} else {
$ageStr = $ageHours . ' hour' . ($ageHours != 1 ? 's' : '');
}
?>
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update">
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '⚠️' : ($ageClass === 'age-warning' ? '⏰' : '📅'); ?></span>
<span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
</div>
<div class="ticket-user-info" style="font-size: 0.85rem; color: #666; margin-top: 0.25rem;">
<?php
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
@@ -227,6 +255,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</select>
</div>
<button id="editButton" class="btn">Edit Ticket</button>
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">Clone</button>
</div>
</div>
</div>
@@ -483,6 +512,46 @@ $nonce = SecurityHeadersMiddleware::getNonce();
});
}
// Clone button
var cloneBtn = document.getElementById('cloneButton');
if (cloneBtn) {
cloneBtn.addEventListener('click', function() {
if (confirm('Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.')) {
cloneBtn.disabled = true;
cloneBtn.textContent = 'Cloning...';
fetch('/api/clone_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: window.ticketData.ticket_id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
toast.success('Ticket cloned successfully!');
setTimeout(function() {
window.location.href = '/ticket/' + data.new_ticket_id;
}, 1000);
} else {
toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone';
}
})
.catch(function(error) {
toast.error('Failed to clone ticket: ' + error.message);
cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone';
});
}
});
}
// Add comment button
var addCommentBtn = document.getElementById('addCommentBtn');
if (addCommentBtn) {