SLAs - the response and resolution targets tickets are held to, and * when their clocks run. * * One list with four views: the SLAs in use and the archived ones, and the * closure days coming up and gone by - holidays and other days the office is * shut, when no clock runs. Each SLA shows its targets as business time and * as typed (48 hours of a 9-5 day is six business days), the priorities it is * the default for, and what uses it. Beside the list: the default SLA for * each ticket priority, the business hours the clocks keep and who is warned, * what is on tickets now, and how a ticket comes by its SLA. * * The engine is functions/sla.php and the handlers admin/post/sla.php. A * client has no SLAs of its own: its contract's come first * (functions/contract_billing.php), and the defaults here cover the rest. */ $show = (string) ($_GET['show'] ?? ''); $show = in_array($show, ['archived', 'days', 'past'], true) ? $show : ''; $on_days = $show === 'days' || $show === 'past'; // Default Column Sortby Filter - SLAs by name; closure days by date, the next one first and the last one gone first $sort = $on_days ? "holiday_date" : "sla_name"; $order = $show === 'past' ? "DESC" : "ASC"; require_once "includes/inc_all_admin.php"; // filter_header.php fills in canned_date=custom when no range was asked for - not a filter here, so not carried into the page links if (($_GET['canned_date'] ?? '') === 'custom' && empty($_GET['dtf'])) { unset($_GET['canned_date']); } $csrf = $_SESSION['csrf_token']; $today = date('Y-m-d'); // Business hours, who is warned, and whether cron is there to do the warning $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_business_days, config_business_hours_start, config_business_hours_end, config_sla_warning_percent, config_sla_notification_email, config_enable_cron, config_cron_last_dispatch_at FROM settings WHERE company_id = 1")); $business_days = array_values(array_filter(array_map('intval', explode(',', (string) $row['config_business_days'])), function ($day) { return $day >= 1 && $day <= 7; })); $business_hours_start = substr((string) $row['config_business_hours_start'], 0, 5); $business_hours_end = substr((string) $row['config_business_hours_end'], 0, 5); $warning_percent = intval($row['config_sla_warning_percent']); $notification_email = (string) $row['config_sla_notification_email']; $day_minutes = slaBusinessDayMinutes(); $weekdays = [1 => 'Mon', 2 => 'Tue', 3 => 'Wed', 4 => 'Thu', 5 => 'Fri', 6 => 'Sat', 7 => 'Sun']; $views = [ '' => ['label' => 'Active', 'icon' => 'fa-stopwatch', 'title' => 'The SLAs new tickets can get'], 'archived' => ['label' => 'Archived', 'icon' => 'fa-archive', 'title' => 'Kept on the tickets that have them, not given to new ones'], 'days' => ['label' => 'Closure days', 'icon' => 'fa-calendar-times', 'title' => 'Holidays and other days the office is shut - today and coming up'], 'past' => ['label' => 'Past closure days', 'icon' => 'fa-history', 'title' => 'Closure days gone by - kept, as the time on older tickets is counted around them'], ]; // The search is for the side being looked at - SLAs or closure days - and leaves the other side's counts alone $sla_q = $on_days ? '' : $q; $day_q = $on_days ? $q : ''; $sla_search = "(sla_name LIKE '%$sla_q%' OR sla_description LIKE '%$sla_q%')"; $day_search = "(holiday_name LIKE '%$day_q%' OR holiday_date LIKE '%$day_q%' OR DATE_FORMAT(holiday_date, '%W %M %e %Y') LIKE '%$day_q%')"; $counts = mysqli_fetch_row(mysqli_query($mysqli, "SELECT (SELECT COUNT(*) FROM slas WHERE $sla_search AND sla_archived_at IS NULL), (SELECT COUNT(*) FROM slas WHERE $sla_search AND sla_archived_at IS NOT NULL), (SELECT COUNT(*) FROM business_holidays WHERE $day_search AND holiday_date >= '$today'), (SELECT COUNT(*) FROM business_holidays WHERE $day_search AND holiday_date < '$today')")); $view_counts = array_combine(array_keys($views), array_map('intval', $counts)); // Only these sort - an unknown column would take the page down $sortable_columns = $on_days ? [ 'holiday_date' => "holiday_date $order", 'holiday_name' => "holiday_name $order, holiday_date ASC", ] : [ 'sla_name' => "sla_name $order", 'sla_response_minutes' => "sla_response_minutes $order, sla_name ASC", // The ones that only time the first reply go last either way 'sla_resolution_minutes' => "sla_resolution_minutes IS NULL, sla_resolution_minutes $order, sla_name ASC", 'tickets' => "n_tickets $order, sla_name ASC", ]; if (!isset($sortable_columns[$sort])) { $sort = $on_days ? 'holiday_date' : 'sla_name'; } if ($on_days) { $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS holiday_id, holiday_date, holiday_name FROM business_holidays WHERE $day_search AND holiday_date " . ($show === 'past' ? '<' : '>=') . " '$today' ORDER BY {$sortable_columns[$sort]} LIMIT $record_from, $record_to"); } else { // Tickets: the open ones with the SLA on them now $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS sla_id, sla_name, sla_description, sla_response_minutes, sla_resolution_minutes, sla_archived_at, (SELECT COUNT(*) FROM tickets WHERE ticket_sla_id = sla_id AND " . TICKET_OPEN_SQL . ") AS n_tickets FROM slas WHERE $sla_search AND sla_archived_at IS " . ($show === 'archived' ? 'NOT NULL' : 'NULL') . " ORDER BY {$sortable_columns[$sort]}, sla_id ASC LIMIT $record_from, $record_to"); } $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); $rows = []; while ($list_row = mysqli_fetch_assoc($sql)) { $rows[] = $list_row; } // The default SLA for each priority (sla_assignments, client 0), and the other way round - what each SLA is the default for $defaults = []; $sql_defaults = mysqli_query($mysqli, "SELECT sla_assignment_priority, sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = 0"); while ($default_row = mysqli_fetch_assoc($sql_defaults)) { $defaults[$default_row['sla_assignment_priority']] = intval($default_row['sla_assignment_sla_id']); } $default_for = []; foreach (TICKET_PRIORITIES as $priority) { if (!empty($defaults[$priority])) { $default_for[$defaults[$priority]][] = $priority; } } // The SLAs the defaults can pick - the active ones, and an archived one a priority still points at, so it shows why tickets get none $sla_choices = contractSlaChoices(array_values($defaults)); $active_slas = count(array_filter($sla_choices, function ($choice) { return empty($choice['sla_archived_at']); })); // The contracts and contract templates that name each SLA for a priority $sla_used = []; if (!$on_days && $rows && function_exists('contractsReady') && contractsReady()) { foreach (['contracts' => 'contract', 'templates' => 'contract_template'] as $kind => $prefix) { $columns = array_map(function ($part) use ($prefix) { return "{$prefix}_sla_{$part}_id"; }, CONTRACT_SLA_PRIORITIES); try { $sql_used = mysqli_query($mysqli, "SELECT " . implode(', ', $columns) . " FROM {$prefix}s WHERE {$prefix}_archived_at IS NULL AND " . implode(' + ', $columns) . " > 0"); while ($used_row = mysqli_fetch_row($sql_used)) { foreach (array_unique(array_filter(array_map('intval', $used_row))) as $used_sla_id) { $sla_used[$used_sla_id][$kind] = ($sla_used[$used_sla_id][$kind] ?? 0) + 1; } } } catch (Throwable $e) { // The contracts tables arrive with a database update - before it, nothing names an SLA } } } // On tickets now - the same rules the ticket list filters by, so each count matches the list its link opens $ticket_scope = clientScopeSql('ticket_client_id'); $now_open = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE " . TICKET_OPEN_SQL . " AND ticket_sla_id > 0 $ticket_scope"))[0]); $now_breached = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_resolved_at IS NULL AND " . dashboardSlaBreachedSql() . " $ticket_scope"))[0]); $now_at_risk = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id WHERE " . TICKET_OPEN_SQL . " 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) $ticket_scope"))[0]); // The next day the office is shut, for the business hours card $next_closure = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT holiday_date, holiday_name, (SELECT COUNT(*) FROM business_holidays WHERE holiday_date >= '$today') AS coming FROM business_holidays WHERE holiday_date >= '$today' ORDER BY holiday_date ASC LIMIT 1")); // Warnings and breaches are sent by the Ticket SLA Monitor job - nothing goes out while cron or the job is off $alerts_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_enabled FROM cron_jobs WHERE cron_job_name = 'ticket_sla' LIMIT 1")); $alerts_stopped = ''; if (intval($row['config_enable_cron']) !== 1) { $alerts_stopped = 'Cron is switched off'; } elseif (empty($row['config_cron_last_dispatch_at']) || time() - strtotime($row['config_cron_last_dispatch_at']) >= 300) { $alerts_stopped = "Cron isn't running"; } elseif ($alerts_job && intval($alerts_job['cron_job_enabled']) !== 1) { $alerts_stopped = 'The Ticket SLA Monitor job is turned off'; } // An SLA's targets in a line - "Respond in 4 business hours, resolve in 6 business days" $targets_text = function ($sla) { return 'Respond in ' . formatSlaMinutes($sla['sla_response_minutes']) . (intval($sla['sla_resolution_minutes'] ?? 0) > 0 ? ', resolve in ' . formatSlaMinutes($sla['sla_resolution_minutes']) : ''); }; // A target for the list: how long it is in business time, and under it the hours as typed once that is days $target_html = function ($minutes) use ($day_minutes) { $minutes = intval($minutes); $html = '' . escapeHtml(ucfirst(formatSlaMinutes($minutes))) . ''; if ($minutes >= ($day_minutes ?: 1440)) { $html .= '
' . escapeHtml(formatSlaClockMinutes($minutes)) . '
'; } return $html; }; // "32 open tickets, 10 contracts" - what has an SLA on it, and what gives it to new tickets $used_text = function ($sla) use ($sla_used) { $n = [ 'open ticket' => intval($sla['n_tickets']), 'contract' => intval($sla_used[intval($sla['sla_id'])]['contracts'] ?? 0), 'contract template' => intval($sla_used[intval($sla['sla_id'])]['templates'] ?? 0), ]; $parts = []; foreach (array_filter($n) as $word => $count) { $parts[] = number_format($count) . " $word" . ($count === 1 ? '' : 's'); } return $parts; }; // A closure day's distance - "Today", "Tomorrow", "in 12 days", "3 months ago" $day_distance = function ($date) use ($today) { $days = intval(round((strtotime($date) - strtotime($today)) / 86400)); if (abs($days) <= 1) { return [-1 => 'Yesterday', 0 => 'Today', 1 => 'Tomorrow'][$days]; } if (abs($days) < 14) { return $days > 0 ? "in $days days" : abs($days) . ' days ago'; } return timeAgo($date . ' ' . date('H:i:s')); }; // A heading sorts its own way round first - names A to Z, the shortest target and the most used at the top - then the other $sort_first = ['sla_name' => 'ASC', 'sla_response_minutes' => 'ASC', 'sla_resolution_minutes' => 'ASC', 'tickets' => 'DESC', 'holiday_name' => 'ASC', 'holiday_date' => $show === 'past' ? 'DESC' : 'ASC']; $sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon, $sort_first) { return '' . $label . ($sort === $column ? " $order_icon" : '') . ''; }; // A view keeps the search only on its own side - a name typed for an SLA is no use against closure days $pill_url = function ($view) use ($on_days) { $same_side = in_array($view, ['days', 'past'], true) === $on_days; return '?' . http_build_query(array_filter(['q' => $same_side ? ($_GET['q'] ?? '') : '', 'show' => $view])); }; ?>
"> 0 && !in_array(intval(date('N', strtotime($date))), $business_days, true); ?>
,
A - not one of your business days anyway
·
">
Archived
0 ? $target_html($resolution) : 'Response only' ?> ', array_map('escapeHtml', $used)) : 'Not used yet' ?>
'closure days', 'icon' => 'fa-calendar-times', 'filtered' => $q !== '', 'views' => ['show'], 'view' => $show === 'past' ? [ 'title' => 'No closure days gone by', 'icon' => 'fa-history', 'text' => 'A closure day stays on record here once it has passed.', 'actions' => [], ] : [], 'title' => 'No closure days coming up', 'text' => 'Holidays and other days the office is shut. No SLA clock runs on them.', 'actions' => [ ['label' => 'New Closure Day', 'modal' => 'modals/sla/holiday_add.php'], ['label' => 'Add US holidays', 'modal' => 'modals/sla/holiday_generate.php', 'icon' => 'fa-magic'], ], ] : [ 'thing' => 'SLAs', 'icon' => 'fa-stopwatch', 'filtered' => $q !== '', 'archived' => $show === 'archived', 'views' => ['show'], 'text' => 'How long there is to first reply to a ticket, and to resolve it. SLAs are optional - with none, nothing about tickets changes.', 'actions' => [['label' => 'New SLA', 'modal' => 'modals/sla/sla_add.php']], ]; require_once "../includes/filter_footer.php"; ?>

On tickets now

Open with an SLA
0
Close to a target
0
Missed a target

SLAs are optional. A new ticket gets the SLA its client's contract gives its priority, else the default for the priority. With neither, it has none and nothing about it changes.

Targets are business time. 0 ? 'Four hours given an hour before the day ends runs out three hours into the next business day - and 24 hours is ' . escapeHtml(formatSlaMinutes(1440)) . '.' : 'With no business days picked the clocks run around the clock, so a target is plain time.' ?>

Two clocks. Response runs until the first reply. Resolution runs until the ticket is resolved, and stops while it is in a status that pauses it, like On Hold - set in Ticket Statuses.

How you're doing. The SLA Summary and SLA by Client reports show how often targets were met.

Default SLA by priority

A client's contract comes first, for the priorities it gives an SLA. These cover the rest - clients with no contract in force, and priorities a contract leaves alone.

Business hours and alerts

$day_name) { ?> >
to
SLA clocks only run in these hours, on these days. With no day picked they run around the clock. closure day coming up - next, . No closure days are coming up.
% of the target
A ticket's agent is warned when this much of a target is gone, and told again if it is missed - in ITFlow and by email.
A shared address that gets every warning and missed target too. Leave it empty for none.
, so no warnings go out and missed targets aren't recorded until it's back. See Maintenance > Cron.