From 962724d81114d5cfc9242d1f5f1623cf9b7d2914 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 9 Jan 2026 11:20:27 -0500 Subject: [PATCH] better filtering and searching --- api/audit_log.php | 136 +++++++++ api/saved_filters.php | 179 ++++++++++++ assets/css/dashboard.css | 193 +++++++++++++ assets/js/advanced-search.js | 361 ++++++++++++++++++++++++ controllers/DashboardController.php | 17 +- migrations/012_create_saved_filters.sql | 15 + models/AuditLogModel.php | 119 ++++++++ models/SavedFiltersModel.php | 189 +++++++++++++ models/TicketModel.php | 67 ++++- views/DashboardView.php | 121 ++++++++ 10 files changed, 1388 insertions(+), 9 deletions(-) create mode 100644 api/audit_log.php create mode 100644 api/saved_filters.php create mode 100644 assets/js/advanced-search.js create mode 100644 migrations/012_create_saved_filters.sql create mode 100644 models/SavedFiltersModel.php diff --git a/api/audit_log.php b/api/audit_log.php new file mode 100644 index 0000000..67fc39b --- /dev/null +++ b/api/audit_log.php @@ -0,0 +1,136 @@ + false, 'error' => 'Not authenticated']); + exit; +} + +// Check admin status - audit log viewing is admin-only +$isAdmin = $_SESSION['user']['is_admin'] ?? false; +if (!$isAdmin) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Admin access required']); + exit; +} + +// Create database connection +$conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] +); + +if ($conn->connect_error) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Database connection failed']); + exit; +} + +$auditLogModel = new AuditLogModel($conn); + +// GET - Fetch filtered audit logs or export to CSV +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + // Check for CSV export request + if (isset($_GET['export']) && $_GET['export'] === 'csv') { + // Build filters + $filters = []; + if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type']; + if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type']; + if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id']; + if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id']; + if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from']; + if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to']; + if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address']; + + // Get all matching logs (no limit for CSV export) + $result = $auditLogModel->getFilteredLogs($filters, 10000, 0); + $logs = $result['logs']; + + // Set CSV headers + header('Content-Type: text/csv'); + header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d_His') . '.csv"'); + + // Output CSV + $output = fopen('php://output', 'w'); + + // Write CSV header + fputcsv($output, ['Log ID', 'Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'IP Address', 'Details']); + + // Write data rows + foreach ($logs as $log) { + $details = ''; + if (is_array($log['details'])) { + $details = json_encode($log['details']); + } + + fputcsv($output, [ + $log['log_id'], + $log['created_at'], + $log['display_name'] ?? $log['username'] ?? 'N/A', + $log['action_type'], + $log['entity_type'], + $log['entity_id'] ?? 'N/A', + $log['ip_address'] ?? 'N/A', + $details + ]); + } + + fclose($output); + $conn->close(); + exit; + } + + // Normal JSON response for filtered logs + try { + // Get pagination parameters + $page = isset($_GET['page']) ? (int)$_GET['page'] : 1; + $limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50; + $offset = ($page - 1) * $limit; + + // Build filters + $filters = []; + if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type']; + if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type']; + if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id']; + if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id']; + if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from']; + if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to']; + if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address']; + + // Get filtered logs + $result = $auditLogModel->getFilteredLogs($filters, $limit, $offset); + + echo json_encode([ + 'success' => true, + 'logs' => $result['logs'], + 'total' => $result['total'], + 'pages' => $result['pages'], + 'current_page' => $page + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']); + } + $conn->close(); + exit; +} + +// Method not allowed +http_response_code(405); +echo json_encode(['success' => false, 'error' => 'Method not allowed']); +$conn->close(); +?> diff --git a/api/saved_filters.php b/api/saved_filters.php new file mode 100644 index 0000000..de194f8 --- /dev/null +++ b/api/saved_filters.php @@ -0,0 +1,179 @@ + false, 'error' => 'Not authenticated']); + exit; +} + +$userId = $_SESSION['user']['user_id']; + +// Create database connection +$conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] +); + +if ($conn->connect_error) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Database connection failed']); + exit; +} + +$filtersModel = new SavedFiltersModel($conn); + +// GET - Fetch all saved filters or a specific filter +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + try { + if (isset($_GET['filter_id'])) { + $filterId = (int)$_GET['filter_id']; + $filter = $filtersModel->getFilter($filterId, $userId); + + if ($filter) { + echo json_encode(['success' => true, 'filter' => $filter]); + } else { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Filter not found']); + } + } else if (isset($_GET['default'])) { + // Get default filter + $filter = $filtersModel->getDefaultFilter($userId); + echo json_encode(['success' => true, 'filter' => $filter]); + } else { + // Get all filters + $filters = $filtersModel->getUserFilters($userId); + echo json_encode(['success' => true, 'filters' => $filters]); + } + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']); + } + $conn->close(); + exit; +} + +// POST - Create a new saved filter +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); + + if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); + $conn->close(); + exit; + } + + $filterName = trim($data['filter_name']); + $filterCriteria = $data['filter_criteria']; + $isDefault = $data['is_default'] ?? false; + + // Validate filter name + if (empty($filterName) || strlen($filterName) > 100) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid filter name']); + $conn->close(); + exit; + } + + try { + $result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault); + echo json_encode($result); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to save filter']); + } + $conn->close(); + exit; +} + +// PUT - Update an existing filter +if ($_SERVER['REQUEST_METHOD'] === 'PUT') { + $data = json_decode(file_get_contents('php://input'), true); + + if (!isset($data['filter_id'])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Missing filter_id']); + $conn->close(); + exit; + } + + $filterId = (int)$data['filter_id']; + + // Handle setting default filter + if (isset($data['set_default']) && $data['set_default'] === true) { + try { + $result = $filtersModel->setDefaultFilter($filterId, $userId); + echo json_encode($result); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to set default filter']); + } + $conn->close(); + exit; + } + + // Handle full filter update + if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); + $conn->close(); + exit; + } + + $filterName = trim($data['filter_name']); + $filterCriteria = $data['filter_criteria']; + $isDefault = $data['is_default'] ?? false; + + try { + $result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault); + echo json_encode($result); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to update filter']); + } + $conn->close(); + exit; +} + +// DELETE - Delete a saved filter +if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { + $data = json_decode(file_get_contents('php://input'), true); + + if (!isset($data['filter_id'])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Missing filter_id']); + $conn->close(); + exit; + } + + $filterId = (int)$data['filter_id']; + + try { + $result = $filtersModel->deleteFilter($filterId, $userId); + echo json_encode($result); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to delete filter']); + } + $conn->close(); + exit; +} + +// Method not allowed +http_response_code(405); +echo json_encode(['success' => false, 'error' => 'Method not allowed']); +$conn->close(); +?> diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index fe28c3b..4a9160e 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -2807,3 +2807,196 @@ code.inline-code { color: var(--terminal-cyan); font-style: italic; } +/* Advanced Search Modal */ +.advanced-search-modal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 10000; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(5px); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + overflow-y: auto; +} + +.advanced-search-content { + position: relative; + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); + box-shadow: 0 0 30px rgba(0, 255, 65, 0.5); + max-width: 900px; + width: 100%; + max-height: calc(90vh - 2rem); + overflow-y: auto; + overflow-x: hidden; + font-family: var(--font-mono); + padding: 2rem; + animation: slideIn 0.3s ease; + box-sizing: border-box; +} + +.advanced-search-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + border-bottom: 2px solid var(--terminal-green); + padding-bottom: 1rem; +} + +.advanced-search-header h3 { + color: var(--terminal-amber); + text-shadow: var(--glow-amber); + font-size: 1.5rem; + margin: 0; +} + +.close-advanced-search { + background: transparent; + border: 2px solid var(--terminal-red); + color: var(--terminal-red); + font-size: 1.5rem; + padding: 0.25rem 0.75rem; + cursor: pointer; + font-family: var(--font-mono); + transition: all 0.3s ease; +} + +.close-advanced-search:hover { + background: var(--terminal-red); + color: var(--bg-primary); + box-shadow: 0 0 15px rgba(255, 77, 77, 0.7); +} + +.advanced-search-body { + font-family: var(--font-mono); +} + +.search-section { + margin-bottom: 2rem; + border: 2px solid var(--terminal-green); + padding: 1.5rem; +} + +.search-section h4 { + color: var(--terminal-amber); + text-shadow: var(--glow-amber); + margin-bottom: 1rem; + font-size: 1.1rem; +} + +.search-field { + margin-bottom: 1rem; +} + +.search-field label { + display: block; + color: var(--terminal-green); + margin-bottom: 0.5rem; +} + +.search-field input[type="text"], +.search-field input[type="date"], +.search-field input[type="number"], +.search-field select { + width: 100%; + background: var(--bg-primary); + color: var(--terminal-green); + border: 2px solid var(--terminal-green); + padding: 0.75rem; + font-family: var(--font-mono); + font-size: 1rem; + box-sizing: border-box; +} + +.search-field input:focus, +.search-field select:focus { + outline: none; + border-color: var(--terminal-amber); + box-shadow: 0 0 10px rgba(255, 193, 7, 0.5); +} + +.date-range-group, +.priority-range-group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.search-field select[multiple] { + min-height: 120px; +} + +.advanced-search-footer { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; + padding-top: 1rem; + border-top: 2px solid var(--terminal-green); +} + +.btn-search-primary { + background: var(--terminal-green); + color: var(--bg-primary); + border: 2px solid var(--terminal-green); + padding: 0.75rem 2rem; + font-family: var(--font-mono); + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + font-weight: bold; +} + +.btn-search-primary:hover { + background: transparent; + color: var(--terminal-green); + box-shadow: 0 0 15px rgba(0, 255, 65, 0.7); +} + +.btn-search-secondary { + background: transparent; + color: var(--terminal-green); + border: 2px solid var(--terminal-green); + padding: 0.75rem 1.5rem; + font-family: var(--font-mono); + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-search-secondary:hover { + background: var(--terminal-green); + color: var(--bg-primary); + box-shadow: 0 0 15px rgba(0, 255, 65, 0.5); +} + +.btn-search-reset { + background: transparent; + color: var(--terminal-amber); + border: 2px solid var(--terminal-amber); + padding: 0.75rem 1.5rem; + font-family: var(--font-mono); + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-search-reset:hover { + background: var(--terminal-amber); + color: var(--bg-primary); + box-shadow: 0 0 15px rgba(255, 193, 7, 0.5); +} + +/* Hint text */ +.search-hint { + color: var(--terminal-cyan); + font-size: 0.85rem; + margin-top: 0.25rem; + font-style: italic; +} diff --git a/assets/js/advanced-search.js b/assets/js/advanced-search.js new file mode 100644 index 0000000..a503663 --- /dev/null +++ b/assets/js/advanced-search.js @@ -0,0 +1,361 @@ +/** + * Advanced Search Functionality + * Handles complex search queries with date ranges, user filters, and multiple criteria + */ + +// Open advanced search modal +function openAdvancedSearch() { + const modal = document.getElementById('advancedSearchModal'); + if (modal) { + modal.style.display = 'flex'; + document.body.classList.add('modal-open'); + loadUsersForSearch(); + populateCurrentFilters(); + loadSavedFilters(); + } +} + +// Close advanced search modal +function closeAdvancedSearch() { + const modal = document.getElementById('advancedSearchModal'); + if (modal) { + modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + } +} + +// Close modal when clicking on backdrop +function closeOnAdvancedSearchBackdropClick(event) { + const modal = document.getElementById('advancedSearchModal'); + if (event.target === modal) { + closeAdvancedSearch(); + } +} + +// Load users for dropdown +async function loadUsersForSearch() { + try { + const response = await fetch('/api/get_users.php'); + const data = await response.json(); + + if (data.success && data.users) { + const createdBySelect = document.getElementById('adv-created-by'); + const assignedToSelect = document.getElementById('adv-assigned-to'); + + // Clear existing options (except first default option) + while (createdBySelect.options.length > 1) { + createdBySelect.remove(1); + } + while (assignedToSelect.options.length > 2) { // Keep "Any" and "Unassigned" + assignedToSelect.remove(2); + } + + // Add users to both dropdowns + data.users.forEach(user => { + const displayName = user.display_name || user.username; + + const option1 = document.createElement('option'); + option1.value = user.user_id; + option1.textContent = displayName; + createdBySelect.appendChild(option1); + + const option2 = document.createElement('option'); + option2.value = user.user_id; + option2.textContent = displayName; + assignedToSelect.appendChild(option2); + }); + } + } catch (error) { + console.error('Error loading users:', error); + } +} + +// Populate form with current URL parameters +function populateCurrentFilters() { + const urlParams = new URLSearchParams(window.location.search); + + // Search text + if (urlParams.has('search')) { + document.getElementById('adv-search-text').value = urlParams.get('search'); + } + + // Status + if (urlParams.has('status')) { + const statuses = urlParams.get('status').split(','); + const statusSelect = document.getElementById('adv-status'); + Array.from(statusSelect.options).forEach(option => { + option.selected = statuses.includes(option.value); + }); + } +} + +// Perform advanced search +function performAdvancedSearch(event) { + event.preventDefault(); + + const params = new URLSearchParams(); + + // Search text + const searchText = document.getElementById('adv-search-text').value.trim(); + if (searchText) { + params.set('search', searchText); + } + + // Date ranges + const createdFrom = document.getElementById('adv-created-from').value; + const createdTo = document.getElementById('adv-created-to').value; + const updatedFrom = document.getElementById('adv-updated-from').value; + const updatedTo = document.getElementById('adv-updated-to').value; + + if (createdFrom) params.set('created_from', createdFrom); + if (createdTo) params.set('created_to', createdTo); + if (updatedFrom) params.set('updated_from', updatedFrom); + if (updatedTo) params.set('updated_to', updatedTo); + + // Status (multi-select) + const statusSelect = document.getElementById('adv-status'); + const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value); + if (selectedStatuses.length > 0) { + params.set('status', selectedStatuses.join(',')); + } + + // Priority range + const priorityMin = document.getElementById('adv-priority-min').value; + const priorityMax = document.getElementById('adv-priority-max').value; + if (priorityMin) params.set('priority_min', priorityMin); + if (priorityMax) params.set('priority_max', priorityMax); + + // Users + const createdBy = document.getElementById('adv-created-by').value; + const assignedTo = document.getElementById('adv-assigned-to').value; + if (createdBy) params.set('created_by', createdBy); + if (assignedTo) params.set('assigned_to', assignedTo); + + // Redirect to dashboard with params + window.location.href = '/?' + params.toString(); +} + +// Reset advanced search form +function resetAdvancedSearch() { + document.getElementById('advancedSearchForm').reset(); + + // Unselect all multi-select options + const statusSelect = document.getElementById('adv-status'); + Array.from(statusSelect.options).forEach(option => { + option.selected = false; + }); +} + +// Save current search as a filter +async function saveCurrentFilter() { + const filterName = prompt('Enter a name for this filter:'); + if (!filterName || filterName.trim() === '') return; + + const filterCriteria = getCurrentFilterCriteria(); + + try { + const response = await fetch('/api/saved_filters.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filter_name: filterName.trim(), + filter_criteria: filterCriteria + }) + }); + + const result = await response.json(); + + if (result.success) { + if (typeof toast !== 'undefined') { + toast.success(`Filter "${filterName}" saved successfully!`); + } + loadSavedFilters(); + } else { + if (typeof toast !== 'undefined') { + toast.error('Failed to save filter: ' + (result.error || 'Unknown error')); + } + } + } catch (error) { + console.error('Error saving filter:', error); + if (typeof toast !== 'undefined') { + toast.error('Error saving filter'); + } + } +} + +// Get current filter criteria from form +function getCurrentFilterCriteria() { + const criteria = {}; + + const searchText = document.getElementById('adv-search-text').value.trim(); + if (searchText) criteria.search = searchText; + + const createdFrom = document.getElementById('adv-created-from').value; + if (createdFrom) criteria.created_from = createdFrom; + + const createdTo = document.getElementById('adv-created-to').value; + if (createdTo) criteria.created_to = createdTo; + + const updatedFrom = document.getElementById('adv-updated-from').value; + if (updatedFrom) criteria.updated_from = updatedFrom; + + const updatedTo = document.getElementById('adv-updated-to').value; + if (updatedTo) criteria.updated_to = updatedTo; + + const statusSelect = document.getElementById('adv-status'); + const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value); + if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(','); + + const priorityMin = document.getElementById('adv-priority-min').value; + if (priorityMin) criteria.priority_min = priorityMin; + + const priorityMax = document.getElementById('adv-priority-max').value; + if (priorityMax) criteria.priority_max = priorityMax; + + const createdBy = document.getElementById('adv-created-by').value; + if (createdBy) criteria.created_by = createdBy; + + const assignedTo = document.getElementById('adv-assigned-to').value; + if (assignedTo) criteria.assigned_to = assignedTo; + + return criteria; +} + +// Load saved filters +async function loadSavedFilters() { + try { + const response = await fetch('/api/saved_filters.php'); + const data = await response.json(); + + if (data.success && data.filters) { + populateSavedFiltersDropdown(data.filters); + } + } catch (error) { + console.error('Error loading saved filters:', error); + } +} + +// Populate saved filters dropdown +function populateSavedFiltersDropdown(filters) { + const dropdown = document.getElementById('saved-filters-select'); + if (!dropdown) return; + + // Clear existing options except the first (placeholder) + while (dropdown.options.length > 1) { + dropdown.remove(1); + } + + // Add saved filters + filters.forEach(filter => { + const option = document.createElement('option'); + option.value = filter.filter_id; + option.textContent = filter.filter_name + (filter.is_default ? ' ⭐' : ''); + option.dataset.criteria = JSON.stringify(filter.filter_criteria); + dropdown.appendChild(option); + }); +} + +// Load a saved filter +function loadSavedFilter() { + const dropdown = document.getElementById('saved-filters-select'); + const selectedOption = dropdown.options[dropdown.selectedIndex]; + + if (!selectedOption || !selectedOption.dataset.criteria) return; + + try { + const criteria = JSON.parse(selectedOption.dataset.criteria); + applySavedFilterCriteria(criteria); + } catch (error) { + console.error('Error loading filter:', error); + } +} + +// Apply saved filter criteria to form +function applySavedFilterCriteria(criteria) { + // Search text + document.getElementById('adv-search-text').value = criteria.search || ''; + + // Date ranges + document.getElementById('adv-created-from').value = criteria.created_from || ''; + document.getElementById('adv-created-to').value = criteria.created_to || ''; + document.getElementById('adv-updated-from').value = criteria.updated_from || ''; + document.getElementById('adv-updated-to').value = criteria.updated_to || ''; + + // Status + const statusSelect = document.getElementById('adv-status'); + const statuses = criteria.status ? criteria.status.split(',') : []; + Array.from(statusSelect.options).forEach(option => { + option.selected = statuses.includes(option.value); + }); + + // Priority + document.getElementById('adv-priority-min').value = criteria.priority_min || ''; + document.getElementById('adv-priority-max').value = criteria.priority_max || ''; + + // Users + document.getElementById('adv-created-by').value = criteria.created_by || ''; + document.getElementById('adv-assigned-to').value = criteria.assigned_to || ''; +} + +// Delete saved filter +async function deleteSavedFilter() { + const dropdown = document.getElementById('saved-filters-select'); + const selectedOption = dropdown.options[dropdown.selectedIndex]; + + if (!selectedOption || selectedOption.value === '') { + if (typeof toast !== 'undefined') { + toast.error('Please select a filter to delete'); + } + return; + } + + const filterId = selectedOption.value; + const filterName = selectedOption.textContent; + + if (!confirm(`Are you sure you want to delete the filter "${filterName}"?`)) { + return; + } + + try { + const response = await fetch('/api/saved_filters.php', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filter_id: filterId }) + }); + + const result = await response.json(); + + if (result.success) { + if (typeof toast !== 'undefined') { + toast.success('Filter deleted successfully'); + } + loadSavedFilters(); + resetAdvancedSearch(); + } else { + if (typeof toast !== 'undefined') { + toast.error('Failed to delete filter'); + } + } + } catch (error) { + console.error('Error deleting filter:', error); + if (typeof toast !== 'undefined') { + toast.error('Error deleting filter'); + } + } +} + +// Keyboard shortcut (Ctrl+Shift+F) +document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.shiftKey && e.key === 'F') { + e.preventDefault(); + openAdvancedSearch(); + } + + // ESC to close + if (e.key === 'Escape') { + const modal = document.getElementById('advancedSearchModal'); + if (modal && modal.style.display === 'flex') { + closeAdvancedSearch(); + } + } +}); diff --git a/controllers/DashboardController.php b/controllers/DashboardController.php index a3744e2..3baceaf 100644 --- a/controllers/DashboardController.php +++ b/controllers/DashboardController.php @@ -48,9 +48,20 @@ class DashboardController { } } // If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null) - - // Get tickets with pagination, sorting, and search - $result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search); + + // Build advanced search filters array + $filters = []; + if (isset($_GET['created_from'])) $filters['created_from'] = $_GET['created_from']; + if (isset($_GET['created_to'])) $filters['created_to'] = $_GET['created_to']; + if (isset($_GET['updated_from'])) $filters['updated_from'] = $_GET['updated_from']; + if (isset($_GET['updated_to'])) $filters['updated_to'] = $_GET['updated_to']; + if (isset($_GET['priority_min'])) $filters['priority_min'] = $_GET['priority_min']; + if (isset($_GET['priority_max'])) $filters['priority_max'] = $_GET['priority_max']; + if (isset($_GET['created_by'])) $filters['created_by'] = $_GET['created_by']; + if (isset($_GET['assigned_to'])) $filters['assigned_to'] = $_GET['assigned_to']; + + // Get tickets with pagination, sorting, search, and advanced filters + $result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters); // Get categories and types for filters $categories = $this->getCategories(); diff --git a/migrations/012_create_saved_filters.sql b/migrations/012_create_saved_filters.sql new file mode 100644 index 0000000..ff0c5c0 --- /dev/null +++ b/migrations/012_create_saved_filters.sql @@ -0,0 +1,15 @@ +-- Create saved_filters table for storing user's custom search filters +CREATE TABLE IF NOT EXISTS saved_filters ( + filter_id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + filter_name VARCHAR(100) NOT NULL, + filter_criteria JSON NOT NULL, + is_default BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE, + UNIQUE KEY unique_user_filter_name (user_id, filter_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create index for faster lookups +CREATE INDEX idx_user_filters ON saved_filters(user_id, is_default); diff --git a/models/AuditLogModel.php b/models/AuditLogModel.php index ec7b287..16264ec 100644 --- a/models/AuditLogModel.php +++ b/models/AuditLogModel.php @@ -321,4 +321,123 @@ class AuditLogModel { $stmt->close(); return $timeline; } + + /** + * Get filtered audit logs with advanced search + * + * @param array $filters Associative array of filter criteria + * @param int $limit Maximum number of logs to return + * @param int $offset Offset for pagination + * @return array Array containing logs and total count + */ + public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) { + $whereConditions = []; + $params = []; + $paramTypes = ''; + + // Action type filter + if (!empty($filters['action_type'])) { + $actions = explode(',', $filters['action_type']); + $placeholders = str_repeat('?,', count($actions) - 1) . '?'; + $whereConditions[] = "al.action_type IN ($placeholders)"; + $params = array_merge($params, $actions); + $paramTypes .= str_repeat('s', count($actions)); + } + + // Entity type filter + if (!empty($filters['entity_type'])) { + $entities = explode(',', $filters['entity_type']); + $placeholders = str_repeat('?,', count($entities) - 1) . '?'; + $whereConditions[] = "al.entity_type IN ($placeholders)"; + $params = array_merge($params, $entities); + $paramTypes .= str_repeat('s', count($entities)); + } + + // User filter + if (!empty($filters['user_id'])) { + $whereConditions[] = "al.user_id = ?"; + $params[] = (int)$filters['user_id']; + $paramTypes .= 'i'; + } + + // Entity ID filter (for specific ticket/comment) + if (!empty($filters['entity_id'])) { + $whereConditions[] = "al.entity_id = ?"; + $params[] = $filters['entity_id']; + $paramTypes .= 's'; + } + + // Date range filters + if (!empty($filters['date_from'])) { + $whereConditions[] = "DATE(al.created_at) >= ?"; + $params[] = $filters['date_from']; + $paramTypes .= 's'; + } + if (!empty($filters['date_to'])) { + $whereConditions[] = "DATE(al.created_at) <= ?"; + $params[] = $filters['date_to']; + $paramTypes .= 's'; + } + + // IP address filter + if (!empty($filters['ip_address'])) { + $whereConditions[] = "al.ip_address LIKE ?"; + $params[] = '%' . $filters['ip_address'] . '%'; + $paramTypes .= 's'; + } + + // Build WHERE clause + $whereClause = ''; + if (!empty($whereConditions)) { + $whereClause = 'WHERE ' . implode(' AND ', $whereConditions); + } + + // Get total count for pagination + $countSql = "SELECT COUNT(*) as total FROM audit_log al $whereClause"; + $countStmt = $this->conn->prepare($countSql); + if (!empty($params)) { + $countStmt->bind_param($paramTypes, ...$params); + } + $countStmt->execute(); + $totalResult = $countStmt->get_result(); + $totalCount = $totalResult->fetch_assoc()['total']; + $countStmt->close(); + + // Get filtered logs + $sql = "SELECT al.*, u.username, u.display_name + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.user_id + $whereClause + ORDER BY al.created_at DESC + LIMIT ? OFFSET ?"; + $stmt = $this->conn->prepare($sql); + + // Add limit and offset parameters + $params[] = $limit; + $params[] = $offset; + $paramTypes .= 'ii'; + + if (!empty($params)) { + $stmt->bind_param($paramTypes, ...$params); + } + + $stmt->execute(); + $result = $stmt->get_result(); + + $logs = []; + while ($row = $result->fetch_assoc()) { + if ($row['details']) { + $row['details'] = json_decode($row['details'], true); + } + $logs[] = $row; + } + + $stmt->close(); + + return [ + 'logs' => $logs, + 'total' => $totalCount, + 'pages' => ceil($totalCount / $limit) + ]; + } } diff --git a/models/SavedFiltersModel.php b/models/SavedFiltersModel.php new file mode 100644 index 0000000..1704f79 --- /dev/null +++ b/models/SavedFiltersModel.php @@ -0,0 +1,189 @@ +conn = $conn; + } + + /** + * Get all saved filters for a user + */ + public function getUserFilters($userId) { + $sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at + FROM saved_filters + WHERE user_id = ? + ORDER BY is_default DESC, filter_name ASC"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $userId); + $stmt->execute(); + $result = $stmt->get_result(); + + $filters = []; + while ($row = $result->fetch_assoc()) { + $row['filter_criteria'] = json_decode($row['filter_criteria'], true); + $filters[] = $row; + } + return $filters; + } + + /** + * Get a specific saved filter + */ + public function getFilter($filterId, $userId) { + $sql = "SELECT filter_id, filter_name, filter_criteria, is_default + FROM saved_filters + WHERE filter_id = ? AND user_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ii", $filterId, $userId); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($row = $result->fetch_assoc()) { + $row['filter_criteria'] = json_decode($row['filter_criteria'], true); + return $row; + } + return null; + } + + /** + * Save a new filter + */ + public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) { + // If this is set as default, unset all other defaults for this user + if ($isDefault) { + $this->clearDefaultFilters($userId); + } + + $sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + filter_criteria = VALUES(filter_criteria), + is_default = VALUES(is_default), + updated_at = CURRENT_TIMESTAMP"; + + $stmt = $this->conn->prepare($sql); + $criteriaJson = json_encode($filterCriteria); + $stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault); + + if ($stmt->execute()) { + return [ + 'success' => true, + 'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName) + ]; + } + return ['success' => false, 'error' => $this->conn->error]; + } + + /** + * Update an existing filter + */ + public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) { + // Verify ownership + $existing = $this->getFilter($filterId, $userId); + if (!$existing) { + return ['success' => false, 'error' => 'Filter not found']; + } + + // If this is set as default, unset all other defaults for this user + if ($isDefault) { + $this->clearDefaultFilters($userId); + } + + $sql = "UPDATE saved_filters + SET filter_name = ?, filter_criteria = ?, is_default = ?, updated_at = CURRENT_TIMESTAMP + WHERE filter_id = ? AND user_id = ?"; + + $stmt = $this->conn->prepare($sql); + $criteriaJson = json_encode($filterCriteria); + $stmt->bind_param("ssiii", $filterName, $criteriaJson, $isDefault, $filterId, $userId); + + if ($stmt->execute()) { + return ['success' => true]; + } + return ['success' => false, 'error' => $this->conn->error]; + } + + /** + * Delete a saved filter + */ + public function deleteFilter($filterId, $userId) { + $sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ii", $filterId, $userId); + + if ($stmt->execute() && $stmt->affected_rows > 0) { + return ['success' => true]; + } + return ['success' => false, 'error' => 'Filter not found']; + } + + /** + * Set a filter as default + */ + public function setDefaultFilter($filterId, $userId) { + // First, clear all defaults + $this->clearDefaultFilters($userId); + + // Then set this one as default + $sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ii", $filterId, $userId); + + if ($stmt->execute()) { + return ['success' => true]; + } + return ['success' => false, 'error' => $this->conn->error]; + } + + /** + * Get the default filter for a user + */ + public function getDefaultFilter($userId) { + $sql = "SELECT filter_id, filter_name, filter_criteria + FROM saved_filters + WHERE user_id = ? AND is_default = 1 + LIMIT 1"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $userId); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($row = $result->fetch_assoc()) { + $row['filter_criteria'] = json_decode($row['filter_criteria'], true); + return $row; + } + return null; + } + + /** + * Clear all default filters for a user (helper method) + */ + private function clearDefaultFilters($userId) { + $sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("i", $userId); + $stmt->execute(); + } + + /** + * Get filter ID by name (helper method) + */ + private function getFilterIdByName($userId, $filterName) { + $sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("is", $userId, $filterName); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($row = $result->fetch_assoc()) { + return $row['filter_id']; + } + return null; + } +} +?> diff --git a/models/TicketModel.php b/models/TicketModel.php index 20254d5..fb10919 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -46,15 +46,15 @@ class TicketModel { return $comments; } - public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null) { + public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null, $filters = []) { // Calculate offset $offset = ($page - 1) * $limit; - + // Build WHERE clause $whereConditions = []; $params = []; $paramTypes = ''; - + // Status filtering if ($status) { $statuses = explode(',', $status); @@ -63,7 +63,7 @@ class TicketModel { $params = array_merge($params, $statuses); $paramTypes .= str_repeat('s', count($statuses)); } - + // Category filtering if ($category) { $categories = explode(',', $category); @@ -72,7 +72,7 @@ class TicketModel { $params = array_merge($params, $categories); $paramTypes .= str_repeat('s', count($categories)); } - + // Type filtering if ($type) { $types = explode(',', $type); @@ -81,7 +81,7 @@ class TicketModel { $params = array_merge($params, $types); $paramTypes .= str_repeat('s', count($types)); } - + // Search Functionality if ($search && !empty($search)) { $whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)"; @@ -89,6 +89,61 @@ class TicketModel { $params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]); $paramTypes .= 'sssss'; } + + // Advanced search filters + // Date range - created_at + if (!empty($filters['created_from'])) { + $whereConditions[] = "DATE(t.created_at) >= ?"; + $params[] = $filters['created_from']; + $paramTypes .= 's'; + } + if (!empty($filters['created_to'])) { + $whereConditions[] = "DATE(t.created_at) <= ?"; + $params[] = $filters['created_to']; + $paramTypes .= 's'; + } + + // Date range - updated_at + if (!empty($filters['updated_from'])) { + $whereConditions[] = "DATE(t.updated_at) >= ?"; + $params[] = $filters['updated_from']; + $paramTypes .= 's'; + } + if (!empty($filters['updated_to'])) { + $whereConditions[] = "DATE(t.updated_at) <= ?"; + $params[] = $filters['updated_to']; + $paramTypes .= 's'; + } + + // Priority range + if (!empty($filters['priority_min'])) { + $whereConditions[] = "t.priority >= ?"; + $params[] = (int)$filters['priority_min']; + $paramTypes .= 'i'; + } + if (!empty($filters['priority_max'])) { + $whereConditions[] = "t.priority <= ?"; + $params[] = (int)$filters['priority_max']; + $paramTypes .= 'i'; + } + + // Created by user + if (!empty($filters['created_by'])) { + $whereConditions[] = "t.created_by = ?"; + $params[] = (int)$filters['created_by']; + $paramTypes .= 'i'; + } + + // Assigned to user (including unassigned option) + if (!empty($filters['assigned_to'])) { + if ($filters['assigned_to'] === 'unassigned') { + $whereConditions[] = "t.assigned_to IS NULL"; + } else { + $whereConditions[] = "t.assigned_to = ?"; + $params[] = (int)$filters['assigned_to']; + $paramTypes .= 'i'; + } + } $whereClause = ''; if (!empty($whereConditions)) { diff --git a/views/DashboardView.php b/views/DashboardView.php index 5caf3ca..16ce407 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -197,6 +197,7 @@ class="search-box" value=""> + @@ -468,7 +469,127 @@ + + + + \ No newline at end of file