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:
@@ -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">×</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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user