diff --git a/admin/database_updates/2.5.1.php b/admin/database_updates/2.5.1.php new file mode 100644 index 00000000..e48d5a30 --- /dev/null +++ b/admin/database_updates/2.5.1.php @@ -0,0 +1,41 @@ + 0 + AND sla_resolution_minutes > 0 + AND ticket_resolution_due_at IS NOT NULL + AND ticket_resolved_at IS NULL + AND ticket_closed_at IS NULL + AND ticket_archived_at IS NULL"); diff --git a/admin/modals/ticket_status/ticket_status_add.php b/admin/modals/ticket_status/ticket_status_add.php index cbdc4e1f..aca70aac 100644 --- a/admin/modals/ticket_status/ticket_status_add.php +++ b/admin/modals/ticket_status/ticket_status_add.php @@ -33,6 +33,20 @@ ob_start(); + +
+ +
+
+ +
+ +
+ Tickets sitting in a paused status never warn or breach on resolution. Time already spent is kept and the deadline moves out when the ticket comes back. +
+
+ +
+
+ +
+ +
+ Tickets sitting in a paused status never warn or breach on resolution. Time already spent is kept and the deadline moves out when the ticket comes back. +
+ +
+
+ + +
+ +
+
+

+
+
+
+ + + + + + + + + + + + + + + + + 0 AND ticket_client_id = $client_id AND $period_query" + )); + + $ticket_count = intval($stats['ticket_count']); + if ($ticket_count == 0) { + continue; + } + $any_rows = true; + + $response_met = intval($stats['response_met']); + $response_missed = intval($stats['response_missed']); + $resolution_met = intval($stats['resolution_met']); + $resolution_missed = intval($stats['resolution_missed']); + + $response_judged = $response_met + $response_missed; + $response_percent = $response_judged ? round($response_met / $response_judged * 100, 1) : null; + + $resolution_judged = $resolution_met + $resolution_missed; + $resolution_percent = $resolution_judged ? round($resolution_met / $resolution_judged * 100, 1) : null; + + $avg_time_to_respond = is_null($stats['avg_response_seconds']) ? '-' : secondsToTime($stats['avg_response_seconds']); + + // Resolution time is measured in clock time actually spent - + // paused spells are excluded, which is what the SLA judged on + $avg_time_to_resolve = '-'; + $resolved_minutes_total = 0; + $resolved_count = 0; + $sql_resolved = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_sla_id > 0 AND ticket_client_id = $client_id AND ticket_resolved_at IS NOT NULL AND $period_query"); + while ($resolved_row = mysqli_fetch_assoc($sql_resolved)) { + $resolved_minutes_total += getTicketSlaConsumedMinutes($resolved_row['ticket_id']); + $resolved_count++; + } + if ($resolved_count > 0) { + $avg_time_to_resolve = secondsToTime(($resolved_minutes_total / $resolved_count) * 60); + } + ?> + + + + + + + + + + + + + + + + +
ClientTicketsResponse metResponse missedResponse %Resolution metResolution missedResolution %Avg time to respondAvg clock to resolve
No tickets with an SLA in this period.
+
+ + Time to respond is wall-clock from ticket creation. Time to resolve counts only business hours with the SLA clock running, so paused time is excluded. + +
+
+ +
+ + + 0 $where" + )); + + $compliance = [ + 'ticket_count' => intval($row['ticket_count']), + 'response_met' => intval($row['response_met']), + 'response_missed' => intval($row['response_missed']), + 'response_pending' => intval($row['response_pending']), + 'resolution_met' => intval($row['resolution_met']), + 'resolution_missed' => intval($row['resolution_missed']), + 'resolution_pending' => intval($row['resolution_pending']), + ]; + + // Percentages count only judged tickets - a ticket still in flight is + // neither a hit nor a miss + $response_judged = $compliance['response_met'] + $compliance['response_missed']; + $compliance['response_percent'] = $response_judged ? round($compliance['response_met'] / $response_judged * 100, 1) : null; + + $resolution_judged = $compliance['resolution_met'] + $compliance['resolution_missed']; + $compliance['resolution_percent'] = $resolution_judged ? round($compliance['resolution_met'] / $resolution_judged * 100, 1) : null; + + return $compliance; +} + +// Colour the headline figures the way the ticket list colours rows +function slaPercentDisplay($percent) +{ + if (is_null($percent)) { + return "-"; + } + if ($percent >= 95) { + return "$percent%"; + } + if ($percent >= 80) { + return "$percent%"; + } + return "$percent%"; +} + +$overall = getSlaCompliance("AND YEAR(ticket_created_at) = $year"); + +?> + +
+
+

SLA Summary

+
+ +
+
+
+
+ +
+ + +

No tickets raised in carried an SLA. Assign SLAs under Admin > SLAs to start tracking.

+ + +
+
+
+ +
+ Tickets with an SLA + +
+
+
+
+
+ +
+ Response compliance + +
+
+
+
+
+ +
+ Resolution compliance + +
+
+
+
+ +
+
+

By Priority ()

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
PriorityTicketsResponse metResponse missedResponse %Resolution metResolution missedResolution %
+
+
+
+ +
+
+

By Month ()

+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
MonthTicketsResponse %Resolution %Still open
+
+
+
+ + Percentages count only tickets whose targets have been judged - tickets still awaiting a response or resolution are excluded until their outcome is known. Tickets raised before SLAs were assigned carry no SLA and are not counted. + + + +
+
+ +Priority: Low Priority: Med Priority: High + Priority: Urgent Resolved Total Time worked (H:M:S) Avg time to respond @@ -105,6 +106,11 @@ $sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients $row = mysqli_fetch_assoc($sql_high_ticket_count); $high_ticket_count = intval($row['high_ticket_count']); + // Breakdown tickets for each priority - Urgent + $sql_urgent_ticket_count = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS urgent_ticket_count FROM tickets WHERE YEAR(ticket_created_at) = $year AND ticket_client_id = $client_id AND ticket_priority = 'Urgent'"); + $row = mysqli_fetch_assoc($sql_urgent_ticket_count); + $urgent_ticket_count = intval($row['urgent_ticket_count']); + // Used to calculate average time to respond to tickets that were raised in period specified $sql_tickets_respond = mysqli_query($mysqli, "SELECT ticket_created_at, ticket_first_response_at FROM tickets WHERE YEAR(ticket_created_at) = $year AND ticket_client_id = $client_id"); @@ -158,6 +164,7 @@ $sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients + @@ -189,6 +196,7 @@ $sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients Priority: Low Priority: Med Priority: High + Priority: Urgent Resolved Total Time worked (H:M:S) Avg time to respond @@ -227,6 +235,11 @@ $sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients $row = mysqli_fetch_assoc($sql_high_ticket_count); $high_ticket_count = intval($row['high_ticket_count']); + // Breakdown tickets for each priority - Urgent + $sql_urgent_ticket_count = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS urgent_ticket_count FROM tickets WHERE YEAR(ticket_created_at) = $year AND MONTH(ticket_created_at) = $month AND ticket_client_id = $client_id AND ticket_priority = 'Urgent'"); + $row = mysqli_fetch_assoc($sql_urgent_ticket_count); + $urgent_ticket_count = intval($row['urgent_ticket_count']); + // Used to calculate average time to respond to tickets that were raised in period specified $sql_tickets_respond = mysqli_query($mysqli, "SELECT ticket_created_at, ticket_first_response_at FROM tickets WHERE YEAR(ticket_created_at) = $year AND MONTH(ticket_created_at) = $month AND ticket_client_id = $client_id"); @@ -279,6 +292,7 @@ $sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients + diff --git a/agent/ticket.php b/agent/ticket.php index 0c51c4aa..063330de 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -117,6 +117,7 @@ if (isset($_GET['ticket_id'])) { $ticket_resolution_due_at = escapeHtml($row['ticket_resolution_due_at']); $ticket_response_sla_met = $row['ticket_response_sla_met']; $ticket_resolution_sla_met = $row['ticket_resolution_sla_met']; + $ticket_sla_paused = intval($row['ticket_status_pauses_sla']); $ticket_sla_name = "None"; if ($ticket_sla_id) { $sla_name_sql = mysqli_query($mysqli, "SELECT sla_name FROM slas WHERE sla_id = $ticket_sla_id"); @@ -911,7 +912,11 @@ if (isset($_GET['ticket_id'])) {
- Resolve by: + Resolve by: + Paused + + + " : ""; } ?>
diff --git a/agent/ticket_kanban.php b/agent/ticket_kanban.php index 5a0976f3..de5632ca 100644 --- a/agent/ticket_kanban.php +++ b/agent/ticket_kanban.php @@ -108,9 +108,22 @@ $kanban = array_values($statuses); } else { $ticket_priority_color = "info"; } + + // SLA state, same stages the ticket list colours on. A paused + // ticket drops its at-risk flag but keeps a recorded breach. + $ticket_sla_alert_stage = max(intval($item['ticket_response_sla_alert_stage']), intval($item['ticket_resolution_sla_alert_stage'])); + if (intval($item['ticket_status_pauses_sla']) && $ticket_sla_alert_stage < 2) { + $ticket_sla_alert_stage = 0; + } + $ticket_sla_class = ''; + if ($ticket_sla_alert_stage == 2) { + $ticket_sla_class = ' border-danger'; + } elseif ($ticket_sla_alert_stage == 1) { + $ticket_sla_class = ' border-warning'; + } ?> -
@@ -119,6 +132,12 @@ $kanban = array_values($statuses); + + + + + + diff --git a/agent/ticket_list.php b/agent/ticket_list.php index 5fccefcc..c24c1a27 100644 --- a/agent/ticket_list.php +++ b/agent/ticket_list.php @@ -92,6 +92,11 @@ $ticket_closed_at = escapeHtml($row['ticket_closed_at']); // SLA alert stages are maintained by cron/ticket_sla.php (1 = warned, 2 = breached) $ticket_sla_alert_stage = max(intval($row['ticket_response_sla_alert_stage']), intval($row['ticket_resolution_sla_alert_stage'])); + // A paused ticket isn't running down its clock, so drop the + // at-risk warning - a breach already recorded still stands + if (intval($row['ticket_status_pauses_sla']) && $ticket_sla_alert_stage < 2) { + $ticket_sla_alert_stage = 0; + } if (empty($ticket_updated_at)) { if (!empty($ticket_closed_at)) { $ticket_updated_at_display = "

Never

"; diff --git a/agent/tickets.php b/agent/tickets.php index a2ec0c32..70cf91ac 100644 --- a/agent/tickets.php +++ b/agent/tickets.php @@ -69,6 +69,24 @@ if (isset($_GET['category']) & !empty($_GET['category'])) { // Default - any $ticket_assigned_query = ''; $ticket_assigned_filter_id = ''; +// SLA state filter - breached / at risk / met / no SLA +$ticket_sla_query = ''; +$ticket_sla_filter = ''; +if (isset($_GET['sla']) && !empty($_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'; + } +} + if (isset($_GET['assigned']) & !empty($_GET['assigned'])) { if ($_GET['assigned'] == 'unassigned') { $ticket_assigned_query = 'AND ticket_assigned_to = 0'; @@ -109,6 +127,7 @@ $query = $category_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%') + $ticket_sla_query $ticket_billable_snippet $ticket_project_snippet $access_permission_query_overide @@ -211,6 +230,24 @@ $sql_categories_filter = mysqli_query(
+ 0; + if ($sla_filter_in_use) { ?> +
+
+ +
+
+ +