From 162a597da97aaef58283e57e24a5977fd3e54f38 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Wed, 29 Jul 2026 13:02:27 -0400 Subject: [PATCH] Add SLA pausing, SLA reports, SLA filtering and kanban SLA state (phase 3) Statuses can be flagged to pause the resolution clock; sla_history records the intervals a ticket's clock actually ran and the deadline is re-based on remaining budget when it resumes. Adds SLA Summary and SLA by Client reports, an SLA state filter on the ticket list, SLA colouring on kanban cards, and an Urgent column on the Tickets by Client report. DB update 2.5.1. Also fixes resolution SLA verdicts being skipped when resolving via kanban or the client portal. --- admin/database_updates/2.5.1.php | 41 ++++ .../ticket_status/ticket_status_add.php | 14 ++ .../ticket_status/ticket_status_edit.php | 15 ++ admin/post/ticket_status.php | 14 +- admin/ticket_statuses.php | 9 + agent/ajax.php | 5 + agent/post/ticket.php | 10 + agent/reports/includes/reports_side_nav.php | 14 ++ agent/reports/sla_by_client.php | 171 ++++++++++++++ agent/reports/sla_summary.php | 212 ++++++++++++++++++ agent/reports/ticket_by_client.php | 14 ++ agent/ticket.php | 7 +- agent/ticket_kanban.php | 21 +- agent/ticket_list.php | 5 + agent/tickets.php | 37 +++ api/v1/tickets/resolve.php | 1 + client/post.php | 6 + cron/cron.php | 1 + cron/ticket_email_parser.php | 1 + cron/ticket_sla.php | 11 +- db.sql | 16 ++ functions/sla.php | 180 ++++++++++++++- 22 files changed, 796 insertions(+), 9 deletions(-) create mode 100644 admin/database_updates/2.5.1.php create mode 100644 agent/reports/sla_by_client.php create mode 100644 agent/reports/sla_summary.php 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) { ?> +
+
+ +
+
+ +