state=open|closed (status[] stays as-is) * billable=1&unbilled -> billing=unbilled * * The old page carried the coarse Open/Closed state in a scalar `status` * while the status multi-select posted `status[]`. Both landed in the same * key, so whichever came last in the query string won and the two controls * silently fought each other. They are separate params now. */ if (isset($_GET['status']) && !is_array($_GET['status'])) { if (!isset($_GET['state'])) { $_GET['state'] = strtolower($_GET['status']) === 'closed' ? 'closed' : 'open'; } unset($_GET['status']); } if (isset($_GET['billable']) && $_GET['billable'] == '1' && isset($_GET['unbilled'])) { $_GET['billing'] = 'unbilled'; } unset($_GET['billable'], $_GET['unbilled']); // filter_header.php built this from the raw query string - rebuild it so the // sort links carry the normalised params too $get_copy = $_GET; unset($get_copy['sort'], $get_copy['order']); $url_query_strings_sort = http_build_query($get_copy); /* * filter_header.php strips a sort value down to [a-z_], which blocks injection * but happily leaves an invalid column name behind - ?sort=ticket_id;DROP... * becomes ticket_idtickets, and the ORDER BY then takes the page down with an * uncaught mysqli exception. Only these columns are sortable here, so anything * else falls back to the default. */ $sortable_columns = array( 'ticket_number', 'ticket_subject', 'client_name', 'contact_name', 'ticket_billable', 'ticket_priority', 'ticket_status', 'user_name', 'ticket_updated_at', 'ticket_created_at' ); if (!in_array($sort, $sortable_columns, true)) { $sort = 'ticket_number'; } /* * Builds a link to this page with filter overrides applied. Pass null to drop * a filter. Paging always resets - a page number from the previous filter set * means nothing once the result set changes. */ function ticketsFilterUrl($overrides = []) { $params = $_GET; foreach ($overrides as $name => $value) { if ($value === null) { unset($params[$name]); } else { $params[$name] = $value; } } unset($params['page']); return escapeHtml('?' . http_build_query($params)); } // View - list or kanban $view = ($_GET['view'] ?? '') === 'kanban' ? 'kanban' : (($_GET['view'] ?? '') === 'list' ? 'list' : ''); if (!$view) { // Admin default (0 = list, 2 = kanban) $view = $config_ticket_default_view === 2 ? 'kanban' : 'list'; } // Chips describing what is currently filtering the list, built as each // filter is parsed. Each one renders with an x that drops just that filter. $active_filters = array(); // Specific ticket statuses - takes precedence over the open/closed state $status_filter = array(); if (isset($_GET['status']) && is_array($_GET['status']) && !empty($_GET['status'])) { $status_filter = array_filter(array_map('intval', $_GET['status'])); } /* * Coarse state. Anything with a resolved date is "closed" as far as this page * is concerned, matching what the counts in the header report. */ $state = $_GET['state'] ?? ''; if (!in_array($state, array('open', 'closed', 'all'), true)) { $state = 'open'; } // Billing filter $billing_filter = $_GET['billing'] ?? ''; if (!in_array($billing_filter, array('unbilled', 'invoiced', 'nonbillable'), true)) { $billing_filter = ''; } $ticket_billable_snippet = ''; if ($billing_filter == 'unbilled') { $ticket_billable_snippet = 'AND ticket_billable = 1 AND ticket_invoice_id = 0'; $active_filters[] = array('label' => 'Billing', 'value' => 'Billable, not invoiced', 'drop' => 'billing'); } elseif ($billing_filter == 'invoiced') { $ticket_billable_snippet = 'AND ticket_invoice_id > 0'; $active_filters[] = array('label' => 'Billing', 'value' => 'Invoiced', 'drop' => 'billing'); } elseif ($billing_filter == 'nonbillable') { $ticket_billable_snippet = 'AND ticket_billable = 0'; $active_filters[] = array('label' => 'Billing', 'value' => 'Not billable', 'drop' => 'billing'); } /* * Unbilled work is nearly always on tickets that are already done, so a * billing filter with no state asked for widens to every ticket rather than * returning the empty list the open default would give. The old page did the * same thing invisibly - now it shows up as a chip. */ if ($billing_filter && !isset($_GET['state'])) { $state = 'all'; } // Status snippet - specific statuses win, otherwise the coarse state if ($status_filter) { $status_ids = implode(',', $status_filter); $ticket_status_snippet = "ticket_status IN ($status_ids)"; $status_names = array(); $sql_status_names = mysqli_query($mysqli, "SELECT ticket_status_name FROM ticket_statuses WHERE ticket_status_id IN ($status_ids) ORDER BY ticket_status_order"); while ($row = mysqli_fetch_assoc($sql_status_names)) { $status_names[] = escapeHtml($row['ticket_status_name']); } $active_filters[] = array('label' => 'Status', 'value' => implode(', ', $status_names), 'drop' => 'status'); } elseif ($state == 'closed') { $ticket_status_snippet = 'ticket_resolved_at IS NOT NULL'; } elseif ($state == 'all') { $ticket_status_snippet = '1 = 1'; } else { $ticket_status_snippet = 'ticket_resolved_at IS NULL'; } // Category Filter $category_query = ''; $category_filter = ''; if (!empty($_GET['category'])) { $category_filter = intval($_GET['category']); $category_query = "AND ticket_category = $category_filter"; $active_filters[] = array('label' => 'Category', 'value' => escapeHtml(getFieldById('categories', $category_filter, 'category_name')), 'drop' => 'category'); } // Priority Filter $ticket_priority_query = ''; $priority_filter = ''; if (!empty($_GET['priority']) && in_array($_GET['priority'], array('Low', 'Medium', 'High', 'Urgent'), true)) { $priority_filter = $_GET['priority']; $ticket_priority_query = "AND ticket_priority = '$priority_filter'"; $active_filters[] = array('label' => 'Priority', 'value' => $priority_filter, 'drop' => 'priority'); } // SLA state filter - breached / at risk / paused / met / no SLA $ticket_sla_query = ''; $ticket_sla_filter = ''; $sla_filter_labels = array( 'breached' => 'SLA breached', 'at_risk' => 'SLA at risk', 'paused' => 'SLA paused', 'met' => 'SLA met', 'none' => 'No SLA' ); if (!empty($_GET['sla']) && isset($sla_filter_labels[$_GET['sla']])) { $ticket_sla_filter = $_GET['sla']; if ($ticket_sla_filter == 'breached') { $ticket_sla_query = 'AND ticket_sla_id > 0 AND (ticket_response_sla_alert_stage = 2 OR ticket_resolution_sla_alert_stage = 2 OR ticket_response_sla_met = 0 OR ticket_resolution_sla_met = 0)'; } elseif ($ticket_sla_filter == 'at_risk') { $ticket_sla_query = 'AND ticket_sla_id > 0 AND COALESCE(ticket_status_pauses_sla, 0) = 0 AND (ticket_response_sla_alert_stage = 1 OR ticket_resolution_sla_alert_stage = 1)'; } elseif ($ticket_sla_filter == 'paused') { $ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1'; } elseif ($ticket_sla_filter == 'met') { $ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_response_sla_met = 1 AND (ticket_resolution_sla_met = 1 OR ticket_resolution_due_at IS NULL)'; } elseif ($ticket_sla_filter == 'none') { $ticket_sla_query = 'AND ticket_sla_id = 0'; } $active_filters[] = array('label' => 'SLA', 'value' => $sla_filter_labels[$ticket_sla_filter], 'drop' => 'sla'); } // Ticket assignment filter $ticket_assigned_query = ''; $ticket_assigned_filter_id = ''; if (!empty($_GET['assigned'])) { if ($_GET['assigned'] == 'unassigned') { $ticket_assigned_query = 'AND ticket_assigned_to = 0'; $ticket_assigned_filter_id = 0; $active_filters[] = array('label' => 'Assigned', 'value' => 'Unassigned', 'drop' => 'assigned'); } else { $ticket_assigned_filter_id = intval($_GET['assigned']); $ticket_assigned_query = "AND ticket_assigned_to = $ticket_assigned_filter_id"; $assigned_name = $ticket_assigned_filter_id === intval($session_user_id) ? 'Me' : escapeHtml(getFieldById('users', $ticket_assigned_filter_id, 'user_name')); $active_filters[] = array('label' => 'Assigned', 'value' => $assigned_name, 'drop' => 'assigned'); } } // Project filter $ticket_project_snippet = ''; $ticket_project_filter_id = ''; if (!empty($_GET['project'])) { $ticket_project_filter_id = intval($_GET['project']); $ticket_project_snippet = "AND ticket_project_id = $ticket_project_filter_id"; $active_filters[] = array('label' => 'Project', 'value' => escapeHtml(getFieldById('projects', $ticket_project_filter_id, 'project_name')), 'drop' => 'project'); } // Date range - filter_header.php resolves the canned ranges into $dtf / $dtt $date_filter_active = isset($_GET['canned_date']) && $_GET['canned_date'] !== 'custom' && $_GET['canned_date'] !== 'alltime'; if (!$date_filter_active && !empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') { $date_filter_active = true; } if ($date_filter_active) { $active_filters[] = array('label' => 'Created', 'value' => "$dtf to $dtt", 'drop' => array('canned_date', 'dtf', 'dtt')); } // Search if ($q !== '') { $active_filters[] = array('label' => 'Search', 'value' => stripslashes(escapeHtml($q)), 'drop' => 'q'); } // Ticket client access overide - This is the only way to show tickets without a client to agents with restricted client access $access_permission_query_overide = ''; if ($client_access_string) { $access_permission_query_overide = "AND ticket_client_id IN (0,$client_access_string)"; } /* * Columns the two views need. Explicit rather than SELECT * - the tickets * table carries ticket_details (longtext), and pulling a page of ticket * bodies to render a list that never shows them is pure overhead. */ $ticket_select_columns = "ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, ticket_status, ticket_billable, ticket_schedule, ticket_order, ticket_created_at, ticket_updated_at, ticket_resolved_at, ticket_closed_at, ticket_client_id, ticket_contact_id, ticket_assigned_to, ticket_project_id, ticket_invoice_id, ticket_quote_id, ticket_sla_id, ticket_first_response_at, ticket_response_due_at, ticket_resolution_due_at, ticket_response_sla_met, ticket_resolution_sla_met, ticket_response_sla_alert_stage, ticket_resolution_sla_alert_stage, ticket_status_id, ticket_status_name, ticket_status_color, ticket_status_pauses_sla, client_name, contact_id, contact_name, contact_email, user_name, asset_name, category_name"; $ticket_joins = "LEFT JOIN clients ON ticket_client_id = client_id LEFT JOIN contacts ON ticket_contact_id = contact_id LEFT JOIN users ON ticket_assigned_to = user_id LEFT JOIN assets ON ticket_asset_id = asset_id LEFT JOIN locations ON ticket_location_id = location_id LEFT JOIN vendors ON ticket_vendor_id = vendor_id LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id LEFT JOIN categories ON ticket_category = category_id"; $ticket_where = "WHERE $ticket_status_snippet $ticket_assigned_query $category_query $ticket_priority_query $ticket_sla_query $ticket_billable_snippet $ticket_project_snippet $access_permission_query_overide $client_query AND DATE(ticket_created_at) BETWEEN '$dtf' AND '$dtt' AND (CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR client_name LIKE '%$q%' OR ticket_subject LIKE '%$q%' OR ticket_status_name LIKE '%$q%' OR ticket_priority LIKE '%$q%' OR user_name LIKE '%$q%' OR contact_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR ticket_vendor_ticket_number LIKE '%$q%')"; // Counts for the quick views in the header - scope-wide, not filter-aware $count_where = "$access_permission_query_overide $client_query"; $total_tickets_open = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_resolved_at IS NULL $count_where"))[0]); $total_tickets_closed = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_resolved_at IS NOT NULL $count_where"))[0]); $total_tickets_unassigned = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_assigned_to = 0 AND ticket_resolved_at IS NULL $count_where"))[0]); $user_active_assigned_tickets = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_assigned_to = $session_user_id AND ticket_resolved_at IS NULL $count_where"))[0]); // Only offer the SLA filter once SLAs are actually in use $sla_filter_in_use = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(sla_id) FROM slas WHERE sla_archived_at IS NULL"))[0] > 0; // Gates both the Billing column in the table and the Billing filter select $show_billing_column = $config_module_enable_accounting && lookupUserPermission("module_sales") >= 2; /* * How many of the collapsed filters are set. The panel stays closed even when * filters are active - the count on the filter button and the chips below the * bar are what make them visible, without costing the vertical space an * auto-expanded panel would. */ $hidden_filter_count = 0; foreach (array('status', 'assigned', 'priority', 'category', 'sla', 'project', 'billing') as $panel_filter) { if (!empty($_GET[$panel_filter])) { $hidden_filter_count++; } } if ($date_filter_active) { $hidden_filter_count++; } ?>

Tickets Open | Closed | All

= 2) { ?>
" id="advancedFilter">
null); ?> : Clear all