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:
115
api/clone_ticket.php
Normal file
115
api/clone_ticket.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* Clone Ticket API
|
||||
* Creates a copy of an existing ticket with the same properties
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => 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']);
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = `
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 400px;">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
||||
<div class="ascii-frame">
|
||||
<div class="ascii-content">
|
||||
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
|
||||
<tr><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);">Save Changes</td></tr>
|
||||
<tr><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
|
||||
<tr><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.5rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
|
||||
<tr><td style="padding: 0.5rem;"><kbd>?</kbd></td><td style="padding: 0.5rem;">Show This Help</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin-top: 1rem;">
|
||||
|
||||
@@ -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