Add optional ticket SLAs

Response/resolution targets stamped at creation from per-client/priority assignments, business-hours due date math, warn/breach alert stages via cron/ticket_sla.php, ticket list coloring, per-ticket SLA override, admin page for plans/assignments/business hours. DB update 2.5.0. No behavior change unless SLAs are assigned. Bulk reply now only counts Public replies as first response.
This commit is contained in:
johnnyq
2026-07-29 00:00:42 -04:00
parent 78ee38b39f
commit d08ef6184a
23 changed files with 1479 additions and 11 deletions

View File

@@ -0,0 +1,59 @@
<?php
/*
* ITFlow - Database update to version 2.5.0 (from 2.4.8)
* Included by admin/database_updates.php - do not access directly
*
* (2.4.9 was briefly used by the reverted refunds change - skipping that
* number so instances that applied it before the revert still pick this up)
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Ticket SLAs. slas holds the response/resolution targets and
// sla_assignments maps a client + priority to an SLA (client 0 rows are
// the global default, an assignment pointing at SLA 0 is an explicit "no
// SLA" override). SLAs are optional - with no assignments defined, nothing
// in the app changes behaviour.
mysqli_query($mysqli, "CREATE TABLE `sla_assignments` (
`sla_assignment_id` int(11) NOT NULL AUTO_INCREMENT,
`sla_assignment_client_id` int(11) NOT NULL DEFAULT 0,
`sla_assignment_priority` varchar(200) NOT NULL,
`sla_assignment_sla_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`sla_assignment_id`),
UNIQUE KEY `sla_assignment_client_priority` (`sla_assignment_client_id`,`sla_assignment_priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
mysqli_query($mysqli, "CREATE TABLE `slas` (
`sla_id` int(11) NOT NULL AUTO_INCREMENT,
`sla_name` varchar(200) NOT NULL,
`sla_description` varchar(500) DEFAULT NULL,
`sla_response_minutes` int(11) NOT NULL,
`sla_resolution_minutes` int(11) DEFAULT NULL,
`sla_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`sla_archived_at` datetime DEFAULT NULL,
PRIMARY KEY (`sla_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
// SLA targets are computed once at write time and stored on the ticket, so
// the ticket list and cron/ticket_sla.php only ever compare datetimes. The
// due date indexes bound the cron's every-minute scans the same way the
// 2.4.8 logs index bounded the login lockout queries.
mysqli_query($mysqli, "ALTER TABLE `tickets`
ADD COLUMN `ticket_sla_id` int(11) NOT NULL DEFAULT 0 AFTER `ticket_status`,
ADD COLUMN `ticket_response_due_at` datetime DEFAULT NULL AFTER `ticket_first_response_at`,
ADD COLUMN `ticket_resolution_due_at` datetime DEFAULT NULL AFTER `ticket_response_due_at`,
ADD COLUMN `ticket_response_sla_met` tinyint(1) DEFAULT NULL AFTER `ticket_resolution_due_at`,
ADD COLUMN `ticket_resolution_sla_met` tinyint(1) DEFAULT NULL AFTER `ticket_response_sla_met`,
ADD COLUMN `ticket_response_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0 AFTER `ticket_resolution_sla_met`,
ADD COLUMN `ticket_resolution_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0 AFTER `ticket_response_sla_alert_stage`,
ADD INDEX `ticket_response_due_at` (`ticket_response_due_at`),
ADD INDEX `ticket_resolution_due_at` (`ticket_resolution_due_at`)");
// Business hours + SLA notification settings
mysqli_query($mysqli, "ALTER TABLE `settings`
ADD COLUMN `config_business_days` varchar(20) NOT NULL DEFAULT '1,2,3,4,5' AFTER `config_timezone`,
ADD COLUMN `config_business_hours_start` time NOT NULL DEFAULT '09:00:00' AFTER `config_business_days`,
ADD COLUMN `config_business_hours_end` time NOT NULL DEFAULT '17:00:00' AFTER `config_business_hours_start`,
ADD COLUMN `config_sla_warning_percent` tinyint(3) NOT NULL DEFAULT 75 AFTER `config_business_hours_end`,
ADD COLUMN `config_sla_notification_email` varchar(200) DEFAULT NULL AFTER `config_sla_warning_percent`");

View File

@@ -77,6 +77,14 @@
</a>
</li>
<?php } ?>
<?php if ($config_module_enable_ticketing) { ?>
<li class="nav-item">
<a href="/admin/sla.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'sla.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-stopwatch"></i>
<p>SLAs</p>
</a>
</li>
<?php } ?>
<li class="nav-header">INTEGRATIONS</li>
<?php if ($config_module_enable_accounting) { ?>

View File

@@ -0,0 +1,64 @@
<?php
require_once '../../includes/modal_header.php';
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-stopwatch mr-2"></i>New SLA</h5>
<button type="button" class="close text-white" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="form-group">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-stopwatch"></i></span>
</div>
<input type="text" class="form-control" name="name" placeholder="SLA name" maxlength="200" required autofocus>
</div>
</div>
<div class="form-group">
<label>Description</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-comment"></i></span>
</div>
<input type="text" class="form-control" name="description" placeholder="Short description" maxlength="500">
</div>
</div>
<div class="form-group">
<label>Response target (business minutes) <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-reply"></i></span>
</div>
<input type="number" class="form-control" name="response_minutes" min="1" placeholder="e.g. 60" required>
</div>
</div>
<div class="form-group">
<label>Resolution target (business minutes)</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-flag-checkered"></i></span>
</div>
<input type="number" class="form-control" name="resolution_minutes" min="1" placeholder="Leave empty for response-only">
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="add_sla" class="btn btn-primary text-bold"><i class="fas fa-check mr-2"></i>Create</button>
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fas fa-times mr-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -0,0 +1,85 @@
<?php
require_once '../../includes/modal_header.php';
// With a client_id we're editing that client's overrides, otherwise adding new
$client_id = intval($_GET['client_id'] ?? 0);
$client_name = '';
$client_assignments = [];
if ($client_id) {
$client_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id LIMIT 1"));
$client_name = escapeHtml($client_row['client_name']);
$sql_assignments = mysqli_query($mysqli, "SELECT sla_assignment_priority, sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = $client_id");
while ($assignment_row = mysqli_fetch_assoc($sql_assignments)) {
$client_assignments[$assignment_row['sla_assignment_priority']] = intval($assignment_row['sla_assignment_sla_id']);
}
}
$sql_slas = mysqli_query($mysqli, "SELECT sla_id, sla_name FROM slas WHERE sla_archived_at IS NULL ORDER BY sla_name ASC");
$active_slas = [];
while ($sla_row = mysqli_fetch_assoc($sql_slas)) {
$active_slas[intval($sla_row['sla_id'])] = $sla_row['sla_name'];
}
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-random mr-2"></i><?= $client_id ? "Editing SLA overrides: <strong>$client_name</strong>" : "New client SLA override" ?></h5>
<button type="button" class="close text-white" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="form-group">
<label>Client <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
</div>
<?php if ($client_id) { ?>
<input type="hidden" name="client_id" value="<?= $client_id ?>">
<input type="text" class="form-control" value="<?= $client_name ?>" disabled>
<?php } else { ?>
<select class="form-control select2" name="client_id" required>
<option value="">- Client -</option>
<?php
$sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL ORDER BY client_name ASC");
while ($client_row = mysqli_fetch_assoc($sql_clients)) { ?>
<option value="<?= intval($client_row['client_id']) ?>"><?= escapeHtml($client_row['client_name']) ?></option>
<?php } ?>
</select>
<?php } ?>
</div>
</div>
<?php foreach (['Low', 'Medium', 'High'] as $priority) {
$current = $client_assignments[$priority] ?? 'default';
?>
<div class="form-group">
<label><?= $priority ?> priority</label>
<select class="form-control" name="client_sla_<?= strtolower($priority) ?>">
<option value="default" <?php if ($current === 'default') { echo "selected"; } ?>>Default (follow global)</option>
<option value="0" <?php if ($current === 0) { echo "selected"; } ?>>None (no SLA)</option>
<?php foreach ($active_slas as $active_sla_id => $active_sla_name) { ?>
<option value="<?= $active_sla_id ?>" <?php if ($current === $active_sla_id) { echo "selected"; } ?>><?= escapeHtml($active_sla_name) ?></option>
<?php } ?>
</select>
</div>
<?php } ?>
</div>
<div class="modal-footer">
<button type="submit" name="save_client_sla_assignment" class="btn btn-primary text-bold"><i class="fas fa-check mr-2"></i>Save</button>
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fas fa-times mr-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -0,0 +1,76 @@
<?php
require_once '../../includes/modal_header.php';
$sla_id = intval($_GET['id']);
$sql = mysqli_query($mysqli, "SELECT * 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']);
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-stopwatch mr-2"></i>Editing SLA: <strong><?= $sla_name ?></strong></h5>
<button type="button" class="close text-white" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="sla_id" value="<?= $sla_id ?>">
<div class="modal-body">
<div class="form-group">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-stopwatch"></i></span>
</div>
<input type="text" class="form-control" name="name" value="<?= $sla_name ?>" maxlength="200" required>
</div>
</div>
<div class="form-group">
<label>Description</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-comment"></i></span>
</div>
<input type="text" class="form-control" name="description" value="<?= $sla_description ?>" maxlength="500">
</div>
</div>
<div class="form-group">
<label>Response target (business minutes) <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-reply"></i></span>
</div>
<input type="number" class="form-control" name="response_minutes" min="1" value="<?= $sla_response_minutes ?>" required>
</div>
</div>
<div class="form-group">
<label>Resolution target (business minutes)</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-flag-checkered"></i></span>
</div>
<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>
</div>
<div class="modal-footer">
<button type="submit" name="edit_sla" class="btn btn-primary text-bold"><i class="fas fa-check mr-2"></i>Save</button>
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fas fa-times mr-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

217
admin/post/sla.php Normal file
View File

@@ -0,0 +1,217 @@
<?php
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['add_sla'])) {
validateCSRFToken();
$name = escapeSql($_POST['name']);
$description = escapeSql($_POST['description']);
$response_minutes = intval($_POST['response_minutes']);
$resolution_minutes = intval($_POST['resolution_minutes']);
$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");
redirect();
}
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']);
$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");
// Re-stamp open tickets on this SLA so their targets follow the new minutes
$restamped = 0;
$sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_sla_id = $sla_id AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL");
while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) {
applyTicketSla($ticket_row['ticket_id'], $sla_id);
$restamped++;
}
logAudit("SLA", "Edit", "$session_name edited SLA $name");
flashAlert("SLA <strong>$name</strong> updated - targets recalculated on $restamped open ticket(s)");
redirect();
}
if (isset($_GET['archive_sla'])) {
validateCSRFToken();
$sla_id = intval($_GET['archive_sla']);
mysqli_query($mysqli, "UPDATE slas SET sla_archived_at = NOW() WHERE sla_id = $sla_id");
// Assignments pointing at an archived SLA resolve to "no SLA" for new
// tickets; existing tickets keep their stamped targets
logAudit("SLA", "Archive", "$session_name archived SLA ID $sla_id");
flashAlert("SLA archived");
redirect();
}
if (isset($_GET['unarchive_sla'])) {
validateCSRFToken();
$sla_id = intval($_GET['unarchive_sla']);
mysqli_query($mysqli, "UPDATE slas SET sla_archived_at = NULL WHERE sla_id = $sla_id");
logAudit("SLA", "Unarchive", "$session_name restored SLA ID $sla_id");
flashAlert("SLA restored");
redirect();
}
if (isset($_POST['edit_sla_settings'])) {
validateCSRFToken();
// Business days arrive as an array of ISO weekday numbers (1 = Mon .. 7 = Sun)
$business_days = [];
if (isset($_POST['business_days']) && is_array($_POST['business_days'])) {
foreach ($_POST['business_days'] as $day) {
$day = intval($day);
if ($day >= 1 && $day <= 7) {
$business_days[] = $day;
}
}
}
$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']);
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");
// Business hours feed the due date math - re-stamp open SLA tickets
$restamped = 0;
$sql_tickets = mysqli_query($mysqli, "SELECT ticket_id, ticket_sla_id FROM tickets WHERE ticket_sla_id > 0 AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL");
while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) {
applyTicketSla($ticket_row['ticket_id'], $ticket_row['ticket_sla_id']);
$restamped++;
}
logAudit("Settings", "Edit", "$session_name edited SLA / business hours settings");
flashAlert("SLA settings updated - targets recalculated on $restamped open ticket(s)");
redirect();
}
if (isset($_POST['save_sla_assignments'])) {
validateCSRFToken();
// Global defaults - one select per priority; 0 means no SLA, which for the
// global row is simply no assignment
foreach (['Low', 'Medium', 'High'] as $priority) {
$field = 'global_sla_' . strtolower($priority);
$sla_id = intval($_POST[$field] ?? 0);
mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = 0 AND sla_assignment_priority = '$priority'");
if ($sla_id > 0) {
mysqli_query($mysqli, "INSERT INTO sla_assignments SET sla_assignment_client_id = 0, sla_assignment_priority = '$priority', sla_assignment_sla_id = $sla_id");
}
}
// Re-resolve open tickets against the new defaults
$restamped = 0;
$sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_closed_at IS NULL AND ticket_archived_at IS NULL");
while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) {
applyTicketSla($ticket_row['ticket_id']);
$restamped++;
}
logAudit("SLA", "Edit", "$session_name updated default SLA assignments");
flashAlert("Default SLA assignments saved - $restamped open ticket(s) re-evaluated");
redirect();
}
if (isset($_POST['save_client_sla_assignment'])) {
validateCSRFToken();
$client_id = intval($_POST['client_id']);
// Per-priority values: 'default' = follow the global default (no row),
// '0' = explicitly no SLA for this client, otherwise an sla_id
foreach (['Low', 'Medium', 'High'] as $priority) {
$field = 'client_sla_' . strtolower($priority);
$value = $_POST[$field] ?? 'default';
mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = $client_id AND sla_assignment_priority = '$priority'");
if ($value !== 'default') {
$sla_id = intval($value);
mysqli_query($mysqli, "INSERT INTO sla_assignments SET sla_assignment_client_id = $client_id, sla_assignment_priority = '$priority', sla_assignment_sla_id = $sla_id");
}
}
// Re-resolve this client's open tickets
$restamped = 0;
$sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_client_id = $client_id AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL");
while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) {
applyTicketSla($ticket_row['ticket_id']);
$restamped++;
}
logAudit("SLA", "Edit", "$session_name updated SLA assignments for client ID $client_id");
flashAlert("Client SLA assignments saved - $restamped open ticket(s) re-evaluated");
redirect();
}
if (isset($_GET['delete_client_sla_assignments'])) {
validateCSRFToken();
$client_id = intval($_GET['delete_client_sla_assignments']);
mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = $client_id");
// Back on the global defaults - re-resolve this client's open tickets
$sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_client_id = $client_id AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL");
while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) {
applyTicketSla($ticket_row['ticket_id']);
}
logAudit("SLA", "Delete", "$session_name removed SLA overrides for client ID $client_id");
flashAlert("Client SLA overrides removed");
redirect();
}

274
admin/sla.php Normal file
View File

@@ -0,0 +1,274 @@
<?php
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 * 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'];
}
// Assignments, keyed for easy lookup: [client_id][priority] = sla_id
$assignments = [];
$assignment_client_names = [];
$sql_assignments = mysqli_query($mysqli, "SELECT sla_assignment_client_id, sla_assignment_priority, sla_assignment_sla_id, client_name FROM sla_assignments
LEFT JOIN clients ON sla_assignment_client_id = client_id
ORDER BY client_name ASC"
);
while ($assignment_row = mysqli_fetch_assoc($sql_assignments)) {
$assignment_client_id = intval($assignment_row['sla_assignment_client_id']);
$assignments[$assignment_client_id][$assignment_row['sla_assignment_priority']] = intval($assignment_row['sla_assignment_sla_id']);
if ($assignment_client_id) {
$assignment_client_names[$assignment_client_id] = $assignment_row['client_name'];
}
}
// Wording for an assignment cell: mapped plan, explicit none, or global default
function slaAssignmentWording($assignments, $active_slas, $client_id, $priority)
{
if (!isset($assignments[$client_id][$priority])) {
return "<span class='text-secondary'>Default</span>";
}
$sla_id = $assignments[$client_id][$priority];
if ($sla_id == 0) {
return "<span class='text-secondary'>None</span>";
}
if (isset($active_slas[$sla_id])) {
return escapeHtml($active_slas[$sla_id]);
}
return "<span class='text-secondary'>None (archived)</span>";
}
?>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-stopwatch mr-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 mr-2"></i>New SLA</button>
</div>
</div>
<div class="card-body">
<p class="text-muted">SLAs are optional. Tickets only get response/resolution targets when an assignment below matches their client and priority - 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>
<?php } else { ?>
<div class="text-success text-bold">Active</div>
<?php } ?>
</td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-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 mr-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 mr-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 mr-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 mr-2"></i>SLA Assignments</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/sla/sla_assignment_client.php"><i class="fas fa-plus mr-2"></i>Client Override</button>
</div>
</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 (all clients)</strong>
<div class="form-row mt-2">
<?php foreach (['Low', 'Medium', 'High'] as $priority) {
$selected_sla_id = $assignments[0][$priority] ?? 0;
?>
<div class="form-group col-md-3">
<label><?= $priority ?></label>
<select class="form-control" 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 class="form-group col-md-3 d-flex align-items-end">
<button type="submit" name="save_sla_assignments" class="btn btn-primary"><i class="fas fa-check mr-2"></i>Save Defaults</button>
</div>
</div>
</form>
<hr>
<strong>Client overrides</strong>
<div class="table-responsive-sm mt-2">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark <?php if (empty($assignment_client_names)) { echo "d-none"; } ?>">
<tr>
<th>Client</th>
<th>Low</th>
<th>Medium</th>
<th>High</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php if (empty($assignment_client_names)) { ?>
<tr><td colspan="5" class="text-secondary">No client overrides - everyone follows the defaults above.</td></tr>
<?php } ?>
<?php foreach ($assignment_client_names as $override_client_id => $override_client_name) { ?>
<tr>
<td>
<a href="#" class="ajax-modal" data-modal-url="modals/sla/sla_assignment_client.php?client_id=<?= $override_client_id ?>">
<?= escapeHtml($override_client_name) ?>
</a>
</td>
<td><?= slaAssignmentWording($assignments, $active_slas, $override_client_id, 'Low') ?></td>
<td><?= slaAssignmentWording($assignments, $active_slas, $override_client_id, 'Medium') ?></td>
<td><?= slaAssignmentWording($assignments, $active_slas, $override_client_id, 'High') ?></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-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_assignment_client.php?client_id=<?= $override_client_id ?>">
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_client_sla_assignments=<?= $override_client_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Remove
</a>
</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-business-time mr-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="form-group">
<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>
<?php } ?>
</div>
<small class="text-muted">SLA clocks only run during business hours on these days. Unchecking everything makes SLAs count 24x7.</small>
</div>
<div class="form-row">
<div class="form-group 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="form-group 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="form-group 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="form-group 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" 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 mr-2"></i>Save Settings</button>
</form>
</div>
</div>
<?php require_once "../includes/footer.php";