diff --git a/agent/post/ticket.php b/agent/post/ticket.php
index 479fd2aa9..8cc594e00 100644
--- a/agent/post/ticket.php
+++ b/agent/post/ticket.php
@@ -2865,7 +2865,8 @@ if (isExportRequest('export_tickets')) {
$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)';
$filter_summary['SLA'] = 'SLA at risk';
} elseif ($sla_filter == 'paused') {
- $ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1';
+ // Mirrors agent/tickets.php - finished tickets have a verdict, not a pause
+ $ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1 AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL';
$filter_summary['SLA'] = 'SLA paused';
} elseif ($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)';
diff --git a/agent/ticket_kanban.php b/agent/ticket_kanban.php
index 770bfcdf9..fc2590bb7 100644
--- a/agent/ticket_kanban.php
+++ b/agent/ticket_kanban.php
@@ -22,6 +22,9 @@ while ($row = mysqli_fetch_assoc($status_sql)) {
'name' => escapeHtml($row['ticket_status_name']),
'color' => escapeHtml($row['ticket_status_color']),
'pauses_sla' => intval($row['ticket_status_pauses_sla']),
+ // Resolved pauses the clock by finishing the ticket rather than parking it, so
+ // the header marker would be noise on that column (Closed is not a column here)
+ 'show_sla_pause' => intval($row['ticket_status_pauses_sla']) && $status_id != 4,
'tickets' => array()
);
}
@@ -105,7 +108,7 @@ $kanban = array_values($statuses);
= $column['name'] ?>
= count($column['tickets']) ?>
-
+
diff --git a/agent/ticket_list.php b/agent/ticket_list.php
index c5c691605..d3ae532f6 100644
--- a/agent/ticket_list.php
+++ b/agent/ticket_list.php
@@ -231,7 +231,9 @@ if ($tickets) {
// 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']));
- $ticket_sla_paused = intval($row['ticket_status_pauses_sla']);
+ // Only an open ticket can be paused - Resolved and Closed pause the
+ // clock too, but what those tickets have is a verdict, not a pause
+ $ticket_sla_paused = $ticket_is_open && intval($row['ticket_status_pauses_sla']);
// A paused ticket isn't running down its clock, so drop the
// at-risk warning - a breach already recorded still stands
if ($ticket_sla_paused && $ticket_sla_alert_stage < 2) {
diff --git a/agent/tickets.php b/agent/tickets.php
index 09eef198e..0c44ac481 100644
--- a/agent/tickets.php
+++ b/agent/tickets.php
@@ -199,7 +199,8 @@ if (!empty($_GET['sla']) && isset($sla_filter_labels[$_GET['sla']])) {
} 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';
+ // Resolved and Closed pause the clock as well, but they are finished, not parked
+ $ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1 AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL';
} 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') {
diff --git a/cron/ticket_sla.php b/cron/ticket_sla.php
index 9e4472b77..8b5d36e56 100644
--- a/cron/ticket_sla.php
+++ b/cron/ticket_sla.php
@@ -60,6 +60,35 @@ $from_name = $sla_settings['ticket_from_name'];
$now = time();
+/*
+ * Stop any clock that is running when it should not be.
+ *
+ * Every status change calls syncTicketSlaClock(), so this is normally a no-op. It
+ * matters when the pause rules themselves change underneath tickets that are already
+ * parked - the 2.7.0 update makes On Hold pause, and those tickets are still holding
+ * an open interval that would otherwise keep counting as consumed budget. Doing it
+ * here rather than in the migration keeps the business-hours maths in a process that
+ * has the app timezone set, which scripts/update_cli.php does not.
+ *
+ * Only the stopping direction is reconciled. Starting a clock re-bases the resolution
+ * deadline on the remaining budget, which is a real decision about a ticket and belongs
+ * with the status change that caused it, not with a background sweep.
+ */
+$sql_running = mysqli_query($mysqli, "SELECT DISTINCT ticket_id
+ FROM sla_history
+ JOIN tickets ON sla_history_ticket_id = ticket_id
+ LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
+ WHERE sla_history_ended_at IS NULL
+ AND (COALESCE(ticket_status_pauses_sla, 0) = 1
+ OR ticket_resolved_at IS NOT NULL
+ OR ticket_closed_at IS NOT NULL
+ OR ticket_archived_at IS NOT NULL)"
+);
+
+while ($running = mysqli_fetch_assoc($sql_running)) {
+ syncTicketSlaClock(intval($running['ticket_id']));
+}
+
// Queue in-app + email notifications for an SLA event
function sendSlaAlert($ticket, $subject_line, $body_line)
{
diff --git a/functions/app.php b/functions/app.php
index 7fa3f3e29..239fa1127 100644
--- a/functions/app.php
+++ b/functions/app.php
@@ -59,6 +59,35 @@ function getInvoiceBadgeColor($invoice_status) {
* The display name for a ticket status id, RAW. Escaping is the caller's job -
* same convention as getFieldById() above.
*/
+/*
+ * Fixed SLA clock behaviour for the five built-in ticket statuses.
+ *
+ * New and Open always run the resolution clock; On Hold, Resolved and Closed
+ * always stop it, and none of the five can be configured otherwise. A ticket
+ * nobody can work on should not burn resolution budget, and a finished ticket
+ * has a verdict rather than a clock.
+ *
+ * Resolved and Closed additionally stop BOTH clocks through ticket_resolved_at
+ * and ticket_closed_at, which cron/ticket_sla.php and syncTicketSlaClock() read
+ * directly - their behaviour never depended on this flag and still does not.
+ * The flag is set on them so every SLA surface reads one consistent answer.
+ *
+ * Returns 1 or 0 for a built-in status, or null for a custom one (id > 5),
+ * which the admin configures freely.
+ */
+function getTicketStatusSlaLock($ticket_status_id)
+{
+ $locked_statuses = [
+ 1 => 0, // New
+ 2 => 0, // Open
+ 3 => 1, // On Hold
+ 4 => 1, // Resolved
+ 5 => 1, // Closed
+ ];
+
+ return $locked_statuses[intval($ticket_status_id)] ?? null;
+}
+
function getTicketStatusName($ticket_status) {
global $mysqli;
diff --git a/setup/seed_data.php b/setup/seed_data.php
index d68fa87d4..48ad0d683 100644
--- a/setup/seed_data.php
+++ b/setup/seed_data.php
@@ -77,11 +77,13 @@ function seedDefaultData($mysqli)
mysqli_query($mysqli,"INSERT INTO calendars SET calendar_name = 'Default', calendar_color = 'blue'");
// Add default ticket statuses
- mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'New', ticket_status_color = '#dc3545'"); // Default ID for new tickets is 1
- mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Open', ticket_status_color = '#007bff'"); // 2
- mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'On Hold', ticket_status_color = '#28a745'"); // 3
- mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Resolved', ticket_status_color = '#343a40'"); // 4 (was auto-close)
- mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Closed', ticket_status_color = '#343a40'"); // 5
+ // ticket_status_pauses_sla is fixed for these five and not editable in the admin UI -
+ // see getTicketStatusSlaLock() in functions/app.php. Keep the two in step.
+ mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'New', ticket_status_color = '#dc3545', ticket_status_pauses_sla = 0"); // Default ID for new tickets is 1
+ mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Open', ticket_status_color = '#007bff', ticket_status_pauses_sla = 0"); // 2
+ mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'On Hold', ticket_status_color = '#28a745', ticket_status_pauses_sla = 1"); // 3
+ mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Resolved', ticket_status_color = '#343a40', ticket_status_pauses_sla = 1"); // 4 (was auto-close)
+ mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Closed', ticket_status_color = '#343a40', ticket_status_pauses_sla = 1"); // 5
// Add default modules
mysqli_query($mysqli, "INSERT INTO modules SET module_name = 'module_client', module_description = 'General client & contact management'");