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 .= '
| = $sort_link('holiday_name', 'Closure day') ?> | = $sort_link('holiday_date', 'Date') ?> | |
|---|---|---|
|
= escapeHtml($day['holiday_name']) ?>
= $weekday ?>, = $date_text ?>
A = $weekday ?> - not one of your business days anyway
|
= $date_text ?>
= $weekday ?> · = escapeHtml($day_distance($date)) ?>
|
| = $sort_link('sla_name', 'SLA') ?> | = $sort_link('sla_response_minutes', 'Response') ?> | = $sort_link('sla_resolution_minutes', 'Resolution') ?> | = $sort_link('tickets', 'Used by') ?> | |
|---|---|---|---|---|
|
= $name ?>
= 'Default for ' . implode(', ', $default_priorities) ?>
= escapeHtml($sla['sla_description']) ?>
Archived = date('M j, Y', strtotime($sla['sla_archived_at'])) ?>= $default_priorities ? ' - still the default for ' . implode(', ', $default_priorities) . ', so those tickets get none' : '' ?>
= escapeHtml($targets_text($sla)) ?>
|
= $target_html($sla['sla_response_minutes']) ?> | = $resolution > 0 ? $target_html($resolution) : 'Response only' ?> |
= $used ? implode(' ', array_map('escapeHtml', $used)) : 'Not used yet' ?> |
|
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. = $day_minutes > 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.