Rebuild Admin > SLAs on the standard list pattern: the SLAs are a searchable, sortable, paged list with Active and Archived views and the settings forms beside it, and each SLA shows its targets in business time with the hours typed under it, the priorities it is the default for and what uses it (open tickets, contracts and contract templates); closure days become views of the same list, coming up and past, searched by name, date, month or weekday, and Add US Holidays is a window that says how many of each year are already in; the SLA window takes targets as hours and minutes and refuses a missing name, a missing response target or a resolution target shorter than the response target; archiving says what stops getting the SLA and an archived default is shown as archived; Default SLA by priority uses the select picker, Business hours and alerts has day buttons and warns when cron is not running, and an On tickets now strip links to the tickets close to or past a target; fix business hours that end before they start being accepted and running the SLA clocks around the clock, keep the warning percent within 1 to 99, word the save messages as tickets not yet closed, and fix the backslash shown after saving an SLA with an apostrophe in its name; add slaBusinessDayMinutes, formatSlaClockMinutes and slaTargetMinutes to functions/sla.php; no database change

This commit is contained in:
johnnyq
2026-11-13 02:25:09 -05:00
parent dfb0298245
commit be50f92fe8
9 changed files with 888 additions and 343 deletions

View File

@@ -24,11 +24,18 @@ Update from Maintenance > Update — Queue Update hands the job to cron and it a
- Cron: a job's menu has Run now, Change the schedule, Turn off - which asks first - and Copy the command to run it, which copies the command that runs that one job on the server, where it shows what it does as it goes. The list sorts by its headings, and starts in the order the day runs: the every-minute jobs, then the nightly ones by time.
- Cron: beside the page, At a glance has whether cron is running, when it last ran, how many jobs are on and off, any that failed, what runs next, and the time zone the schedules are in. Turn cron off, which stops every job at once for a restored backup or a test copy, asks first and says what stops.
- Update, Backup and Cron: a page that is keeping itself up to date no longer reloads under you. It waits while a window or a menu is open, while you are typing in a field, and while you have text selected to copy.
- SLAs: Admin > SLAs is rebuilt as a list like every other - it searches, sorts by its headings and has pages - with the settings beside it instead of stacked under it. Each SLA shows what its targets come to in business time as well as the hours typed: with a 9 to 5 day, 48 hours reads 6 business days, with 48 hours under it. It also shows the priorities it is the default for, and what uses it - open tickets, contracts and contract templates.
- SLAs: a target is typed as hours and minutes, not as a count of minutes, and the window says what a day's worth comes to with your business hours. An SLA with no name or no response target, or with a resolution target shorter than its response target, is refused with the reason.
- SLAs: archiving an SLA says first what stops getting it - the priorities it is the default for and the contracts that give it - and that tickets already on it keep their targets. A priority whose default has been archived says so, where it used to read None.
- SLAs: holidays and closure days are views of the same list, coming up and past, searched by name, date, month or day of the week. Each shows its date and how far off it is, and one that falls on a day you're shut anyway says so. Add US holidays opens a window that says how many of each year's are already in and offers the first year with some to add. Removing a day asks first, and says what changes.
- SLAs: beside the list, Default SLA by priority uses the select picker, with each SLA's targets under its name. Business hours and alerts has the business days as buttons, the hours, when to warn and who else to email, and says when cron isn't running, as no warning goes out without it. On tickets now counts the open tickets with an SLA, those close to a target and those that missed one, each a link to the tickets.
### Bug Fixes
- Backups: the list showed only the newest 100. Older backups were still on the server, taking up room, with no way to download or delete them from the page. Every backup can now be reached.
- Backups: the window for exporting the master key said the key "is not written anywhere". An encrypted copy is filed with the other backups; the window now says so, and that every agent is notified of the export.
- SLAs: business hours saved with the day ending before it starts were accepted, and SLA clocks then ran around the clock without saying so - a 4-hour target given late on a Friday came due that evening. They are now refused, with how to set cover around the clock. A warning percent outside 1 to 99 is kept within it.
- SLAs: the message after saving an SLA with an apostrophe in its name showed a backslash in it.
### Developer Updates
@@ -53,6 +60,14 @@ Update from Maintenance > Update — Queue Update hands the job to cron and it a
- `admin/post/cron.php`: the messages follow the buttons - "Cron turned off.", "Mail Queue turned on." - where they said "Cron disabled." and "Cron job enabled."; `enable_cron_job` and `disable_cron_job` load the registry for the job's label. The audit log's wording is unchanged.
- `js/auto_reload.js` holds its reload while `.modal.show`, `.dropdown-menu.show` or `.swal2-container` is on the page, while an `input`, `textarea` or `select` has focus, or while there is a selection, and looks again every two seconds. Before, the Backup page could reload away the Change the time window, or what was being typed into its retention boxes, while a backup was being made.
- Hooks for tests and scripts: `data-cron-state` on the hero, `data-cron-jobs` with `data-cron-job`, `data-cron-status` and `data-cron-enabled` on each job's `<tbody>`, `data-cron-run`, `data-cron-edit`, `data-cron-off` and `data-cron-on` in its menu, `data-cron-reason`, `data-cron-error` and `data-cron-dismiss` under it, `data-cron-glance`, `data-cron-command`, `data-cron-switch` (`on` or `off`), `data-cron-busy` and `data-cron-recovered`.
- `admin/sla.php` is rewritten on the standard list plumbing, as `admin/tax_rates.php` is laid out: the list with `SQL_CALC_FOUND_ROWS`, `includes/filter_footer.php` and `$empty_state`, and two form cards beside it. `show` picks the view - none for the SLAs in use, `archived`, `days` (closure days from today on) or `past` - and each side has its own default sort, sortable columns (`sla_name`, `sla_response_minutes`, `sla_resolution_minutes`, `tickets`; `holiday_date`, `holiday_name`), search and counts; a view's link keeps the search only on its own side. The closure days were a table with no paging under the settings. No CSS or JavaScript is added, and there is no database change.
- What uses an SLA: open tickets by a subquery on `TICKET_OPEN_SQL`, and the contracts and contract templates that name it for a priority, counted in PHP where `contractsReady()`. On tickets now uses `dashboardSlaBreachedSql()` and the ticket list's own at-risk rule, so each count is the list its link opens (`/agent/tickets.php?sla=breached`, `?sla=at_risk`).
- `functions/sla.php` gains `slaBusinessDayMinutes()` (0 when the clocks run around the clock, by `addBusinessMinutes()`'s own test), `formatSlaClockMinutes()` ("48 hours", "1 hour 30 minutes" - beside `formatSlaMinutes()`, which says what that is in business days) and `slaTargetMinutes($hours, $minutes)`.
- `admin/modals/sla/`: `sla_add.php` and `sla_edit.php` share the new `sla_form.php`, which posts `response_hours` and `response_minutes`, `resolution_hours` and `resolution_minutes`; `holiday_generate.php` is new, in place of the year box in the card's header; `holiday_add.php` is tidied. `add_sla` and `edit_sla` (`admin/post/sla.php`) add hours to minutes, so a caller that sends only `response_minutes` and `resolution_minutes` as totals gets what it did before.
- `admin/post/sla.php`: `add_sla` and `edit_sla` check the name and targets first; `edit_sla_settings` takes the hours as HH:MM, refuses a day that ends before it starts when any day is picked, and keeps `warning_percent` within 1 to 99; the messages count tickets properly and say which ("1 ticket not yet closed", "80 tickets not yet closed" - a save works out again every ticket still to close, resolved ones included, which can be more than the open tickets a row counts), use the name as typed, and `generate_holidays` says when every one was already in. What is saved, the restamping of open tickets and the audit log are unchanged.
- Removing a closure day is a `button.confirm-link` in its form (`js/confirm_modal.js`) in place of an inline `onsubmit="return confirm()"`, and Archive carries its own `data-confirm-*` text.
- Found and left alone: `slaMinutesText()` (`functions/contract.php`), which words an SLA on contracts and in the contract form, calls 2880 minutes "2 days" where the clock and `formatSlaMinutes()` make it 6 business days of a 9 to 5 day.
- Hooks for tests and scripts: `data-sla-view` on each view's link, `data-sla-list` with `data-sla-id` on each row, `data-sla-default-for`, `data-sla-archive` and `data-sla-restore`; `data-sla-days` with `data-sla-day` (the date) on each row; `data-sla-new`, `data-sla-new-day` and `data-sla-us-holidays`; `data-sla-default` (the priority) on each default's select, `data-sla-closures`, `data-sla-alerts-stopped` and `data-sla-now`.
## [26.11.7]

View File

@@ -1,5 +1,14 @@
<?php
/*
* New closure day - a date the office is shut, and what for. No SLA clock
* runs on it, the same as a day outside the business days, and adding one
* works the targets out again on every ticket with an SLA that isn't closed yet
* (admin/post/sla.php).
*/
require_once '../../includes/modal_header.php';
ob_start();
?>
@@ -12,24 +21,23 @@ ob_start();
<div class="modal-body">
<div class="mb-3">
<label>Date <strong class="text-danger">*</strong></label>
<label class="form-label">Date <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
<input type="date" class="form-control" name="holiday_date" required autofocus>
</div>
</div>
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<label class="form-label">Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<input type="text" class="form-control" name="holiday_name" placeholder="e.g. Christmas Day, Office closed for move" maxlength="200" required>
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<input type="text" class="form-control" name="holiday_name" maxlength="200" placeholder="Christmas Day, Office closed for the move" required>
</div>
</div>
<small class="text-muted">SLA clocks do not run on this day at all, the same as a day outside your business days. Open tickets have their targets recalculated when you save.</small>
<div class="small text-secondary"><i class="fas fa-fw fa-stopwatch me-1"></i>No SLA clock runs on the day at all, the same as a day outside your business days. Adding it works the targets out again on every ticket with an SLA that isn't closed yet.</div>
</div>
<div class="modal-footer">
<button type="submit" name="add_holiday" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Add</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>

View File

@@ -0,0 +1,65 @@
<?php
/*
* Add a year's US federal holidays as closure days - the eleven of them, each
* on the day it is observed (usFederalHolidays()). A day already listed is
* left as it is, so a year partly filled in by hand is safe to run over. The
* window says how many of each year's are in, and offers the first year with
* some still to add.
*/
require_once '../../includes/modal_header.php';
$listed = getBusinessHolidays();
$this_year = intval(date('Y'));
$years = [];
$offer_year = 0;
for ($year = $this_year; $year <= $this_year + 2; $year++) {
$days = usFederalHolidays($year);
$in = count(array_filter($days, function ($day) use ($listed) {
return isset($listed[$day['date']]);
}));
$years[$year] = [$in, count($days)];
if (!$offer_year && $in < count($days)) {
$offer_year = $year;
}
}
$offer_year = $offer_year ?: $this_year + 3;
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-magic me-2"></i>Add US Holidays</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<p class="text-secondary">Adds the eleven US federal holidays for a year as closure days, each on the day it is observed - a holiday that falls on a Saturday is taken the Friday before, and one on a Sunday the Monday after.</p>
<div class="mb-3">
<label class="form-label">Year <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-alt"></i></span>
<input type="number" class="form-control" name="holiday_year" min="2000" max="2100" step="1" value="<?= $offer_year ?>" required autofocus>
</div>
<small class="text-secondary">
<?php foreach ($years as $year => [$in, $total]) { ?>
<span class="d-block"><?= $year ?> - <?= $in === $total ? "all $total are in" : ($in ? "$in of $total are in" : 'none are in yet') ?></span>
<?php } ?>
</small>
</div>
<div class="small text-secondary"><i class="fas fa-fw fa-stopwatch me-1"></i>A day already on the list is left as it is, name and all. Adding them works the targets out again on every ticket with an SLA that isn't closed yet.</div>
</div>
<div class="modal-footer">
<button type="submit" name="generate_holidays" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Add holidays</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -1,5 +1,15 @@
<?php
/*
* New SLA - its name, what it is for, and how long there is to first reply to
* a ticket and to resolve it. Nothing gets it until it is the default for a
* priority (Admin > SLAs) or a contract picks it. The fields are sla_form.php.
*/
require_once '../../includes/modal_header.php';
$sla = null;
ob_start();
?>
@@ -11,39 +21,8 @@ ob_start();
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-stopwatch"></i></span>
<input type="text" class="form-control" name="name" placeholder="SLA name" maxlength="200" required autofocus>
</div>
</div>
<div class="mb-3">
<label>Description</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-comment"></i></span>
<input type="text" class="form-control" name="description" placeholder="Short description" maxlength="500">
</div>
</div>
<div class="mb-3">
<label>Response target (business minutes) <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-reply"></i></span>
<input type="number" class="form-control" name="response_minutes" min="1" placeholder="e.g. 60" required>
</div>
</div>
<div class="mb-3">
<label>Resolution target (business minutes)</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-flag-checkered"></i></span>
<input type="number" class="form-control" name="resolution_minutes" min="1" placeholder="Leave empty for response-only">
</div>
</div>
<?php require __DIR__ . '/sla_form.php'; ?>
</div>
<div class="modal-footer">
<button type="submit" name="add_sla" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Create</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>

View File

@@ -1,20 +1,27 @@
<?php
/*
* Edit an SLA. Saving works its targets out again on the tickets that have it
* and aren't closed yet (admin/post/sla.php), so the window says how many
* that is. The fields are sla_form.php.
*/
require_once '../../includes/modal_header.php';
$sla_id = intval($_GET['id']);
$sla_id = intval($_GET['id'] ?? 0);
$sql = mysqli_query($mysqli, "SELECT sla_description, sla_name, sla_resolution_minutes, sla_response_minutes FROM slas WHERE sla_id = $sla_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
$sla_name = escapeHtml($row['sla_name']);
$sla_description = escapeHtml($row['sla_description']);
$sla_response_minutes = intval($row['sla_response_minutes']);
$sla_resolution_minutes = intval($row['sla_resolution_minutes']);
$sla = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT sla_archived_at, sla_description, sla_id, sla_name, sla_resolution_minutes, sla_response_minutes FROM slas WHERE sla_id = $sla_id LIMIT 1"));
ob_start();
?>
if (!$sla) { ?>
<div class="modal-body text-center text-secondary py-5">That SLA is gone.</div>
<?php } else {
// The tickets a save moves the targets on - the handler's own rule
$sla_tickets = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_sla_id = $sla_id AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL"))[0]); ?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-stopwatch me-2"></i>Editing SLA: <strong><?= $sla_name ?></strong></h5>
<h5 class="modal-title"><i class="fas fa-fw fa-stopwatch me-2"></i>SLA: <strong><?= escapeHtml($sla['sla_name']) ?></strong></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
@@ -22,45 +29,19 @@ ob_start();
<input type="hidden" name="sla_id" value="<?= $sla_id ?>">
<div class="modal-body">
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-stopwatch"></i></span>
<input type="text" class="form-control" name="name" value="<?= $sla_name ?>" maxlength="200" required>
</div>
</div>
<div class="mb-3">
<label>Description</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-comment"></i></span>
<input type="text" class="form-control" name="description" value="<?= $sla_description ?>" maxlength="500">
</div>
</div>
<div class="mb-3">
<label>Response target (business minutes) <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-reply"></i></span>
<input type="number" class="form-control" name="response_minutes" min="1" value="<?= $sla_response_minutes ?>" required>
</div>
</div>
<div class="mb-3">
<label>Resolution target (business minutes)</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-flag-checkered"></i></span>
<input type="number" class="form-control" name="resolution_minutes" min="1" value="<?= $sla_resolution_minutes ?: '' ?>" placeholder="Leave empty for response-only">
</div>
<small class="text-muted">Saving recalculates targets on open tickets using this SLA.</small>
</div>
<?php if ($sla['sla_archived_at']) { ?>
<div class="alert alert-secondary small py-2"><i class="fas fa-fw fa-archive me-1"></i>Archived - new tickets don't get it. Restore it from the list.</div>
<?php } ?>
<?php require __DIR__ . '/sla_form.php'; ?>
<?php if ($sla_tickets) { ?>
<div class="small text-secondary mt-2"><i class="fas fa-fw fa-life-ring me-1"></i>Saving works the targets out again on the <?= number_format($sla_tickets) ?> ticket<?= $sla_tickets === 1 ? '' : 's' ?> with this SLA that <?= $sla_tickets === 1 ? "isn't" : "aren't" ?> closed yet.</div>
<?php } ?>
</div>
<div class="modal-footer">
<button type="submit" name="edit_sla" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Save</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
<?php }
require_once '../../../includes/modal_footer.php';

View File

@@ -0,0 +1,77 @@
<?php
/*
* An SLA's fields - for sla_add.php and sla_edit.php, which set $sla (its row,
* or null when new). Each target is typed as hours and minutes, and the
* handler makes minutes of the two (admin/post/sla.php, slaTargetMinutes()).
* A resolution target left empty is an SLA that only times the first reply.
*/
isset($mysqli) || die("Direct file access is not allowed");
$f_name = (string) ($sla['sla_name'] ?? '');
$f_description = (string) ($sla['sla_description'] ?? '');
$f_response = intval($sla['sla_response_minutes'] ?? 0);
$f_resolution = intval($sla['sla_resolution_minutes'] ?? 0);
// Minutes into the two boxes - a box that would hold a 0 is left empty
$f_hours = function ($minutes) {
return $minutes >= 60 ? (string) intdiv($minutes, 60) : '';
};
$f_minutes = function ($minutes) {
return $minutes % 60 > 0 ? (string) ($minutes % 60) : '';
};
// What a day's worth of hours comes to on the business calendar - the part people are caught out by
$f_day_minutes = slaBusinessDayMinutes();
?>
<div class="mb-3">
<label class="form-label">Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-stopwatch"></i></span>
<input type="text" class="form-control" name="name" maxlength="200" placeholder="Standard Support, Priority Support, Critical Response" value="<?= escapeHtml($f_name) ?>" required<?= $sla ? '' : ' autofocus' ?>>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-align-left"></i></span>
<input type="text" class="form-control" name="description" maxlength="500" placeholder="What it covers, and who gets it" value="<?= escapeHtml($f_description) ?>">
</div>
</div>
<div class="mb-3">
<label class="form-label">Response target <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-reply"></i></span>
<input type="number" class="form-control" name="response_hours" min="0" max="8760" step="1" placeholder="0" value="<?= $f_hours($f_response) ?>" aria-label="Response target, hours">
<span class="input-group-text">hours</span>
<input type="number" class="form-control" name="response_minutes" min="0" max="59" step="1" placeholder="0" value="<?= $f_minutes($f_response) ?>" aria-label="Response target, minutes">
<span class="input-group-text">minutes</span>
</div>
<small class="text-secondary">How long there is to first reply to a new ticket.</small>
</div>
<div class="mb-3">
<label class="form-label">Resolution target</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-flag-checkered"></i></span>
<input type="number" class="form-control" name="resolution_hours" min="0" max="8760" step="1" placeholder="0" value="<?= $f_hours($f_resolution) ?>" aria-label="Resolution target, hours">
<span class="input-group-text">hours</span>
<input type="number" class="form-control" name="resolution_minutes" min="0" max="59" step="1" placeholder="0" value="<?= $f_minutes($f_resolution) ?>" aria-label="Resolution target, minutes">
<span class="input-group-text">minutes</span>
</div>
<small class="text-secondary">How long there is to resolve it. Leave both empty for an SLA that only times the first reply.</small>
</div>
<div class="small text-secondary mb-1">
<i class="fas fa-fw fa-business-time me-1"></i>
<?php if ($f_day_minutes > 0) { ?>
Targets count business hours only. Your business day is <?= escapeHtml(formatSlaClockMinutes($f_day_minutes)) ?>, so <strong>24 hours here is <?= escapeHtml(formatSlaMinutes(1440)) ?></strong>, not one - for a target of two business days, give <?= escapeHtml(formatSlaClockMinutes(2 * $f_day_minutes)) ?>.
<?php } else { ?>
No business hours are set, so the clocks run around the clock and a target is plain time - 24 hours is one day.
<?php } ?>
</div>

View File

@@ -2,21 +2,55 @@
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['add_sla'])) {
/*
* Admin > SLAs. An SLA's window (modals/sla/sla_form.php) sends each target as
* hours and minutes - response_hours and response_minutes, resolution_hours and
* resolution_minutes - and slaTargetMinutes() makes minutes of the two. A
* resolution target left empty is an SLA that only times the first reply.
*/
// "12 tickets not yet closed" for the messages - what a save worked out again.
// That is every ticket still to close, the resolved ones included, so it can be
// more than the open tickets the list counts against an SLA.
$sla_open_tickets_text = function ($count) {
return number_format($count) . ' ticket' . (intval($count) === 1 ? '' : 's') . ' not yet closed';
};
if (isset($_POST['add_sla']) || isset($_POST['edit_sla'])) {
validateCSRFToken();
$name = escapeSql($_POST['name']);
$description = escapeSql($_POST['description']);
$response_minutes = intval($_POST['response_minutes']);
$resolution_minutes = intval($_POST['resolution_minutes']);
$name_input = trim((string) ($_POST['name'] ?? ''));
$response_minutes = slaTargetMinutes($_POST['response_hours'] ?? 0, $_POST['response_minutes'] ?? 0);
$resolution_minutes = slaTargetMinutes($_POST['resolution_hours'] ?? 0, $_POST['resolution_minutes'] ?? 0);
// Said here, not left to the database: an SLA with no name can't be picked, and one with no response target stamps tickets as due the moment they are raised
if ($name_input === '') {
flashAlert("Give the SLA a name.", 'error');
redirect();
}
if ($response_minutes < 1) {
flashAlert("Give the SLA a response target - how long there is to first reply.", 'error');
redirect();
}
if ($resolution_minutes > 0 && $resolution_minutes < $response_minutes) {
flashAlert("The resolution target can't be shorter than the response target.", 'error');
redirect();
}
}
if (isset($_POST['add_sla'])) {
$name = escapeSql($name_input);
$description = escapeSql(trim((string) ($_POST['description'] ?? '')));
$resolution_minutes_set = $resolution_minutes > 0 ? $resolution_minutes : "NULL";
mysqli_query($mysqli, "INSERT INTO slas SET sla_name = '$name', sla_description = '$description', sla_response_minutes = $response_minutes, sla_resolution_minutes = $resolution_minutes_set");
logAudit("SLA", "Create", "$session_name created SLA $name");
flashAlert("SLA <strong>$name</strong> created");
flashAlert("SLA <strong>$name_input</strong> created");
redirect();
@@ -24,13 +58,9 @@ if (isset($_POST['add_sla'])) {
if (isset($_POST['edit_sla'])) {
validateCSRFToken();
$sla_id = intval($_POST['sla_id']);
$name = escapeSql($_POST['name']);
$description = escapeSql($_POST['description']);
$response_minutes = intval($_POST['response_minutes']);
$resolution_minutes = intval($_POST['resolution_minutes']);
$name = escapeSql($name_input);
$description = escapeSql(trim((string) ($_POST['description'] ?? '')));
$resolution_minutes_set = $resolution_minutes > 0 ? $resolution_minutes : "NULL";
mysqli_query($mysqli, "UPDATE slas SET sla_name = '$name', sla_description = '$description', sla_response_minutes = $response_minutes, sla_resolution_minutes = $resolution_minutes_set WHERE sla_id = $sla_id");
@@ -45,7 +75,7 @@ if (isset($_POST['edit_sla'])) {
logAudit("SLA", "Edit", "$session_name edited SLA $name");
flashAlert("SLA <strong>$name</strong> updated - targets recalculated on $restamped open ticket(s)");
flashAlert("SLA <strong>$name_input</strong> saved" . ($restamped ? " - targets worked out again on " . $sla_open_tickets_text($restamped) : ''));
redirect();
@@ -100,12 +130,27 @@ if (isset($_POST['edit_sla_settings'])) {
}
}
}
// The hours as HH:MM, the day ending after it starts. Anything else the clock
// can't use, and addBusinessMinutes() would quietly run it around the clock instead
$business_hours_start = substr(trim((string) ($_POST['business_hours_start'] ?? '')), 0, 5);
$business_hours_end = substr(trim((string) ($_POST['business_hours_end'] ?? '')), 0, 5);
$time_pattern = '/^([01][0-9]|2[0-3]):[0-5][0-9]$/';
if (!preg_match($time_pattern, $business_hours_start) || !preg_match($time_pattern, $business_hours_end)) {
flashAlert("Enter the business hours as times, for example 09:00 to 17:00.", 'error');
redirect();
}
if ($business_days && $business_hours_start >= $business_hours_end) {
flashAlert("Business hours have to end after they start. For cover around the clock, leave every day unpicked.", 'error');
redirect();
}
$business_days = escapeSql(implode(',', $business_days));
$business_hours_start = escapeSql($_POST['business_hours_start']);
$business_hours_end = escapeSql($_POST['business_hours_end']);
$warning_percent = intval($_POST['warning_percent']);
$notification_email = escapeSql($_POST['notification_email']);
$business_hours_start = escapeSql($business_hours_start);
$business_hours_end = escapeSql($business_hours_end);
$warning_percent = min(99, max(1, intval($_POST['warning_percent'] ?? 75)));
$notification_email = escapeSql(trim((string) ($_POST['notification_email'] ?? '')));
mysqli_query($mysqli, "UPDATE settings SET config_business_days = '$business_days', config_business_hours_start = '$business_hours_start', config_business_hours_end = '$business_hours_end', config_sla_warning_percent = $warning_percent, config_sla_notification_email = '$notification_email' WHERE company_id = 1");
@@ -117,7 +162,7 @@ if (isset($_POST['edit_sla_settings'])) {
logAudit("Settings", "Edit", "$session_name edited SLA / business hours settings");
flashAlert("SLA settings updated - targets recalculated on $restamped open ticket(s)");
flashAlert("Business hours and alerts saved" . ($restamped ? " - targets worked out again on " . $sla_open_tickets_text($restamped) : ''));
redirect();
@@ -157,7 +202,7 @@ if (isset($_POST['add_holiday'])) {
logAudit("Settings", "Create", "$session_name added SLA closure day $holiday_date - $holiday_name");
flashAlert("Closure day added - targets recalculated on $restamped open ticket(s)");
flashAlert("Closure day added" . ($restamped ? " - targets worked out again on " . $sla_open_tickets_text($restamped) : ''));
redirect();
@@ -184,7 +229,7 @@ if (isset($_POST['delete_holiday'])) {
logAudit("Settings", "Delete", "$session_name removed SLA closure day $holiday_date - $holiday_name");
flashAlert("Closure day removed - targets recalculated on $restamped open ticket(s)");
flashAlert("Closure day removed" . ($restamped ? " - targets worked out again on " . $sla_open_tickets_text($restamped) : ''));
redirect();
@@ -216,7 +261,9 @@ if (isset($_POST['generate_holidays'])) {
logAudit("Settings", "Create", "$session_name generated $added US federal holiday closure day(s) for $holiday_year");
flashAlert("Added $added US federal holiday(s) for $holiday_year - targets recalculated on $restamped open ticket(s)");
flashAlert($added
? "Added $added US federal holiday" . ($added === 1 ? '' : 's') . " for $holiday_year" . ($restamped ? " - targets worked out again on " . $sla_open_tickets_text($restamped) : '')
: "Every US federal holiday for $holiday_year is on the list already");
redirect();
@@ -249,7 +296,7 @@ if (isset($_POST['save_sla_assignments'])) {
logAudit("SLA", "Edit", "$session_name updated default SLA assignments");
flashAlert("Default SLA assignments saved - $restamped open ticket(s) re-evaluated");
flashAlert("Default SLAs saved" . ($restamped ? " - looked again at " . $sla_open_tickets_text($restamped) : ''));
redirect();

View File

@@ -1,274 +1,605 @@
<?php
/*
* Admin > 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";
// Current SLA / business hours settings
$sql_sla_settings = mysqli_query($mysqli, "SELECT config_business_days, config_business_hours_start, config_business_hours_end, config_sla_warning_percent, config_sla_notification_email FROM settings WHERE company_id = 1");
$row = mysqli_fetch_assoc($sql_sla_settings);
$config_business_days = explode(',', $row['config_business_days']);
$config_business_hours_start = substr($row['config_business_hours_start'], 0, 5);
$config_business_hours_end = substr($row['config_business_hours_end'], 0, 5);
$config_sla_warning_percent = intval($row['config_sla_warning_percent']);
$config_sla_notification_email = escapeHtml($row['config_sla_notification_email']);
// SLA plans (active first)
$sql_slas = mysqli_query($mysqli, "SELECT sla_archived_at, sla_description, sla_id, sla_name, sla_resolution_minutes,
sla_response_minutes FROM slas ORDER BY sla_archived_at IS NOT NULL, sla_name ASC");
// Active plans for the assignment dropdowns
$active_slas = [];
$sql_active_slas = mysqli_query($mysqli, "SELECT sla_id, sla_name FROM slas WHERE sla_archived_at IS NULL ORDER BY sla_name ASC");
while ($active_sla_row = mysqli_fetch_assoc($sql_active_slas)) {
$active_slas[intval($active_sla_row['sla_id'])] = $active_sla_row['sla_name'];
// 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']);
}
// Closure days, newest first - past ones are kept as a record of what was applied
$holidays = [];
$sql_holidays = mysqli_query($mysqli, "SELECT holiday_date, holiday_id, holiday_name FROM business_holidays ORDER BY holiday_date DESC");
while ($holiday_row = mysqli_fetch_assoc($sql_holidays)) {
$holidays[] = $holiday_row;
$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';
}
$holiday_year = intval(date('Y'));
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()"));
// Default assignments: [priority] = sla_id - a client's contract can set its own (functions/contract_billing.php)
$assignments = [];
$sql_assignments = mysqli_query($mysqli, "SELECT sla_assignment_priority, sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = 0");
while ($assignment_row = mysqli_fetch_assoc($sql_assignments)) {
$assignments[0][$assignment_row['sla_assignment_priority']] = intval($assignment_row['sla_assignment_sla_id']);
$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 = '<span class="fw-semibold">' . escapeHtml(ucfirst(formatSlaMinutes($minutes))) . '</span>';
if ($minutes >= ($day_minutes ?: 1440)) {
$html .= '<div class="small text-body-secondary" title="As it was typed">' . escapeHtml(formatSlaClockMinutes($minutes)) . '</div>';
}
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 '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . ($sort === $column ? $disp : $sort_first[$column]) . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
// 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]));
};
?>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-stopwatch me-2"></i>SLAs</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/sla/sla_add.php"><i class="fas fa-plus me-2"></i>New SLA</button>
</div>
</div>
<div class="row">
<div class="card-body">
<div class="col-xl-8">
<p class="text-muted">SLAs are optional. A new ticket gets the SLA its client's contract sets for its priority, else the default for the priority below - with nothing assigned, nothing changes.</p>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark">
<tr>
<th>Name</th>
<th>Description</th>
<th>Response</th>
<th>Resolution</th>
<th>Status</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php while ($sla_row = mysqli_fetch_assoc($sql_slas)) {
$sla_id = intval($sla_row['sla_id']);
$sla_name = escapeHtml($sla_row['sla_name']);
$sla_description = escapeHtml($sla_row['sla_description']);
$sla_response_minutes = intval($sla_row['sla_response_minutes']);
$sla_resolution_minutes = intval($sla_row['sla_resolution_minutes']);
$sla_archived_at = $sla_row['sla_archived_at'];
?>
<tr>
<td>
<a href="#" class="ajax-modal" data-modal-url="modals/sla/sla_edit.php?id=<?= $sla_id ?>">
<?= $sla_name ?>
</a>
</td>
<td><?= $sla_description ?></td>
<td><?= $sla_response_minutes ?> min</td>
<td><?= $sla_resolution_minutes ? "$sla_resolution_minutes min" : "-" ?></td>
<td>
<?php if ($sla_archived_at) { ?>
<div class="text-secondary">Archived</div>
<div class="card mb-3">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-stopwatch me-2"></i>SLAs</h3>
<div class="card-tools">
<div class="btn-group">
<?php if ($on_days) { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/sla/holiday_add.php" data-sla-new-day>
<i class="fas fa-plus"></i><span class="d-none d-lg-inline ms-2">New Closure Day</span>
</button>
<?php } else { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/sla/sla_add.php" data-sla-new>
<i class="fas fa-plus"></i><span class="d-none d-lg-inline ms-2">New SLA</span>
</button>
<?php } ?>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-label="More"></button>
<div class="dropdown-menu dropdown-menu-end">
<?php if ($on_days) { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/sla/holiday_generate.php" data-sla-us-holidays>
<i class="fas fa-fw fa-magic text-secondary me-2"></i>Add US holidays
</a>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/sla/sla_add.php">
<i class="fas fa-fw fa-stopwatch text-secondary me-2"></i>New SLA
</a>
<?php } else { ?>
<div class="text-success text-bold">Active</div>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/sla/holiday_add.php">
<i class="fas fa-fw fa-calendar-times text-secondary me-2"></i>New closure day
</a>
<a class="dropdown-item" href="ticket_statuses.php" title="Which statuses pause the resolution clock">
<i class="fas fa-fw fa-pause-circle text-secondary me-2"></i>Ticket statuses
</a>
<?php } ?>
</td>
<td>
<div class="dropdown dropstart text-center">
<button class="btn btn-secondary btn-sm" title="More actions" type="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/sla/sla_edit.php?id=<?= $sla_id ?>">
<i class="fas fa-fw fa-edit me-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<?php if ($sla_archived_at) { ?>
<a class="dropdown-item" href="post.php?unarchive_sla=<?= $sla_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-undo me-2"></i>Restore
</a>
<?php } else { ?>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?archive_sla=<?= $sla_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive me-2"></i>Archive
</a>
<?php } ?>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-random me-2"></i>SLA Assignments</h3>
</div>
<div class="card-body">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<strong>Default (for a client whose contract sets none)</strong>
<div class="row g-2 mt-2">
<?php foreach (['Low', 'Medium', 'High', 'Urgent'] as $priority) {
$selected_sla_id = $assignments[0][$priority] ?? 0;
?>
<div class="mb-3 col-md-3">
<label><?= $priority ?></label>
<select class="form-select" name="global_sla_<?= strtolower($priority) ?>">
<option value="0">None</option>
<?php foreach ($active_slas as $active_sla_id => $active_sla_name) { ?>
<option value="<?= $active_sla_id ?>" <?php if ($selected_sla_id == $active_sla_id) { echo "selected"; } ?>><?= escapeHtml($active_sla_name) ?></option>
<?php } ?>
</select>
</div>
<?php } ?>
</div>
<button type="submit" name="save_sla_assignments" class="btn btn-primary"><i class="fas fa-check me-2"></i>Save Defaults</button>
</form>
<small class="text-muted d-block mt-2">A client has no SLAs of its own: a contract in force for the client comes first, for the priorities it sets an SLA for, and these defaults cover the rest.</small>
</div>
</div>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-business-time me-2"></i>Business Hours &amp; Notifications</h3>
</div>
<div class="card-body">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="mb-3">
<label>Business days</label>
<div>
<?php foreach ([1 => 'Mon', 2 => 'Tue', 3 => 'Wed', 4 => 'Thu', 5 => 'Fri', 6 => 'Sat', 7 => 'Sun'] as $day_number => $day_name) { ?>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="business_days[]" value="<?= $day_number ?>" id="business_day_<?= $day_number ?>" <?php if (in_array($day_number, $config_business_days)) { echo "checked"; } ?>>
<label class="form-check-label" for="business_day_<?= $day_number ?>"><?= $day_name ?></label>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/agent/reports/sla_summary.php">
<i class="fas fa-fw fa-chart-line text-secondary me-2"></i>SLA Summary report
</a>
<a class="dropdown-item" href="/agent/reports/sla_by_client.php">
<i class="fas fa-fw fa-users text-secondary me-2"></i>SLA by Client report
</a>
</div>
</div>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<?php if ($show !== '') { ?><input type="hidden" name="show" value="<?= $show ?>"><?php } ?>
<div class="row g-2 align-items-center">
<div class="col-md-7">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>"
placeholder="<?= $on_days ? 'Search closure days...' : 'Search SLAs...' ?>" title="<?= $on_days ? 'Name, date, month or day of the week' : 'Name or description' ?>">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
</div>
</form>
<nav class="nav nav-pills itflow-asset-tabs mt-2 flex-nowrap overflow-auto">
<?php foreach ($views as $view_key => $view) {
if ($view_key === 'days') { ?><span class="vr mx-1 my-1"></span><?php } ?>
<a class="nav-link text-nowrap <?= $show === (string) $view_key ? 'active' : '' ?>" href="<?= escapeHtml($pill_url($view_key)) ?>" title="<?= escapeHtml($view['title']) ?>" data-sla-view="<?= $view_key ?>">
<i class="fas fa-fw <?= $view['icon'] ?> me-1"></i><?= $view['label'] ?> <span class="badge rounded-pill ms-1"><?= $view_counts[$view_key] ?></span>
</a>
<?php } ?>
</div>
<small class="text-muted">SLA clocks only run during business hours on these days. Unchecking everything makes SLAs count 24x7.</small>
</nav>
</div>
<div class="row g-2">
<div class="mb-3 col-md-3">
<label>Business hours start</label>
<input type="time" class="form-control" name="business_hours_start" value="<?= $config_business_hours_start ?>" required>
</div>
<div class="mb-3 col-md-3">
<label>Business hours end</label>
<input type="time" class="form-control" name="business_hours_end" value="<?= $config_business_hours_end ?>" required>
</div>
<div class="mb-3 col-md-3">
<label>Warn at % of target</label>
<input type="number" class="form-control" name="warning_percent" min="1" max="99" value="<?= $config_sla_warning_percent ?>" required>
</div>
<div class="mb-3 col-md-3">
<label>Notification email <small class="text-muted">(optional)</small></label>
<input type="email" class="form-control" name="notification_email" placeholder="dispatch@example.com" maxlength="200" value="<?= $config_sla_notification_email ?>">
</div>
</div>
<small class="text-muted d-block mb-3">Warnings and breach alerts also notify the assigned agent in-app and by email. Requires cron/ticket_sla.php running every minute.</small>
<button type="submit" name="edit_sla_settings" class="btn btn-primary"><i class="fas fa-check me-2"></i>Save Settings</button>
</form>
</div>
</div>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-calendar-times me-2"></i>Holidays &amp; Closure Days</h3>
<div class="card-tools">
<form action="post.php" method="post" class="d-inline" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="input-group input-group-sm d-inline-flex w-auto align-middle me-2">
<input type="number" class="form-control" name="holiday_year" min="2000" max="2100" value="<?= $holiday_year ?>" required>
<button type="submit" name="generate_holidays" class="btn btn-secondary"><i class="fas fa-magic me-2"></i>Add US Holidays</button>
</div>
</form>
<button type="button" class="btn btn-sm btn-primary ajax-modal" data-modal-url="modals/sla/holiday_add.php"><i class="fas fa-plus me-2"></i>New Closure Day</button>
</div>
</div>
<div class="card-body">
<p class="text-muted">
SLA clocks stop completely on these dates, the same as a day outside your business days.
Use them for public holidays or any other day the office is shut. Adding or removing one
recalculates the targets on every open ticket.
</p>
<?php if (empty($holidays)) { ?>
<p class="text-secondary mb-0">No closure days configured - SLA clocks run on every business day.</p>
<?php } else { ?>
<div class="table-responsive">
<table class="table table-borderless table-hover align-middle">
<thead class="border-bottom">
<?php if ($on_days) { ?>
<!-- Closure days: holidays and other days the office is shut -->
<table class="table table-hover align-middle mb-0" data-sla-days>
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>Date</th>
<th>Day</th>
<th>Name</th>
<th class="text-end">Action</th>
<th class="ps-3"><?= $sort_link('holiday_name', 'Closure day') ?></th>
<th class="d-none d-sm-table-cell"><?= $sort_link('holiday_date', 'Date') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($holidays as $holiday) {
$holiday_id = intval($holiday['holiday_id']);
$holiday_date_raw = $holiday['holiday_date'];
$holiday_date = escapeHtml(date('M j, Y', strtotime($holiday_date_raw)));
$holiday_day = escapeHtml(date('l', strtotime($holiday_date_raw)));
$holiday_name = escapeHtml($holiday['holiday_name']);
$holiday_is_past = strtotime($holiday_date_raw) < strtotime(date('Y-m-d'));
?>
<tr class="<?php if ($holiday_is_past) { echo "text-secondary"; } ?>">
<td><?= $holiday_date ?></td>
<td><?= $holiday_day ?></td>
<td><?= $holiday_name ?></td>
<td class="text-end">
<form action="post.php" method="post" onsubmit="return confirm('Remove this closure day? Open ticket targets will be recalculated.');">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
</thead>
<tbody>
<?php foreach ($rows as $day) {
$holiday_id = intval($day['holiday_id']);
$date = $day['holiday_date'];
$date_text = date('M j, Y', strtotime($date));
$weekday = date('l', strtotime($date));
// Shut anyway - a day of the week the clocks don't run on
$shut_anyway = $business_days && $day_minutes > 0 && !in_array(intval(date('N', strtotime($date))), $business_days, true); ?>
<tr data-sla-day="<?= escapeHtml($date) ?>">
<td class="ps-3">
<div class="d-flex align-items-center gap-3">
<span class="itflow-asset-icon itflow-asset-tone-other d-none d-sm-inline-flex" aria-hidden="true"><i class="fas fa-fw fa-calendar-times"></i></span>
<div class="min-w-0">
<div class="fw-semibold"><?= escapeHtml($day['holiday_name']) ?></div>
<!-- On a phone the date comes along under the name -->
<div class="d-sm-none small text-body-secondary"><?= $weekday ?>, <?= $date_text ?></div>
<?php if ($shut_anyway) { ?>
<div class="small text-body-secondary"><i class="fas fa-fw fa-info-circle me-1"></i>A <?= $weekday ?> - not one of your business days anyway</div>
<?php } ?>
</div>
</div>
</td>
<td class="d-none d-sm-table-cell text-nowrap">
<div><?= $date_text ?></div>
<div class="small text-body-secondary"><?= $weekday ?> &middot; <?= escapeHtml($day_distance($date)) ?></div>
</td>
<td class="text-end pe-3">
<form action="post.php" method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="holiday_id" value="<?= $holiday_id ?>">
<button type="submit" name="delete_holiday" class="btn btn-sm btn-light border" title="Delete holiday"><i class="fas fa-fw fa-trash text-danger"></i></button>
<button type="submit" name="delete_holiday" class="btn btn-sm btn-light text-danger confirm-link" title="Remove"
data-confirm-title="Remove <?= escapeHtml($day['holiday_name']) ?>?"
data-confirm-text="<?= $date_text ?> is counted as <?= $shut_anyway ? 'any other ' . $weekday : 'a business day' ?> again, and the targets on every ticket with an SLA that isn't closed yet are worked out again."
data-confirm-button="Remove"><i class="fas fa-fw fa-trash"></i></button>
</form>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</tbody>
</table>
<?php } else { ?>
<!-- The SLAs -->
<table class="table table-hover align-middle mb-0" data-sla-list>
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="ps-3"><?= $sort_link('sla_name', 'SLA') ?></th>
<th class="d-none d-sm-table-cell"><?= $sort_link('sla_response_minutes', 'Response') ?></th>
<th class="d-none d-sm-table-cell"><?= $sort_link('sla_resolution_minutes', 'Resolution') ?></th>
<th class="d-none d-lg-table-cell"><?= $sort_link('tickets', 'Used by') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $sla) {
$sla_id = intval($sla['sla_id']);
$name = escapeHtml($sla['sla_name']);
$resolution = intval($sla['sla_resolution_minutes']);
$used = $used_text($sla);
$default_priorities = $default_for[$sla_id] ?? [];
$edit_url = "modals/sla/sla_edit.php?id=$sla_id";
// What archiving it changes, for the confirmation - the part that differs from one SLA to the next first
$gives = array_filter([
$default_priorities ? 'it is the default for ' . implode(', ', $default_priorities) . ' tickets' : '',
!empty($sla_used[$sla_id]['contracts']) ? number_format($sla_used[$sla_id]['contracts']) . ' contract' . ($sla_used[$sla_id]['contracts'] === 1 ? ' gives' : 's give') . ' it' : '',
]);
$open_tickets = intval($sla['n_tickets']);
$archive_text = ($gives ? ucfirst(implode(' and ', $gives)) . ' - those tickets get no SLA from then on. ' : 'New tickets no longer get it. ')
. ($open_tickets ? 'The ' . number_format($open_tickets) . ' open ticket' . ($open_tickets === 1 ? ' on it keeps its' : 's on it keep their') . ' targets.' : 'Tickets that have it keep their targets.'); ?>
<tr data-sla-id="<?= $sla_id ?>">
<td class="ps-3">
<div class="d-flex align-items-start gap-3">
<span class="itflow-asset-icon itflow-asset-tone-other d-none d-sm-inline-flex" aria-hidden="true"><i class="fas fa-fw fa-stopwatch"></i></span>
<div class="min-w-0">
<a class="ajax-modal fw-semibold text-body" href="#" data-modal-url="<?= $edit_url ?>" title="Edit <?= $name ?>"><?= $name ?></a>
<?php if ($default_priorities && !$sla['sla_archived_at']) { ?>
<span class="badge rounded-pill text-bg-primary ms-1" title="New <?= implode(', ', $default_priorities) ?> tickets get it, unless the client's contract gives another" data-sla-default-for><?= 'Default for ' . implode(', ', $default_priorities) ?></span>
<?php } ?>
<?php if ((string) $sla['sla_description'] !== '') { ?>
<div class="small text-body-secondary"><?= escapeHtml($sla['sla_description']) ?></div>
<?php } ?>
<?php if ($sla['sla_archived_at']) { ?>
<div class="small text-body-secondary"><i class="fas fa-fw fa-archive me-1"></i>Archived <?= date('M j, Y', strtotime($sla['sla_archived_at'])) ?><?= $default_priorities ? ' - still the default for ' . implode(', ', $default_priorities) . ', so those tickets get none' : '' ?></div>
<?php } ?>
<!-- On a phone the targets come along under the name -->
<div class="d-sm-none small text-body-secondary mt-1"><?= escapeHtml($targets_text($sla)) ?></div>
</div>
</div>
</td>
<td class="d-none d-sm-table-cell text-nowrap"><?= $target_html($sla['sla_response_minutes']) ?></td>
<td class="d-none d-sm-table-cell text-nowrap">
<?= $resolution > 0 ? $target_html($resolution) : '<span class="text-body-secondary" title="It only times the first reply">Response only</span>' ?>
</td>
<td class="d-none d-lg-table-cell small text-nowrap">
<?= $used ? implode('<br>', array_map('escapeHtml', $used)) : '<span class="text-body-secondary">Not used yet</span>' ?>
</td>
<td class="text-end pe-3">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="<?= $edit_url ?>"><i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit</a>
<?php if ($sla['sla_archived_at']) { ?>
<a class="dropdown-item" href="post.php?unarchive_sla=<?= $sla_id ?>&amp;csrf_token=<?= $csrf ?>" data-sla-restore><i class="fas fa-fw fa-box-open text-secondary me-2"></i>Restore</a>
<?php } else { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?archive_sla=<?= $sla_id ?>&amp;csrf_token=<?= $csrf ?>" data-sla-archive
data-confirm-title="Archive <?= $name ?>?" data-confirm-text="<?= escapeHtml($archive_text) ?>" data-confirm-button="Archive">
<i class="fas fa-fw fa-archive me-2"></i>Archive
</a>
<?php } ?>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
<?php } ?>
</div>
<?php } ?>
<?php
$empty_state = $on_days ? [
'thing' => '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";
?>
</div>
<!-- What is on tickets now - each count is the list its link opens -->
<div class="card mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-life-ring me-2"></i>On tickets now</h3>
</div>
<div class="row g-0 text-center" data-sla-now>
<div class="col-4 p-3 border-end">
<div class="fs-4 fw-semibold"><?= number_format($now_open) ?></div>
<div class="small text-body-secondary">Open with an SLA</div>
</div>
<div class="col-4 p-3 border-end">
<?php if ($now_at_risk) { ?>
<a class="fs-4 fw-semibold text-warning-emphasis text-decoration-none" href="/agent/tickets.php?sla=at_risk" title="See them"><?= number_format($now_at_risk) ?></a>
<?php } else { ?>
<div class="fs-4 fw-semibold text-body-tertiary">0</div>
<?php } ?>
<div class="small text-body-secondary">Close to a target</div>
</div>
<div class="col-4 p-3">
<?php if ($now_breached) { ?>
<a class="fs-4 fw-semibold text-danger-emphasis text-decoration-none" href="/agent/tickets.php?sla=breached" title="See them"><?= number_format($now_breached) ?></a>
<?php } else { ?>
<div class="fs-4 fw-semibold text-body-tertiary">0</div>
<?php } ?>
<div class="small text-body-secondary">Missed a target</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body small text-secondary">
<div class="row g-3">
<p class="col-md-6 mb-0"><i class="fas fa-fw fa-stopwatch me-1"></i><strong>SLAs are optional.</strong> 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.</p>
<p class="col-md-6 mb-0"><i class="fas fa-fw fa-business-time me-1"></i><strong>Targets are business time.</strong> <?= $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.' ?></p>
<p class="col-md-6 mb-0"><i class="fas fa-fw fa-pause-circle me-1"></i><strong>Two clocks.</strong> 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 <a href="ticket_statuses.php">Ticket Statuses</a>.</p>
<p class="col-md-6 mb-0"><i class="fas fa-fw fa-chart-line me-1"></i><strong>How you're doing.</strong> The <a href="/agent/reports/sla_summary.php">SLA Summary</a> and <a href="/agent/reports/sla_by_client.php">SLA by Client</a> reports show how often targets were met.</p>
</div>
</div>
</div>
</div>
<div class="col-xl-4">
<!-- Which SLA a new ticket gets, by its priority -->
<div class="card card-dark mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-flag me-2"></i>Default SLA by priority</h3>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="card-body">
<?php foreach (TICKET_PRIORITIES as $priority) {
$selected_sla_id = intval($defaults[$priority] ?? 0);
// The priority's own colour, as its badge has in the ticket lists
$flag_tone = ticketPriorityColor($priority) === 'dark' ? 'body' : ticketPriorityColor($priority); ?>
<div class="input-group mb-2">
<span class="input-group-text" style="width: 7rem;"><i class="fa fa-fw fa-flag me-2 text-<?= $flag_tone ?>"></i><?= $priority ?></span>
<select class="form-select select2" name="global_sla_<?= strtolower($priority) ?>" aria-label="Default SLA for <?= $priority ?> tickets" data-sla-default="<?= $priority ?>">
<option value="0">No SLA</option>
<?php foreach ($sla_choices as $choice_id => $choice) { ?>
<option value="<?= $choice_id ?>"<?= $selected_sla_id === $choice_id ? ' selected' : '' ?><?= selectOptionData(['description' => $choice['sla_archived_at'] ? 'Archived - tickets get none' : $targets_text($choice)]) ?>><?= escapeHtml($choice['sla_name']) ?><?= $choice['sla_archived_at'] ? ' (archived)' : '' ?></option>
<?php } ?>
</select>
</div>
<?php } ?>
<small class="text-secondary d-block">
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.
<?= $active_slas ? '' : ' Add an SLA to pick one here.' ?>
</small>
</div>
<div class="card-footer d-flex flex-wrap align-items-center gap-2">
<button type="submit" name="save_sla_assignments" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Save</button>
<span class="small text-secondary">Every ticket not yet closed is looked at again.</span>
</div>
</form>
</div>
<!-- When the clocks run, and who is warned -->
<div class="card card-dark mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-business-time me-2"></i>Business hours and alerts</h3>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="card-body">
<label class="form-label">Business days</label>
<div class="btn-group btn-group-sm w-100 mb-3" role="group" aria-label="Business days">
<?php foreach ($weekdays as $day_number => $day_name) { ?>
<input type="checkbox" class="btn-check" name="business_days[]" value="<?= $day_number ?>" id="slaBusinessDay<?= $day_number ?>" autocomplete="off"<?= in_array($day_number, $business_days, true) ? ' checked' : '' ?>>
<label class="btn btn-outline-primary" for="slaBusinessDay<?= $day_number ?>"><?= $day_name ?></label>
<?php } ?>
</div>
<div class="mb-3">
<label class="form-label">Business hours</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
<input type="time" class="form-control" name="business_hours_start" value="<?= escapeHtml($business_hours_start) ?>" required aria-label="The business day starts">
<span class="input-group-text">to</span>
<input type="time" class="form-control" name="business_hours_end" value="<?= escapeHtml($business_hours_end) ?>" required aria-label="The business day ends">
</div>
<small class="text-secondary">
SLA clocks only run in these hours, on these days<?= !empty($session_timezone) ? ' - ' . escapeHtml($session_timezone) . ' time' : '' ?>. With no day picked they run around the clock.
<?php if ($next_closure) { ?>
<a href="?show=days" data-sla-closures><?= number_format($next_closure['coming']) ?> closure day<?= intval($next_closure['coming']) === 1 ? '' : 's' ?></a> <?= intval($next_closure['coming']) === 1 ? 'is' : 'are' ?> coming up - <?= escapeHtml($next_closure['holiday_name']) ?> next, <?= date('M j', strtotime($next_closure['holiday_date'])) ?>.
<?php } else { ?>
No <a href="?show=days" data-sla-closures>closure days</a> are coming up.
<?php } ?>
</small>
</div>
<div class="mb-3">
<label class="form-label">Warn at</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-bell"></i></span>
<input type="number" class="form-control" name="warning_percent" min="1" max="99" step="1" value="<?= $warning_percent ?>" required>
<span class="input-group-text">% of the target</span>
</div>
<small class="text-secondary">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.</small>
</div>
<div class="mb-1">
<label class="form-label">Also email</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" name="notification_email" maxlength="200" placeholder="dispatch@example.com" value="<?= escapeHtml($notification_email) ?>">
</div>
<small class="text-secondary">A shared address that gets every warning and missed target too. Leave it empty for none.</small>
</div>
<?php if ($alerts_stopped !== '' && $active_slas) { ?>
<div class="alert alert-warning small py-2 mt-3 mb-0" data-sla-alerts-stopped>
<i class="fas fa-fw fa-exclamation-circle me-1"></i><?= $alerts_stopped ?>, so no warnings go out and missed targets aren't recorded until it's back. See <a href="cron.php" class="alert-link">Maintenance &gt; Cron</a>.
</div>
<?php } ?>
</div>
<div class="card-footer d-flex flex-wrap align-items-center gap-2">
<button type="submit" name="edit_sla_settings" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Save</button>
<span class="small text-secondary">Targets on tickets not yet closed are worked out again.</span>
</div>
</form>
</div>
</div>
</div>
<?php require_once "../includes/footer.php";
<?php
require_once "../includes/footer.php";

View File

@@ -610,6 +610,48 @@ function formatSlaMinutes($minutes)
return $text;
}
// The minutes in one business day, or 0 when no business calendar is set and
// the clocks run around the clock - the same test addBusinessMinutes makes.
function slaBusinessDayMinutes()
{
$sla_settings = getSlaSettings();
$day_start = $sla_settings['business_hours_start'];
$day_end = $sla_settings['business_hours_end'];
if (empty($sla_settings['business_days']) || empty($day_start) || empty($day_end) || $day_start >= $day_end) {
return 0;
}
return intval((strtotime($day_end) - strtotime($day_start)) / 60);
}
// A target as the hours and minutes it was typed as - "48 hours", "1 hour 30
// minutes", "45 minutes" - where formatSlaMinutes() says how long that is in
// business days. Admin > SLAs shows both: 48 hours of a 9-5 day is six days.
function formatSlaClockMinutes($minutes)
{
$minutes = max(0, intval($minutes));
$hours = intdiv($minutes, 60);
$remainder = $minutes % 60;
$parts = [];
if ($hours > 0) {
$parts[] = $hours . ' hour' . ($hours == 1 ? '' : 's');
}
if ($remainder > 0 || !$parts) {
$parts[] = $remainder . ' minute' . ($remainder == 1 ? '' : 's');
}
return implode(' ', $parts);
}
// A target from the SLA window's two boxes, hours and minutes, in minutes.
// Either can be left empty; a caller sending only minutes sends the lot.
function slaTargetMinutes($hours, $minutes)
{
return max(0, intval($hours)) * 60 + max(0, intval($minutes));
}
// Record the ticket's first response (if not already recorded) and judge the
// response SLA against the stored due date. Replaces the previous inline
// ticket_first_response_at updates so the SLA verdict can never drift from