better filtering and searching

This commit is contained in:
2026-01-09 11:20:27 -05:00
parent f9d9c775fb
commit 962724d811
10 changed files with 1388 additions and 9 deletions

136
api/audit_log.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
/**
* Audit Log API Endpoint
* Handles fetching filtered audit logs and CSV export
* Admin-only access
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => 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();
?>

179
api/saved_filters.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
/**
* Saved Filters API Endpoint
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => 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();
?>

View File

@@ -2807,3 +2807,196 @@ code.inline-code {
color: var(--terminal-cyan); color: var(--terminal-cyan);
font-style: italic; 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;
}

View File

@@ -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();
}
}
});

View File

@@ -48,9 +48,20 @@ class DashboardController {
} }
} }
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null) // If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
// Get tickets with pagination, sorting, and search // Build advanced search filters array
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search); $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 // Get categories and types for filters
$categories = $this->getCategories(); $categories = $this->getCategories();

View File

@@ -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);

View File

@@ -321,4 +321,123 @@ class AuditLogModel {
$stmt->close(); $stmt->close();
return $timeline; 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)
];
}
} }

View File

@@ -0,0 +1,189 @@
<?php
/**
* SavedFiltersModel
* Handles saving, loading, and managing user's custom search filters
*/
class SavedFiltersModel {
private $conn;
public function __construct($conn) {
$this->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;
}
}
?>

View File

@@ -46,15 +46,15 @@ class TicketModel {
return $comments; 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 // Calculate offset
$offset = ($page - 1) * $limit; $offset = ($page - 1) * $limit;
// Build WHERE clause // Build WHERE clause
$whereConditions = []; $whereConditions = [];
$params = []; $params = [];
$paramTypes = ''; $paramTypes = '';
// Status filtering // Status filtering
if ($status) { if ($status) {
$statuses = explode(',', $status); $statuses = explode(',', $status);
@@ -63,7 +63,7 @@ class TicketModel {
$params = array_merge($params, $statuses); $params = array_merge($params, $statuses);
$paramTypes .= str_repeat('s', count($statuses)); $paramTypes .= str_repeat('s', count($statuses));
} }
// Category filtering // Category filtering
if ($category) { if ($category) {
$categories = explode(',', $category); $categories = explode(',', $category);
@@ -72,7 +72,7 @@ class TicketModel {
$params = array_merge($params, $categories); $params = array_merge($params, $categories);
$paramTypes .= str_repeat('s', count($categories)); $paramTypes .= str_repeat('s', count($categories));
} }
// Type filtering // Type filtering
if ($type) { if ($type) {
$types = explode(',', $type); $types = explode(',', $type);
@@ -81,7 +81,7 @@ class TicketModel {
$params = array_merge($params, $types); $params = array_merge($params, $types);
$paramTypes .= str_repeat('s', count($types)); $paramTypes .= str_repeat('s', count($types));
} }
// Search Functionality // Search Functionality
if ($search && !empty($search)) { if ($search && !empty($search)) {
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)"; $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]); $params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'sssss'; $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 = ''; $whereClause = '';
if (!empty($whereConditions)) { if (!empty($whereConditions)) {

View File

@@ -197,6 +197,7 @@
class="search-box" class="search-box"
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>"> value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
<button type="submit" class="btn search-btn">Search</button> <button type="submit" class="btn search-btn">Search</button>
<button type="button" class="btn btn-secondary" onclick="openAdvancedSearch()" title="Advanced Search">⚙ Advanced</button>
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?> <?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
<a href="?" class="clear-search-btn">✗</a> <a href="?" class="clear-search-btn">✗</a>
<?php endif; ?> <?php endif; ?>
@@ -468,7 +469,127 @@
</div> </div>
</div> </div>
<!-- Advanced Search Modal -->
<div class="settings-modal" id="advancedSearchModal" style="display: none;" onclick="closeOnAdvancedSearchBackdropClick(event)">
<div class="settings-content">
<div class="settings-header">
<h3>🔍 Advanced Search</h3>
<button class="close-settings" onclick="closeAdvancedSearch()">✗</button>
</div>
<form id="advancedSearchForm" onsubmit="performAdvancedSearch(event)">
<div class="settings-body">
<!-- Saved Filters -->
<div class="settings-section">
<h4>╔══ Saved Filters ══╗</h4>
<div class="setting-row">
<label for="saved-filters-select">Load Filter:</label>
<select id="saved-filters-select" class="setting-select" style="max-width: 70%;" onchange="loadSavedFilter()">
<option value="">-- Select a saved filter --</option>
</select>
</div>
<div class="setting-row" style="justify-content: flex-end; gap: 0.5rem;">
<button type="button" class="btn btn-secondary" onclick="saveCurrentFilter()" style="padding: 0.5rem 1rem;">💾 Save Current</button>
<button type="button" class="btn btn-secondary" onclick="deleteSavedFilter()" style="padding: 0.5rem 1rem;">🗑 Delete Selected</button>
</div>
</div>
<!-- Search Text -->
<div class="settings-section">
<h4>╔══ Search Criteria ══╗</h4>
<div class="setting-row">
<label for="adv-search-text">Search Text:</label>
<input type="text" id="adv-search-text" class="setting-select" style="max-width: 100%;" placeholder="Search in title, description...">
</div>
</div>
<!-- Date Ranges -->
<div class="settings-section">
<h4>╔══ Date Range ══╗</h4>
<div class="setting-row">
<label for="adv-created-from">Created From:</label>
<input type="date" id="adv-created-from" class="setting-select">
</div>
<div class="setting-row">
<label for="adv-created-to">Created To:</label>
<input type="date" id="adv-created-to" class="setting-select">
</div>
<div class="setting-row">
<label for="adv-updated-from">Updated From:</label>
<input type="date" id="adv-updated-from" class="setting-select">
</div>
<div class="setting-row">
<label for="adv-updated-to">Updated To:</label>
<input type="date" id="adv-updated-to" class="setting-select">
</div>
</div>
<!-- Status/Priority/Category/Type -->
<div class="settings-section">
<h4>╔══ Filters ══╗</h4>
<div class="setting-row">
<label for="adv-status">Status:</label>
<select id="adv-status" class="setting-select" multiple size="4">
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label for="adv-priority-min">Priority Range:</label>
<select id="adv-priority-min" class="setting-select" style="max-width: 90px;">
<option value="">Any</option>
<option value="1">P1</option>
<option value="2">P2</option>
<option value="3">P3</option>
<option value="4">P4</option>
<option value="5">P5</option>
</select>
<span style="color: var(--terminal-green);">to</span>
<select id="adv-priority-max" class="setting-select" style="max-width: 90px;">
<option value="">Any</option>
<option value="1">P1</option>
<option value="2">P2</option>
<option value="3">P3</option>
<option value="4">P4</option>
<option value="5">P5</option>
</select>
</div>
</div>
<!-- User Filters -->
<div class="settings-section">
<h4>╔══ Users ══╗</h4>
<div class="setting-row">
<label for="adv-created-by">Created By:</label>
<select id="adv-created-by" class="setting-select">
<option value="">Any User</option>
<!-- Will be populated by JavaScript -->
</select>
</div>
<div class="setting-row">
<label for="adv-assigned-to">Assigned To:</label>
<select id="adv-assigned-to" class="setting-select">
<option value="">Any User</option>
<option value="unassigned">Unassigned</option>
<!-- Will be populated by JavaScript -->
</select>
</div>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Search</button>
<button type="button" class="btn btn-secondary" onclick="resetAdvancedSearch()">Reset</button>
<button type="button" class="btn btn-secondary" onclick="closeAdvancedSearch()">Cancel</button>
</div>
</form>
</div>
</div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script> <script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
</body> </body>
</html> </html>