better filtering and searching
This commit is contained in:
136
api/audit_log.php
Normal file
136
api/audit_log.php
Normal 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
179
api/saved_filters.php
Normal 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();
|
||||||
|
?>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
361
assets/js/advanced-search.js
Normal file
361
assets/js/advanced-search.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -49,8 +49,19 @@ 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();
|
||||||
|
|||||||
15
migrations/012_create_saved_filters.sql
Normal file
15
migrations/012_create_saved_filters.sql
Normal 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);
|
||||||
@@ -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)
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
189
models/SavedFiltersModel.php
Normal file
189
models/SavedFiltersModel.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -46,7 +46,7 @@ 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;
|
||||||
|
|
||||||
@@ -90,6 +90,61 @@ class TicketModel {
|
|||||||
$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)) {
|
||||||
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user