From 1c1eb198763493056f5c4bf7887ef0c01a66bba9 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 30 Jan 2026 19:21:36 -0500 Subject: [PATCH] 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 --- api/clone_ticket.php | 115 +++++++++++++++++++++++++++++ assets/css/dashboard.css | 102 ++++++++++++++++++++++++++ assets/css/ticket.css | 51 +++++++++++++ assets/js/dashboard.js | 66 +++++++++++++++++ assets/js/keyboard-shortcuts.js | 125 ++++++++++++++++++++++++++++++-- views/DashboardView.php | 43 +++++++++++ views/TicketView.php | 69 ++++++++++++++++++ 7 files changed, 565 insertions(+), 6 deletions(-) create mode 100644 api/clone_ticket.php diff --git a/api/clone_ticket.php b/api/clone_ticket.php new file mode 100644 index 0000000..8a0a9ab --- /dev/null +++ b/api/clone_ticket.php @@ -0,0 +1,115 @@ + false, 'error' => 'Authentication required']); + exit; + } + + // CSRF Protection + require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; + $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!CsrfMiddleware::validateToken($csrfToken)) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + exit; + } + + // Only accept POST + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); + exit; + } + + // Get request data + $input = file_get_contents('php://input'); + $data = json_decode($input, true); + + if (!$data || empty($data['ticket_id'])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Missing ticket_id']); + exit; + } + + $sourceTicketId = $data['ticket_id']; + $userId = $_SESSION['user']['user_id']; + + // Get database connection + $conn = Database::getConnection(); + + // Get the source ticket + $ticketModel = new TicketModel($conn); + $sourceTicket = $ticketModel->getTicketById($sourceTicketId); + + if (!$sourceTicket) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Source ticket not found']); + exit; + } + + // Prepare cloned ticket data + $clonedTicketData = [ + 'title' => '[CLONE] ' . $sourceTicket['title'], + 'description' => $sourceTicket['description'], + 'priority' => $sourceTicket['priority'], + 'category' => $sourceTicket['category'], + 'type' => $sourceTicket['type'], + 'visibility' => $sourceTicket['visibility'] ?? 'public', + 'visibility_groups' => $sourceTicket['visibility_groups'] ?? null + ]; + + // Create the cloned ticket + $result = $ticketModel->createTicket($clonedTicketData, $userId); + + if ($result['success']) { + // Log the clone operation + $auditLog = new AuditLogModel($conn); + $auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [ + 'action' => 'clone', + 'source_ticket_id' => $sourceTicketId, + 'title' => $clonedTicketData['title'] + ]); + + // Optionally create a "relates_to" dependency + require_once dirname(__DIR__) . '/models/DependencyModel.php'; + $dependencyModel = new DependencyModel($conn); + $dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'new_ticket_id' => $result['ticket_id'], + 'message' => 'Ticket cloned successfully' + ]); + } else { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $result['error'] ?? 'Failed to create cloned ticket' + ]); + } + +} catch (Exception $e) { + error_log("Clone ticket API error: " . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'An internal error occurred']); +} diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 5c1b73a..3b6376e 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -981,6 +981,19 @@ tr:hover { box-shadow: inset 0 0 20px rgba(0, 255, 65, 0.1); } +/* Keyboard navigation selected row */ +tbody tr.keyboard-selected { + background-color: rgba(0, 255, 65, 0.15) !important; + box-shadow: inset 0 0 30px rgba(0, 255, 65, 0.2), 0 0 10px rgba(0, 255, 65, 0.3); + outline: 2px solid var(--terminal-green); + outline-offset: -2px; +} + +tbody tr.keyboard-selected td { + color: var(--terminal-green); + text-shadow: 0 0 5px rgba(0, 255, 65, 0.5); +} + tbody tr td:first-child { border-left: 6px solid; } @@ -1172,6 +1185,95 @@ td:nth-child(2) span::after { margin-left: 4px; } +/* ===== ACTIVE FILTERS BAR ===== */ +.active-filters-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + background: rgba(0, 255, 65, 0.05); + border: 1px solid var(--terminal-green); + border-left: 4px solid var(--terminal-amber); +} + +.active-filters-label { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--terminal-amber); + font-weight: 600; + margin-right: 0.5rem; +} + +.active-filters-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + flex: 1; +} + +.filter-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + background: rgba(0, 255, 65, 0.1); + border: 1px solid var(--terminal-green); + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--terminal-green); + animation: filter-appear 0.3s ease-out; +} + +@keyframes filter-appear { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.filter-badge[data-filter-type="status"] { + border-color: var(--terminal-cyan); + color: var(--terminal-cyan); +} + +.filter-badge[data-filter-type="priority"] { + border-color: var(--terminal-amber); + color: var(--terminal-amber); +} + +.filter-badge[data-filter-type="search"] { + border-color: var(--terminal-magenta, #ff79c6); + color: var(--terminal-magenta, #ff79c6); +} + +.filter-remove { + background: none; + border: none; + color: inherit; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.2rem; + opacity: 0.7; + transition: opacity 0.2s, transform 0.2s; +} + +.filter-remove:hover { + opacity: 1; + transform: scale(1.2); +} + +.btn-sm { + padding: 0.3rem 0.6rem; + font-size: 0.8rem; +} + /* ===== SEARCH AND FILTER STYLES - TERMINAL EDITION ===== */ .search-box, input[type="text"], diff --git a/assets/css/ticket.css b/assets/css/ticket.css index dccdd5d..b444a1e 100644 --- a/assets/css/ticket.css +++ b/assets/css/ticket.css @@ -329,6 +329,57 @@ textarea[data-field="description"]:not(:disabled)::after { color: var(--terminal-green); } +/* Ticket Age Indicator */ +.ticket-age { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + margin-top: 0.5rem; + font-family: var(--font-mono); + font-size: 0.85rem; + border-radius: 0; + border: 1px solid var(--terminal-green); + background: rgba(0, 255, 65, 0.05); +} + +.ticket-age.age-normal { + color: var(--terminal-green); + border-color: var(--terminal-green); +} + +.ticket-age.age-warning { + color: var(--terminal-amber); + border-color: var(--terminal-amber); + background: rgba(255, 176, 0, 0.1); + animation: pulse-warning 2s ease-in-out infinite; +} + +.ticket-age.age-critical { + color: var(--priority-1); + border-color: var(--priority-1); + background: rgba(255, 77, 77, 0.15); + animation: pulse-critical 1s ease-in-out infinite; +} + +@keyframes pulse-warning { + 0%, 100% { box-shadow: 0 0 5px rgba(255, 176, 0, 0.3); } + 50% { box-shadow: 0 0 15px rgba(255, 176, 0, 0.6); } +} + +@keyframes pulse-critical { + 0%, 100% { box-shadow: 0 0 5px rgba(255, 77, 77, 0.3); } + 50% { box-shadow: 0 0 20px rgba(255, 77, 77, 0.8); } +} + +.ticket-age .age-icon { + font-size: 1rem; +} + +.ticket-age .age-text { + font-weight: 500; +} + .status-priority-group { display: flex; gap: 10px; diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index ef5ecdb..d3c97de 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -203,10 +203,76 @@ document.addEventListener('DOMContentLoaded', function() { case 'open-settings-modal': if (typeof openSettingsModal === 'function') openSettingsModal(); break; + // Filter badge actions + case 'remove-filter': + removeFilter(target.dataset.filterType, target.dataset.filterValue); + break; + case 'clear-all-filters': + clearAllFilters(); + break; } }); }); +/** + * Remove a single filter and reload page + */ +function removeFilter(filterType, filterValue) { + const params = new URLSearchParams(window.location.search); + + if (filterType === 'status') { + const currentStatuses = (params.get('status') || '').split(',').filter(s => s.trim()); + const newStatuses = currentStatuses.filter(s => s !== filterValue); + if (newStatuses.length > 0) { + params.set('status', newStatuses.join(',')); + } else { + params.delete('status'); + } + } else if (filterType === 'priority') { + const currentPriorities = (params.get('priority') || '').split(',').filter(p => p.trim()); + const newPriorities = currentPriorities.filter(p => p !== filterValue); + if (newPriorities.length > 0) { + params.set('priority', newPriorities.join(',')); + } else { + params.delete('priority'); + } + } else if (filterType === 'search') { + params.delete('search'); + } else { + params.delete(filterType); + } + + // Reset to page 1 when changing filters + params.delete('page'); + + window.location.search = params.toString(); +} + +/** + * Clear all filters and reload page + */ +function clearAllFilters() { + const params = new URLSearchParams(window.location.search); + + // Remove all filter parameters + params.delete('status'); + params.delete('priority'); + params.delete('category'); + params.delete('type'); + params.delete('assigned_to'); + params.delete('search'); + params.delete('date_from'); + params.delete('date_to'); + params.delete('page'); + + // Keep sort parameters + const sortParams = new URLSearchParams(); + if (params.has('sort')) sortParams.set('sort', params.get('sort')); + if (params.has('dir')) sortParams.set('dir', params.get('dir')); + + window.location.search = sortParams.toString(); +} + function initTableSorting() { const tableHeaders = document.querySelectorAll('th'); tableHeaders.forEach((header, index) => { diff --git a/assets/js/keyboard-shortcuts.js b/assets/js/keyboard-shortcuts.js index 8da2bcb..fd34d01 100644 --- a/assets/js/keyboard-shortcuts.js +++ b/assets/js/keyboard-shortcuts.js @@ -96,9 +96,102 @@ document.addEventListener('DOMContentLoaded', function() { e.preventDefault(); showKeyboardHelp(); } + + // J: Move to next row in table (Gmail-style) + if (e.key === 'j') { + e.preventDefault(); + navigateTableRow('next'); + } + + // K: Move to previous row in table (Gmail-style) + if (e.key === 'k') { + e.preventDefault(); + navigateTableRow('prev'); + } + + // Enter: Open selected ticket + if (e.key === 'Enter') { + const selectedRow = document.querySelector('tbody tr.keyboard-selected'); + if (selectedRow) { + e.preventDefault(); + const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]'); + if (ticketLink) { + window.location.href = ticketLink.href; + } + } + } + + // N: Create new ticket (on dashboard) + if (e.key === 'n') { + e.preventDefault(); + const newTicketBtn = document.querySelector('a[href*="/create"]'); + if (newTicketBtn) { + window.location.href = newTicketBtn.href; + } + } + + // C: Focus comment textarea (on ticket page) + if (e.key === 'c') { + const commentBox = document.getElementById('newComment'); + if (commentBox) { + e.preventDefault(); + commentBox.focus(); + commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + + // G then D: Go to Dashboard (vim-style) + if (e.key === 'g') { + window._pendingG = true; + setTimeout(() => { window._pendingG = false; }, 1000); + } + if (e.key === 'd' && window._pendingG) { + e.preventDefault(); + window._pendingG = false; + window.location.href = '/'; + } + + // 1-4: Quick status change on ticket page + if (['1', '2', '3', '4'].includes(e.key)) { + const statusSelect = document.getElementById('statusSelect'); + if (statusSelect && !document.querySelector('.modal-overlay')) { + const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' }; + const targetStatus = statusMap[e.key]; + const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus); + if (option && !option.disabled) { + e.preventDefault(); + statusSelect.value = targetStatus; + statusSelect.dispatchEvent(new Event('change')); + } + } + } }); }); +// Track currently selected row for J/K navigation +let currentSelectedRowIndex = -1; + +function navigateTableRow(direction) { + const rows = document.querySelectorAll('tbody tr'); + if (rows.length === 0) return; + + // Remove current selection + rows.forEach(row => row.classList.remove('keyboard-selected')); + + if (direction === 'next') { + currentSelectedRowIndex = Math.min(currentSelectedRowIndex + 1, rows.length - 1); + } else { + currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0); + } + + // Add selection to new row + const selectedRow = rows[currentSelectedRowIndex]; + if (selectedRow) { + selectedRow.classList.add('keyboard-selected'); + selectedRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } +} + function showKeyboardHelp() { // Check if help is already showing if (document.getElementById('keyboardHelpModal')) { @@ -109,17 +202,37 @@ function showKeyboardHelp() { modal.id = 'keyboardHelpModal'; modal.className = 'modal-overlay'; modal.innerHTML = ` -