'open', 'closed' => 'closed', 'all' => 'all'][$_GET['state']] ?? ''; } unset($_GET['state']); if (isset($_GET['billable']) && $_GET['billable'] == '1' && isset($_GET['unbilled'])) { $_GET['billing'] = 'unbilled'; } unset($_GET['billable'], $_GET['unbilled']); // filter_header.php fills in canned_date=custom when no range was asked for - not a filter, so not carried into links if (($_GET['canned_date'] ?? '') === 'custom' && empty($_GET['dtf'])) { unset($_GET['canned_date']); } // filter_header.php built this from the raw query string - again, from the one above $get_copy = $_GET; unset($get_copy['sort'], $get_copy['order']); $url_query_strings_sort = http_build_query($get_copy); // List or board - the admin's default when the link doesn't say $view = $_GET['view'] ?? ''; if (!in_array($view, ['list', 'kanban'], true)) { $view = intval($config_ticket_default_view) === 2 ? 'kanban' : 'list'; } $filter_url = function ($changes) { $params = array_merge($_GET, $changes); unset($params['page']); return '?' . http_build_query(array_filter($params, function ($v) { return $v !== '' && $v !== null && $v !== []; })); }; // Only offer what is in use $sla_in_use = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(sla_id) FROM slas WHERE sla_archived_at IS NULL"))[0] > 0; $show_billing = $config_module_enable_accounting && lookupUserPermission("module_sales") >= 2; /* * The filters. $active_filters are the ones tucked away under the Filters * button, shown as chips so a filter is never invisible. */ $active_filters = []; $filter_sql = []; // Client (all clients) $client_filter = 0; if (!$client_url && !empty($_GET['client'])) { $client_filter = intval($_GET['client']); $filter_sql[] = "AND ticket_client_id = $client_filter"; } // Assigned - an agent, or nobody $assigned_filter = $_GET['assigned'] ?? ''; if ($assigned_filter === 'unassigned') { $filter_sql[] = 'AND ticket_assigned_to = 0'; } elseif (intval($assigned_filter)) { $assigned_filter = intval($assigned_filter); $filter_sql[] = "AND ticket_assigned_to = $assigned_filter"; } else { $assigned_filter = ''; } // Specific statuses $status_filter = []; if (isset($_GET['status']) && is_array($_GET['status'])) { $status_filter = array_values(array_unique(array_filter(array_map('intval', $_GET['status'])))); } if ($status_filter) { $status_ids = implode(',', $status_filter); $filter_sql[] = "AND ticket_status IN ($status_ids)"; $status_names = []; $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[] = ['label' => 'Status', 'value' => implode(', ', $status_names), 'drop' => ['status' => null]]; } // Priority $priority_filter = in_array($_GET['priority'] ?? '', TICKET_PRIORITIES, true) ? $_GET['priority'] : ''; if ($priority_filter) { $filter_sql[] = "AND ticket_priority = '$priority_filter'"; $active_filters[] = ['label' => 'Priority', 'value' => $priority_filter, 'drop' => ['priority' => null]]; } // Category $category_filter = intval($_GET['category'] ?? 0); if ($category_filter) { $filter_sql[] = "AND ticket_category = $category_filter"; $active_filters[] = ['label' => 'Category', 'value' => escapeHtml(getFieldById('categories', $category_filter, 'category_name')), 'drop' => ['category' => null]]; } // Project $project_filter = intval($_GET['project'] ?? 0); if ($project_filter) { $filter_sql[] = "AND ticket_project_id = $project_filter"; $active_filters[] = ['label' => 'Project', 'value' => escapeHtml(getFieldById('projects', $project_filter, 'project_name')), 'drop' => ['project' => null]]; } // Tags - a ticket with any of them $tag_filter_ids = tagFilterIds(); if ($tag_filter_ids) { $filter_sql[] = tagFilterSql('ticket', $tag_filter_ids, 'tickets.ticket_id'); $tag_filter_names = []; $sql_tag_names = mysqli_query($mysqli, "SELECT tag_name FROM tags WHERE tag_type = 12 AND tag_id IN (" . implode(',', $tag_filter_ids) . ") ORDER BY tag_name"); while ($row = mysqli_fetch_row($sql_tag_names)) { $tag_filter_names[] = escapeHtml($row[0]); } $active_filters[] = ['label' => 'Tags', 'value' => implode(', ', $tag_filter_names), 'drop' => ['tags' => null]]; } // SLA state - the dashboard's "SLA breached" links here $sla_filters = [ 'breached' => ['SLA breached', '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)'], 'at_risk' => ['SLA at risk', '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)'], 'paused' => ['SLA paused', 'ticket_sla_id > 0 AND ticket_status_pauses_sla = 1 AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL'], 'met' => ['SLA met', 'ticket_sla_id > 0 AND ticket_response_sla_met = 1 AND (ticket_resolution_sla_met = 1 OR ticket_resolution_due_at IS NULL)'], 'none' => ['No SLA', 'ticket_sla_id = 0'], ]; $sla_filter = isset($sla_filters[$_GET['sla'] ?? '']) ? $_GET['sla'] : ''; if ($sla_filter) { $filter_sql[] = "AND ({$sla_filters[$sla_filter][1]})"; $active_filters[] = ['label' => 'SLA', 'value' => $sla_filters[$sla_filter][0], 'drop' => ['sla' => null]]; } // Billing $billing_filters = [ 'unbilled' => ['Billable, not invoiced', 'ticket_billable = 1 AND ticket_invoice_id = 0'], 'invoiced' => ['Invoiced', 'ticket_invoice_id > 0'], 'nonbillable' => ['Not billable', 'ticket_billable = 0'], ]; $billing_filter = isset($billing_filters[$_GET['billing'] ?? '']) ? $_GET['billing'] : ''; if ($billing_filter) { $filter_sql[] = "AND ({$billing_filters[$billing_filter][1]})"; $active_filters[] = ['label' => 'Billing', 'value' => $billing_filters[$billing_filter][0], 'drop' => ['billing' => null]]; } // Created - filter_header.php resolves the canned ranges into $dtf / $dtt $date_filter_active = (isset($_GET['canned_date']) && !in_array($_GET['canned_date'], ['custom', 'alltime'], true)) || (!empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01'); if ($date_filter_active) { $filter_sql[] = "AND DATE(ticket_created_at) BETWEEN '$dtf' AND '$dtt'"; $active_filters[] = ['label' => 'Created', 'value' => escapeHtml("$dtf to $dtt"), 'drop' => ['canned_date' => null, 'dtf' => null, 'dtt' => null]]; } // Search if ($q !== '') { $filter_sql[] = "AND (CONCAT(ticket_prefix, ticket_number) LIKE '%$q%' OR ticket_subject LIKE '%$q%' OR client_name LIKE '%$q%' OR contact_name LIKE '%$q%' OR contact_email LIKE '%$q%' OR ticket_status_name LIKE '%$q%' OR ticket_priority LIKE '%$q%' OR user_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR ticket_vendor_ticket_number LIKE '%$q%' OR category_name LIKE '%$q%' " . tagSearchSql('ticket', $q, 'tickets.ticket_id') . ")"; } $filters = clientScopeSql('ticket_client_id') . " $client_query " . implode(' ', $filter_sql); /* * The views. On the board, Open is "on the board" - everything not closed, * so the Resolved column has its tickets - and the closed ones have no place. */ $open_sql = TICKET_OPEN_SQL; $base_sql = $view === 'kanban' ? 'tickets.ticket_closed_at IS NULL' : $open_sql; $shows = [ 'open' => ['label' => $view === 'kanban' ? 'On the board' : 'Open', 'where' => $base_sql, 'title' => $view === 'kanban' ? 'Everything not closed yet' : 'Neither resolved nor closed'], 'mine' => ['label' => 'Mine', 'icon' => 'fa-user-check', 'where' => "$base_sql AND ticket_assigned_to = " . intval($session_user_id)], 'unassigned' => ['label' => 'Unassigned', 'icon' => 'fa-user-slash', 'where' => "$base_sql AND ticket_assigned_to = 0", 'alarm' => 'warning'], 'reply' => ['label' => 'Needs reply', 'icon' => 'fa-reply', 'where' => ticketNeedsReplySql(), 'alarm' => 'warning', 'title' => 'The client spoke last, or nobody has replied yet'], 'waiting' => ['label' => 'Waiting', 'icon' => 'fa-hourglass-half', 'where' => ticketWaitingSql(), 'title' => 'We replied last and it is with the client - or it is parked in a status that pauses the clock, like On Hold'], 'overdue' => ['label' => 'Overdue', 'icon' => 'fa-stopwatch', 'where' => ticketOverdueSql(), 'alarm' => 'danger', 'title' => 'Past its due date, or its SLA is breached'], 'scheduled' => ['label' => 'Scheduled', 'icon' => 'fa-calendar-check', 'where' => "$open_sql AND ticket_schedule IS NOT NULL", 'title' => 'A visit or call is booked'], ]; // Mentioned me - open tickets with an internal note that @mentions you (functions/ticket_mention.php), after Mine if (ticketMentionsReady()) { $shows = array_slice($shows, 0, 2, true) + ['mentioned' => ['label' => 'Mentioned me', 'icon' => 'fa-at', 'where' => "$base_sql AND " . ticketMentionedSql($session_user_id), 'title' => 'Open tickets with an internal note that mentions you']] + array_slice($shows, 2, null, true); } if ($view === 'list') { $shows['closed'] = ['label' => 'Closed', 'icon' => 'fa-check', 'where' => 'tickets.ticket_resolved_at IS NOT NULL', 'title' => 'Resolved or closed', 'divider' => true]; $shows['all'] = ['label' => 'All', 'where' => '1 = 1']; } // A status or billing filter asked for on its own looks at every ticket - unbilled work is nearly all done already $show = $_GET['show'] ?? ''; if (!isset($shows[$show])) { $show = ($view === 'list' && !isset($_GET['show']) && ($status_filter || $billing_filter)) ? 'all' : 'open'; } // Everything both views select and join - not ticket_details, a page of ticket bodies nobody sees $ticket_select_columns = "tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, ticket_source, ticket_status, ticket_billable, ticket_schedule, ticket_onsite, ticket_order, ticket_due_at, 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_asset_id, ticket_invoice_id, ticket_quote_id, ticket_sla_id, ticket_vendor_ticket_number, 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, contact_photo, user_name, user_avatar, asset_name, category_name, category_color, category_icon, project_prefix, project_number, project_name, " . ticketLastSpokeSql() . " AS ticket_last_spoke"; $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 vendors ON ticket_vendor_id = vendor_id LEFT JOIN projects ON ticket_project_id = project_id LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id LEFT JOIN categories ON ticket_category = category_id"; $ticket_where = "WHERE {$shows[$show]['where']} $filters"; // How many behind each pill - one pass over everything the filters leave $count_sql = []; foreach ($shows as $key => $s) { $count_sql[] = "COALESCE(SUM({$s['where']}), 0)"; } $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " FROM tickets $ticket_joins WHERE 1 = 1 $filters")); $counts = []; foreach (array_keys($shows) as $i => $key) { $counts[$key] = intval($row[$i]); } // How many of the tucked-away filters are set - on the Filters button $hidden_filter_count = count($active_filters); // What the export starts from - the pill, in the export's own terms $export_extra = [ 'state' => $show === 'closed' ? 'closed' : ($show === 'all' ? 'all' : 'open'), 'assigned' => $show === 'mine' ? intval($session_user_id) : ($show === 'unassigned' ? 'unassigned' : ''), 'dtf' => $date_filter_active ? $dtf : '', 'dtt' => $date_filter_active ? $dtt : '', ]; ?>

Tickets

: Clear