mirror of
https://github.com/itflow-org/itflow
synced 2026-08-05 07:07:14 +00:00
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:
59
admin/database_updates/2.5.0.php
Normal file
59
admin/database_updates/2.5.0.php
Normal 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`");
|
||||
@@ -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) { ?>
|
||||
|
||||
64
admin/modals/sla/sla_add.php
Normal file
64
admin/modals/sla/sla_add.php
Normal 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>×</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';
|
||||
85
admin/modals/sla/sla_assignment_client.php
Normal file
85
admin/modals/sla/sla_assignment_client.php
Normal 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>×</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';
|
||||
76
admin/modals/sla/sla_edit.php
Normal file
76
admin/modals/sla/sla_edit.php
Normal 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>×</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
217
admin/post/sla.php
Normal 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
274
admin/sla.php
Normal 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 & 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";
|
||||
70
agent/modals/ticket/ticket_sla.php
Normal file
70
agent/modals/ticket/ticket_sla.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_header.php';
|
||||
|
||||
enforceUserPermission('module_support', 2);
|
||||
|
||||
$ticket_id = intval($_GET['id']);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM tickets
|
||||
LEFT JOIN clients ON client_id = ticket_client_id
|
||||
WHERE ticket_id = $ticket_id
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
$ticket_prefix = escapeHtml($row['ticket_prefix']);
|
||||
$ticket_number = intval($row['ticket_number']);
|
||||
$ticket_sla_id = intval($row['ticket_sla_id']);
|
||||
$client_id = intval($row['ticket_client_id']);
|
||||
|
||||
if ($client_id) {
|
||||
enforceClientAccess();
|
||||
}
|
||||
|
||||
$sql_slas = mysqli_query($mysqli, "SELECT sla_id, sla_name FROM slas WHERE sla_archived_at IS NULL ORDER BY sla_name ASC");
|
||||
|
||||
ob_start();
|
||||
|
||||
?>
|
||||
|
||||
<div class="modal-header bg-dark">
|
||||
<h5 class="modal-title"><i class="fa fa-fw fa-stopwatch mr-2"></i>Editing SLA: <strong><?= "$ticket_prefix$ticket_number" ?></strong></h5>
|
||||
<button type="button" class="close text-white" data-dismiss="modal">
|
||||
<span>×</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="ticket_id" value="<?= $ticket_id ?>">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label>SLA</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>
|
||||
<select class="form-control select2" name="sla_id" required>
|
||||
<option value="0" <?php if ($ticket_sla_id == 0) { echo "selected"; } ?>>None</option>
|
||||
<?php while ($sla_row = mysqli_fetch_assoc($sql_slas)) { ?>
|
||||
<option value="<?= intval($sla_row['sla_id']) ?>" <?php if ($ticket_sla_id == intval($sla_row['sla_id'])) { echo "selected"; } ?>><?= escapeHtml($sla_row['sla_name']) ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
<small class="text-muted">Response and resolution targets are recalculated from the ticket's creation time.</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="submit" name="edit_ticket_sla" class="btn btn-primary text-bold"><i class="fa fa-check mr-2"></i>Save</button>
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fa fa-times mr-2"></i>Cancel</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_footer.php';
|
||||
@@ -844,6 +844,7 @@ if (isset($_POST['bulk_add_client_ticket'])) {
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_category = $category_id, ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_billable = $billable, ticket_status = $ticket_status, ticket_created_by = $session_user_id, ticket_assigned_to = $assigned_to, ticket_url_key = '$url_key', ticket_client_id = $client_id, ticket_project_id = $project_id");
|
||||
|
||||
$ticket_id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Add Tasks
|
||||
if (!empty($_POST['tasks'])) {
|
||||
|
||||
@@ -70,6 +70,7 @@ if (isset($_POST['add_project'])) {
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_subject = '$ticket_template_subject', ticket_details = '$ticket_template_details', ticket_priority = 'Low', ticket_status = 1, ticket_created_by = $session_user_id, ticket_client_id = $client_id, ticket_project_id = $project_id");
|
||||
|
||||
$ticket_id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Task Templates for Ticket template and add the to the ticket
|
||||
$sql_task_templates = mysqli_query($mysqli,
|
||||
|
||||
@@ -131,6 +131,7 @@ if (isset($_POST['bulk_force_recurring_tickets'])) {
|
||||
// Raise the ticket
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = '$ticket_status', ticket_billable = $billable, ticket_url_key = '$url_key', ticket_created_by = $created_id, ticket_assigned_to = $assigned_id, ticket_contact_id = $contact_id, ticket_client_id = $client_id, ticket_asset_id = $asset_id, ticket_category = $category, ticket_recurring_ticket_id = $recurring_ticket_id");
|
||||
$id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($id);
|
||||
|
||||
// Copy Additional Assets from Recurring ticket to new ticket
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_assets (ticket_id, asset_id)
|
||||
@@ -271,6 +272,7 @@ if (isset($_GET['force_recurring_ticket'])) {
|
||||
// Raise the ticket
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = '$ticket_status', ticket_billable = $billable, ticket_url_key = '$url_key', ticket_created_by = $created_id, ticket_assigned_to = $assigned_id, ticket_contact_id = $contact_id, ticket_client_id = $client_id, ticket_asset_id = $asset_id, ticket_category = $category, ticket_recurring_ticket_id = $recurring_ticket_id");
|
||||
$id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($id);
|
||||
|
||||
// Copy Additional Assets from Recurring ticket to new ticket
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_assets (ticket_id, asset_id)
|
||||
|
||||
@@ -77,6 +77,7 @@ if (isset($_POST['add_ticket'])) {
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Agent', ticket_category = $category_id, ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_billable = '$billable', ticket_status = '$ticket_status', ticket_vendor_ticket_number = '$vendor_ticket_number', ticket_vendor_id = $vendor_id, ticket_location_id = $location_id, ticket_asset_id = $asset_id, ticket_created_by = $session_user_id, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_due_at = $due, ticket_client_id = $client_id, ticket_invoice_id = 0, ticket_project_id = $project_id");
|
||||
|
||||
$ticket_id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Add Tasks from Template if Template was selected
|
||||
if($ticket_template_id) {
|
||||
@@ -237,6 +238,7 @@ if (isset($_POST['edit_ticket'])) {
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_category = $category_id, ticket_subject = '$ticket_subject', ticket_priority = '$ticket_priority', ticket_billable = $billable, ticket_details = '$details', ticket_due_at = $due, ticket_vendor_ticket_number = '$vendor_ticket_number', ticket_contact_id = $contact_id, ticket_assigned_to = $assigned_to, ticket_vendor_id = $vendor_id, ticket_location_id = $location_id, ticket_asset_id = $asset_id, ticket_project_id = $project_id WHERE ticket_id = $ticket_id");
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Add Additional Assets
|
||||
if (isset($_POST['additional_assets'])) {
|
||||
@@ -343,6 +345,7 @@ if (isset($_POST['edit_ticket_priority'])) {
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_priority = '$priority' WHERE ticket_id = $ticket_id");
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Update Ticket History
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_history SET ticket_history_status = '$ticket_status', ticket_history_description = '$session_name changed priority from $original_priority to $priority', ticket_history_ticket_id = $ticket_id");
|
||||
@@ -357,6 +360,66 @@ if (isset($_POST['edit_ticket_priority'])) {
|
||||
|
||||
}
|
||||
|
||||
if (isset($_POST['edit_ticket_sla'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
enforceUserPermission('module_support', 2);
|
||||
|
||||
$ticket_id = intval($_POST['ticket_id']);
|
||||
$sla_id = intval($_POST['sla_id']);
|
||||
|
||||
// Get ticket details before updating
|
||||
$sql = mysqli_query($mysqli, "SELECT
|
||||
ticket_prefix, ticket_number, ticket_sla_id, ticket_status_name, ticket_client_id
|
||||
FROM tickets
|
||||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
WHERE ticket_id = $ticket_id"
|
||||
);
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
$ticket_prefix = escapeSql($row['ticket_prefix']);
|
||||
$ticket_number = intval($row['ticket_number']);
|
||||
$original_sla_id = intval($row['ticket_sla_id']);
|
||||
$ticket_status = escapeSql($row['ticket_status_name']);
|
||||
$client_id = intval($row['ticket_client_id']);
|
||||
|
||||
// Don't Enforce Client Access if Ticket doesn't have an assigned client
|
||||
if ($client_id) {
|
||||
enforceClientAccess();
|
||||
}
|
||||
|
||||
// Look up SLA names for the history/audit trail (0 = None)
|
||||
$original_sla_name = "None";
|
||||
if ($original_sla_id) {
|
||||
$sla_sql = mysqli_query($mysqli, "SELECT sla_name FROM slas WHERE sla_id = $original_sla_id");
|
||||
if (mysqli_num_rows($sla_sql)) {
|
||||
$original_sla_name = escapeSql(mysqli_fetch_assoc($sla_sql)['sla_name']);
|
||||
}
|
||||
}
|
||||
$sla_name = "None";
|
||||
if ($sla_id) {
|
||||
$sla_sql = mysqli_query($mysqli, "SELECT sla_name FROM slas WHERE sla_id = $sla_id");
|
||||
if (mysqli_num_rows($sla_sql)) {
|
||||
$sla_name = escapeSql(mysqli_fetch_assoc($sla_sql)['sla_name']);
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the ticket to the chosen SLA and recompute its targets
|
||||
applyTicketSla($ticket_id, $sla_id);
|
||||
|
||||
// Update Ticket History
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_history SET ticket_history_status = '$ticket_status', ticket_history_description = '$session_name changed SLA from $original_sla_name to $sla_name', ticket_history_ticket_id = $ticket_id");
|
||||
|
||||
logAudit("Ticket", "Edit", "$session_name changed SLA from $original_sla_name to $sla_name for ticket $ticket_prefix$ticket_number", $client_id, $ticket_id);
|
||||
|
||||
triggerCustomAction('ticket_update', $ticket_id);
|
||||
|
||||
flashAlert("SLA updated from <strong>$original_sla_name</strong> to <strong>$sla_name</strong>");
|
||||
|
||||
redirect();
|
||||
|
||||
}
|
||||
|
||||
if (isset($_POST['edit_ticket_contact'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
@@ -1091,6 +1154,7 @@ if (isset($_POST['bulk_edit_ticket_priority'])) {
|
||||
|
||||
// Update ticket & insert reply
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_priority = '$priority' WHERE ticket_id = $ticket_id");
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$session_name updated the priority from $current_ticket_priority to $priority', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:01:00', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
|
||||
|
||||
@@ -1208,10 +1272,11 @@ if (isset($_POST['bulk_merge_tickets'])) {
|
||||
|
||||
// Update current ticket
|
||||
if (empty($ticket_first_response_at)) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number bulk merged into <a href=\"ticket.php?ticket_id=$merge_into_ticket_id\">$ticket_prefix$merge_into_ticket_number</a>. Comment: $merge_comment', ticket_reply_time_worked = '00:01:00', ticket_reply_type = '$ticket_reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = '5', ticket_resolved_at = NOW(), ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id") or die(mysqli_error($mysqli));
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
// Update new parent ticket
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number was bulk merged into this ticket with comment: $merge_comment.<br><br><b>$ticket_subject</b><br>$ticket_details', ticket_reply_time_worked = '00:01:00', ticket_reply_type = 'Internal', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $merge_into_ticket_id");
|
||||
@@ -1286,11 +1351,12 @@ if (isset($_POST['bulk_resolve_tickets'])) {
|
||||
|
||||
// Mark FR time if required
|
||||
if (empty($ticket_first_response_at)) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
|
||||
// Update ticket & insert reply
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 4, ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$details', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '$ticket_reply_time_worked', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
|
||||
|
||||
@@ -1426,9 +1492,9 @@ if (isset($_POST['bulk_ticket_reply'])) {
|
||||
$client_uri = '';
|
||||
}
|
||||
|
||||
// Mark FR time if required
|
||||
if (empty($ticket_first_response_at)) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
// Mark FR time if required - internal notes don't count as a response
|
||||
if (empty($ticket_first_response_at) && $ticket_reply_type == 'Public') {
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
|
||||
// Add reply
|
||||
@@ -1451,6 +1517,7 @@ if (isset($_POST['bulk_ticket_reply'])) {
|
||||
// Resolve the ticket, if set
|
||||
if ($ticket_status == 4) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
// Logging
|
||||
logAudit("Ticket", "Resolved", "$session_name resolved Ticket $ticket_prefix$ticket_number", $client_id, $ticket_id);
|
||||
@@ -1684,6 +1751,7 @@ if (isset($_POST['bulk_add_asset_ticket'])) {
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_category = $category_id, ticket_subject = '$subject_asset_prepended', ticket_details = '$details', ticket_priority = '$priority', ticket_billable = $billable, ticket_status = $ticket_status, ticket_asset_id = $asset_id, ticket_created_by = $session_user_id, ticket_assigned_to = $assigned_to, ticket_url_key = '$url_key', ticket_client_id = $client_id, ticket_project_id = $project_id");
|
||||
|
||||
$ticket_id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Add Tasks
|
||||
if (!empty($_POST['tasks'])) {
|
||||
@@ -1769,6 +1837,7 @@ if (isset($_POST['add_ticket_reply'])) {
|
||||
// Resolve the ticket, if set
|
||||
if ($ticket_status == 4) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
logAudit("Ticket", "Resolved", "$session_name resolved Ticket ticket ID $ticket_id", $client_id, $ticket_id);
|
||||
}
|
||||
@@ -1885,7 +1954,7 @@ if (isset($_POST['add_ticket_reply'])) {
|
||||
|
||||
// Handle first response
|
||||
if (empty($ticket_first_response_at) && $ticket_reply_type == 'Public') {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
|
||||
// Custom action/notif handler
|
||||
@@ -2059,12 +2128,13 @@ if (isset($_POST['merge_ticket'])) {
|
||||
|
||||
// Update current ticket
|
||||
if (empty($ticket_first_response_at)) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number merged into <a href=\"ticket.php?ticket_id=$merge_into_ticket_id\">$ticket_prefix$merge_into_ticket_number</a>. Comment: $merge_comment', ticket_reply_time_worked = '00:01:00', ticket_reply_type = '$ticket_reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = '5', ticket_resolved_at = NOW(), ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id") or die(mysqli_error($mysqli));
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
//Update new parent ticket
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number was merged into this ticket with comment: $merge_comment.<br><br><b>$ticket_subject</b><br>$ticket_details', ticket_reply_time_worked = '00:01:00', ticket_reply_type = '$ticket_reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $merge_into_ticket_id");
|
||||
@@ -2101,6 +2171,7 @@ if (isset($_POST['change_client_ticket'])) {
|
||||
|
||||
// Update ticket client & contact
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_client_id = $client_id, ticket_contact_id = $contact_id WHERE ticket_id = $ticket_id LIMIT 1");
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
logAudit("Ticket", "Change", "$session_name changed ticket client", $client_id, $ticket_id);
|
||||
|
||||
@@ -2134,11 +2205,12 @@ if (isset($_GET['resolve_ticket'])) {
|
||||
|
||||
// Mark FR
|
||||
if (empty($ticket_first_response_at)) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
|
||||
// Resolve
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 4, ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id");
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
logAudit("Ticket", "Resolved", "$session_name resolved ticket $ticket_prefix$ticket_number (ID: $ticket_id)", $client_id, $ticket_id);
|
||||
|
||||
@@ -2239,6 +2311,7 @@ if (isset($_GET['close_ticket'])) {
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 5, ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id") or die(mysqli_error($mysqli));
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket closed.', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:01:00', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
|
||||
|
||||
@@ -2339,6 +2412,7 @@ if (isset($_GET['reopen_ticket'])) {
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 2, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id");
|
||||
resetTicketResolutionSla($ticket_id);
|
||||
|
||||
logAudit("Ticket", "Reopened", "$session_name reopened ticket ID $ticket_id", $client_id, $ticket_id);
|
||||
|
||||
|
||||
@@ -110,6 +110,18 @@ if (isset($_GET['ticket_id'])) {
|
||||
$ticket_updated_at = escapeHtml($row['ticket_updated_at']);
|
||||
$ticket_updated_at_ago = timeAgo($row['ticket_updated_at']);
|
||||
$ticket_first_response_at = escapeHtml($row['ticket_first_response_at']);
|
||||
$ticket_sla_id = intval($row['ticket_sla_id']);
|
||||
$ticket_response_due_at = escapeHtml($row['ticket_response_due_at']);
|
||||
$ticket_resolution_due_at = escapeHtml($row['ticket_resolution_due_at']);
|
||||
$ticket_response_sla_met = $row['ticket_response_sla_met'];
|
||||
$ticket_resolution_sla_met = $row['ticket_resolution_sla_met'];
|
||||
$ticket_sla_name = "None";
|
||||
if ($ticket_sla_id) {
|
||||
$sla_name_sql = mysqli_query($mysqli, "SELECT sla_name FROM slas WHERE sla_id = $ticket_sla_id");
|
||||
if (mysqli_num_rows($sla_name_sql)) {
|
||||
$ticket_sla_name = escapeHtml(mysqli_fetch_assoc($sla_name_sql)['sla_name']);
|
||||
}
|
||||
}
|
||||
$ticket_resolved_at = escapeHtml($row['ticket_resolved_at']);
|
||||
$ticket_resolved_at_ago = timeAgo($row['ticket_resolved_at']);
|
||||
$ticket_resolved_date = date('Y-m-d', strtotime($ticket_resolved_at));
|
||||
@@ -488,6 +500,16 @@ if (isset($_GET['ticket_id'])) {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<i class="fa fa-fw fa-stopwatch text-secondary mr-2"></i>SLA:
|
||||
<a href="#" title="SLA"
|
||||
<?php if (lookupUserPermission("module_support") >= 2 && empty($ticket_closed_at)) { ?>
|
||||
class="ajax-modal"
|
||||
data-modal-url="modals/ticket/ticket_sla.php?id=<?= $ticket_id ?>"
|
||||
<?php } ?>
|
||||
><?= $ticket_sla_name ?></a>
|
||||
</div>
|
||||
|
||||
<!-- Ticket scheduling -->
|
||||
<?php if (empty ($ticket_closed_at)) { ?>
|
||||
<div class="mt-1">
|
||||
@@ -878,6 +900,20 @@ if (isset($_GET['ticket_id'])) {
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<!-- SLA targets (stamped at creation by applyTicketSla) -->
|
||||
<?php if ($ticket_sla_id && $ticket_response_due_at) { ?>
|
||||
<div class="mt-2">
|
||||
<i class="fas fa-fw fa-stopwatch text-secondary mr-1"></i><strong class="mr-1">Respond by:</strong><?= date('M d • g:i A', strtotime($ticket_response_due_at)) ?>
|
||||
<?php if (!is_null($ticket_response_sla_met)) { echo $ticket_response_sla_met ? "<i class='fas fa-fw fa-check text-success ml-1' title='Response SLA met'></i>" : "<i class='fas fa-fw fa-exclamation-triangle text-danger ml-1' title='Response SLA missed'></i>"; } ?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<?php if ($ticket_sla_id && $ticket_resolution_due_at) { ?>
|
||||
<div class="mt-2">
|
||||
<i class="fas fa-fw fa-flag-checkered text-secondary mr-1"></i><strong class="mr-1">Resolve by:</strong><?= date('M d • g:i A', strtotime($ticket_resolution_due_at)) ?>
|
||||
<?php if (!is_null($ticket_resolution_sla_met)) { echo $ticket_resolution_sla_met ? "<i class='fas fa-fw fa-check text-success ml-1' title='Resolution SLA met'></i>" : "<i class='fas fa-fw fa-exclamation-triangle text-danger ml-1' title='Resolution SLA missed'></i>"; } ?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<!-- Time tracking -->
|
||||
<?php if ($ticket_total_reply_time) { ?>
|
||||
<div class="mt-2">
|
||||
|
||||
@@ -90,6 +90,8 @@
|
||||
$ticket_updated_at = escapeHtml($row['ticket_updated_at']);
|
||||
$ticket_updated_at_time_ago = timeAgo($row['ticket_updated_at']);
|
||||
$ticket_closed_at = escapeHtml($row['ticket_closed_at']);
|
||||
// SLA alert stages are maintained by cron/ticket_sla.php (1 = warned, 2 = breached)
|
||||
$ticket_sla_alert_stage = max(intval($row['ticket_response_sla_alert_stage']), intval($row['ticket_resolution_sla_alert_stage']));
|
||||
if (empty($ticket_updated_at)) {
|
||||
if (!empty($ticket_closed_at)) {
|
||||
$ticket_updated_at_display = "<p>Never</p>";
|
||||
@@ -192,7 +194,7 @@
|
||||
|
||||
?>
|
||||
|
||||
<tr class="<?php if(empty($ticket_closed_at) && empty($ticket_updated_at)) { echo "text-bold"; }?> <?php if (empty($ticket_closed_at) && $ticket_reply_type == "Client") { echo "table-warning"; } ?>">
|
||||
<tr class="<?php if(empty($ticket_closed_at) && empty($ticket_updated_at)) { echo "text-bold"; }?> <?php if (empty($ticket_closed_at) && $ticket_sla_alert_stage == 2) { echo "table-danger"; } elseif (empty($ticket_closed_at) && ($ticket_reply_type == "Client" || $ticket_sla_alert_stage == 1)) { echo "table-warning"; } ?>">
|
||||
|
||||
<td class="checkbox-column">
|
||||
<!-- Ticket Bulk Select (for open tickets) -->
|
||||
|
||||
@@ -50,6 +50,7 @@ if (!empty($subject)) {
|
||||
// Check insert & get insert ID
|
||||
if ($insert_sql) {
|
||||
$insert_id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($insert_id);
|
||||
|
||||
// Logging
|
||||
logAudit("Ticket", "Create", "Created ticket $config_ticket_prefix$ticket_number $subject via API ($api_key_name)", $client_id, $insert_id);
|
||||
|
||||
@@ -25,11 +25,12 @@ if (!empty($ticket_id)) {
|
||||
|
||||
// Mark FR (if not)
|
||||
if (empty($ticket_first_response_at)) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
|
||||
// Resolve
|
||||
$update_sql = mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 4, ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
// Check insert & get insert ID
|
||||
if ($update_sql) {
|
||||
|
||||
@@ -49,6 +49,7 @@ if (isset($_POST['add_ticket'])) {
|
||||
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Portal', ticket_category = $category, ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = 1, ticket_billable = $config_ticket_default_billable, ticket_created_by = $session_user_id, ticket_contact_id = $session_contact_id, ticket_asset_id = $asset, ticket_url_key = '$url_key', ticket_client_id = $session_client_id");
|
||||
$ticket_id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($ticket_id);
|
||||
|
||||
// Notify agent DL of the new ticket, if populated with a valid email
|
||||
if ($config_ticket_new_ticket_notification_email) {
|
||||
|
||||
@@ -343,6 +343,7 @@ if (mysqli_num_rows($sql_recurring_tickets) > 0) {
|
||||
// Raise the ticket
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = '$ticket_status', ticket_billable = $billable, ticket_created_by = $created_id, ticket_assigned_to = $assigned_id, ticket_contact_id = $contact_id, ticket_client_id = $client_id, ticket_asset_id = $asset_id, ticket_category = $category, ticket_recurring_ticket_id = $recurring_ticket_id");
|
||||
$id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($id);
|
||||
|
||||
// Copy Additional Assets from Recurring ticket to new ticket
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_assets (ticket_id, asset_id)
|
||||
|
||||
@@ -124,6 +124,7 @@ function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date
|
||||
|
||||
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$ticket_prefix_esc', ticket_number = $ticket_number, ticket_source = 'Email', ticket_subject = '$subject', ticket_details = '$message_esc', ticket_priority = 'Low', ticket_status = 1, ticket_billable = $config_ticket_default_billable, ticket_created_by = 0, ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_client_id = $client_id");
|
||||
$id = mysqli_insert_id($mysqli);
|
||||
applyTicketSla($id);
|
||||
|
||||
// Logging
|
||||
logAudit("Ticket", "Create", "Email parser: Client contact $contact_email_esc created ticket $ticket_prefix_esc$ticket_number ($subject) ($id)", $client_id, $id);
|
||||
|
||||
163
cron/ticket_sla.php
Normal file
163
cron/ticket_sla.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
// Set working directory to the directory this cron script lives at.
|
||||
chdir(dirname(__FILE__));
|
||||
|
||||
// Ensure we're running from command line
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script must be run from the command line.\n");
|
||||
}
|
||||
|
||||
// Prevent overlapping runs of this script
|
||||
$cron_lock_script = __FILE__;
|
||||
require_once "../includes/cron_lock.php";
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
require_once "../functions.php";
|
||||
|
||||
/*
|
||||
* ITFlow - Ticket SLA monitor
|
||||
*
|
||||
* Intended to run every minute:
|
||||
* * * * * * php /var/www/itflow/cron/ticket_sla.php
|
||||
*
|
||||
* Walks open tickets that carry SLA targets and moves each track (response /
|
||||
* resolution) through its alert stages:
|
||||
* 0 = on track, 1 = warning sent, 2 = breach recorded + notified
|
||||
* The stages are what the ticket list colours on (1 = yellow, 2 = red) and
|
||||
* they make every notification fire exactly once. Tickets without an SLA
|
||||
* (ticket_sla_id = 0) are never selected, so with no SLAs assigned this cron
|
||||
* is a no-op.
|
||||
*/
|
||||
|
||||
$sla_settings = getSlaSettings();
|
||||
|
||||
$warning_percent = intval($sla_settings['warning_percent']);
|
||||
if ($warning_percent < 1 || $warning_percent > 99) {
|
||||
$warning_percent = 0; // Out of range = warnings disabled, breach alerts only
|
||||
}
|
||||
|
||||
$sla_notification_email = trim(strval($sla_settings['notification_email']));
|
||||
$from_email = $sla_settings['ticket_from_email'];
|
||||
$from_name = $sla_settings['ticket_from_name'];
|
||||
|
||||
$now = time();
|
||||
|
||||
// Queue in-app + email notifications for an SLA event
|
||||
function sendSlaAlert($ticket, $subject_line, $body_line)
|
||||
{
|
||||
global $mysqli, $sla_notification_email, $from_email, $from_name, $config_base_url;
|
||||
|
||||
$ticket_id = intval($ticket['ticket_id']);
|
||||
$client_id = intval($ticket['ticket_client_id']);
|
||||
|
||||
$ticket_ref = "{$ticket['ticket_prefix']}{$ticket['ticket_number']}";
|
||||
$ticket_subject = strval($ticket['ticket_subject']);
|
||||
|
||||
// appNotify inserts what it is given - escape at the boundary
|
||||
appNotify("Ticket SLA", escapeSql("$subject_line - $ticket_ref - $ticket_subject"), "/agent/ticket.php?ticket_id=$ticket_id", $client_id, $ticket_id);
|
||||
|
||||
// addToMailQueue also inserts raw, and the body's link markup contains
|
||||
// single quotes - escape whole strings once here. The body keeps its HTML,
|
||||
// so it gets mysqli_real_escape_string directly (escapeSql strips tags),
|
||||
// same as the ticket email parser does for its bodies.
|
||||
$email_subject = escapeSql("$subject_line: $ticket_ref - $ticket_subject");
|
||||
$email_body = mysqli_real_escape_string($mysqli, "Hello,<br><br>$body_line<br><br>Ticket: $ticket_ref<br>Subject: $ticket_subject<br><br><a href='https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id'>View ticket</a>");
|
||||
|
||||
$email_data = [];
|
||||
|
||||
if (!empty($sla_notification_email)) {
|
||||
$email_data[] = [
|
||||
'from' => $from_email,
|
||||
'from_name' => escapeSql($from_name),
|
||||
'recipient' => $sla_notification_email,
|
||||
'recipient_name' => 'SLA Notifications',
|
||||
'subject' => $email_subject,
|
||||
'body' => $email_body,
|
||||
];
|
||||
}
|
||||
|
||||
// Assigned agent (skip a duplicate if they are also the notification address)
|
||||
if (!empty($ticket['user_email']) && strtolower($ticket['user_email']) != strtolower($sla_notification_email)) {
|
||||
$email_data[] = [
|
||||
'from' => $from_email,
|
||||
'from_name' => escapeSql($from_name),
|
||||
'recipient' => $ticket['user_email'],
|
||||
'recipient_name' => escapeSql(strval($ticket['user_name'])),
|
||||
'subject' => $email_subject,
|
||||
'body' => $email_body,
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($email_data)) {
|
||||
addToMailQueue($email_data);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Response SLA track ---
|
||||
// Open tickets awaiting a first response, not yet marked breached
|
||||
$sql_response = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_created_at, ticket_response_due_at, ticket_response_sla_alert_stage, sla_response_minutes, user_email, user_name
|
||||
FROM tickets
|
||||
LEFT JOIN slas ON ticket_sla_id = sla_id
|
||||
LEFT JOIN users ON ticket_assigned_to = user_id
|
||||
WHERE ticket_sla_id > 0
|
||||
AND ticket_response_due_at IS NOT NULL
|
||||
AND ticket_first_response_at IS NULL
|
||||
AND ticket_resolved_at IS NULL
|
||||
AND ticket_closed_at IS NULL
|
||||
AND ticket_archived_at IS NULL
|
||||
AND ticket_response_sla_alert_stage < 2"
|
||||
);
|
||||
|
||||
while ($ticket = mysqli_fetch_assoc($sql_response)) {
|
||||
|
||||
$ticket_id = intval($ticket['ticket_id']);
|
||||
$stage = intval($ticket['ticket_response_sla_alert_stage']);
|
||||
$due = strtotime($ticket['ticket_response_due_at']);
|
||||
|
||||
if ($now >= $due) {
|
||||
// Breached without a response - the verdict is final, record the miss
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_response_sla_alert_stage = 2, ticket_response_sla_met = 0 WHERE ticket_id = $ticket_id");
|
||||
sendSlaAlert($ticket, "Response SLA breached", "The response SLA on this ticket was missed (due {$ticket['ticket_response_due_at']}).");
|
||||
|
||||
} elseif ($stage < 1 && $warning_percent) {
|
||||
$warn_at = strtotime(addBusinessMinutes($ticket['ticket_created_at'], floor(intval($ticket['sla_response_minutes']) * $warning_percent / 100)));
|
||||
if ($now >= $warn_at) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_response_sla_alert_stage = 1 WHERE ticket_id = $ticket_id");
|
||||
sendSlaAlert($ticket, "Response SLA at risk", "This ticket is approaching its response SLA (due {$ticket['ticket_response_due_at']}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolution SLA track ---
|
||||
// Open tickets with a resolution target, not yet resolved or marked breached
|
||||
$sql_resolution = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_created_at, ticket_resolution_due_at, ticket_resolution_sla_alert_stage, sla_resolution_minutes, user_email, user_name
|
||||
FROM tickets
|
||||
LEFT JOIN slas ON ticket_sla_id = sla_id
|
||||
LEFT JOIN users ON ticket_assigned_to = user_id
|
||||
WHERE ticket_sla_id > 0
|
||||
AND ticket_resolution_due_at IS NOT NULL
|
||||
AND ticket_resolved_at IS NULL
|
||||
AND ticket_closed_at IS NULL
|
||||
AND ticket_archived_at IS NULL
|
||||
AND ticket_resolution_sla_alert_stage < 2"
|
||||
);
|
||||
|
||||
while ($ticket = mysqli_fetch_assoc($sql_resolution)) {
|
||||
|
||||
$ticket_id = intval($ticket['ticket_id']);
|
||||
$stage = intval($ticket['ticket_resolution_sla_alert_stage']);
|
||||
$due = strtotime($ticket['ticket_resolution_due_at']);
|
||||
|
||||
if ($now >= $due) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_alert_stage = 2, ticket_resolution_sla_met = 0 WHERE ticket_id = $ticket_id");
|
||||
sendSlaAlert($ticket, "Resolution SLA breached", "The resolution SLA on this ticket was missed (due {$ticket['ticket_resolution_due_at']}).");
|
||||
|
||||
} elseif ($stage < 1 && $warning_percent) {
|
||||
$warn_at = strtotime(addBusinessMinutes($ticket['ticket_created_at'], floor(intval($ticket['sla_resolution_minutes']) * $warning_percent / 100)));
|
||||
if ($now >= $warn_at) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_alert_stage = 1 WHERE ticket_id = $ticket_id");
|
||||
sendSlaAlert($ticket, "Resolution SLA at risk", "This ticket is approaching its resolution SLA (due {$ticket['ticket_resolution_due_at']}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
52
db.sql
52
db.sql
@@ -2213,6 +2213,11 @@ CREATE TABLE `settings` (
|
||||
`config_theme` varchar(200) DEFAULT 'blue',
|
||||
`config_telemetry` tinyint(1) DEFAULT 0,
|
||||
`config_timezone` varchar(200) NOT NULL DEFAULT 'America/New_York',
|
||||
`config_business_days` varchar(20) NOT NULL DEFAULT '1,2,3,4,5',
|
||||
`config_business_hours_start` time NOT NULL DEFAULT '09:00:00',
|
||||
`config_business_hours_end` time NOT NULL DEFAULT '17:00:00',
|
||||
`config_sla_warning_percent` tinyint(3) NOT NULL DEFAULT 75,
|
||||
`config_sla_notification_email` varchar(200) DEFAULT NULL,
|
||||
`config_destructive_deletes_enable` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`config_whitelabel_enabled` int(11) NOT NULL DEFAULT 0,
|
||||
`config_whitelabel_key` text DEFAULT NULL,
|
||||
@@ -2249,6 +2254,42 @@ CREATE TABLE `shared_items` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `sla_assignments`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `sla_assignments`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
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;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `slas`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `slas`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
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;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `software`
|
||||
--
|
||||
@@ -2700,6 +2741,7 @@ CREATE TABLE `tickets` (
|
||||
`ticket_details` longtext NOT NULL,
|
||||
`ticket_priority` varchar(200) DEFAULT NULL,
|
||||
`ticket_status` int(11) NOT NULL,
|
||||
`ticket_sla_id` int(11) NOT NULL DEFAULT 0,
|
||||
`ticket_billable` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`ticket_schedule` datetime DEFAULT NULL,
|
||||
`ticket_onsite` tinyint(1) NOT NULL DEFAULT 0,
|
||||
@@ -2712,6 +2754,12 @@ CREATE TABLE `tickets` (
|
||||
`ticket_resolved_at` datetime DEFAULT NULL,
|
||||
`ticket_archived_at` datetime DEFAULT NULL,
|
||||
`ticket_first_response_at` datetime DEFAULT NULL,
|
||||
`ticket_response_due_at` datetime DEFAULT NULL,
|
||||
`ticket_resolution_due_at` datetime DEFAULT NULL,
|
||||
`ticket_response_sla_met` tinyint(1) DEFAULT NULL,
|
||||
`ticket_resolution_sla_met` tinyint(1) DEFAULT NULL,
|
||||
`ticket_response_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`ticket_resolution_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`ticket_closed_at` datetime DEFAULT NULL,
|
||||
`ticket_created_by` int(11) NOT NULL,
|
||||
`ticket_assigned_to` int(11) NOT NULL DEFAULT 0,
|
||||
@@ -2726,7 +2774,9 @@ CREATE TABLE `tickets` (
|
||||
`ticket_project_id` int(11) NOT NULL DEFAULT 0,
|
||||
`ticket_recurring_ticket_id` int(11) DEFAULT 0,
|
||||
`ticket_order` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`ticket_id`)
|
||||
PRIMARY KEY (`ticket_id`),
|
||||
KEY `ticket_response_due_at` (`ticket_response_due_at`),
|
||||
KEY `ticket_resolution_due_at` (`ticket_resolution_due_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@ require_once __DIR__ . '/functions/logging.php';
|
||||
require_once __DIR__ . '/functions/app.php';
|
||||
require_once __DIR__ . '/functions/db.php';
|
||||
require_once __DIR__ . '/functions/payments.php';
|
||||
require_once __DIR__ . '/functions/sla.php';
|
||||
|
||||
280
functions/sla.php
Normal file
280
functions/sla.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Ticket SLA helpers
|
||||
*
|
||||
* SLAs are optional at every level. A ticket only gets SLA targets when an
|
||||
* assignment resolves for its client + priority (client-level assignments win,
|
||||
* client 0 rows are the global default, and an assignment pointing at SLA 0 is
|
||||
* an explicit "no SLA" override). Tickets with ticket_sla_id = 0 are simply
|
||||
* ignored by the SLA cron, the list highlighting and the reports.
|
||||
*
|
||||
* Deadlines are computed ONCE at write time (creation / priority change /
|
||||
* client change / manual SLA change) and stored on the ticket as
|
||||
* ticket_response_due_at / ticket_resolution_due_at, so the ticket list and
|
||||
* cron/ticket_sla.php only ever compare datetimes - no business-hours math at
|
||||
* render time.
|
||||
*/
|
||||
|
||||
// Business hours + SLA settings, fetched once per request
|
||||
function getSlaSettings()
|
||||
{
|
||||
global $mysqli;
|
||||
|
||||
static $sla_settings = null;
|
||||
|
||||
if (!is_null($sla_settings)) {
|
||||
return $sla_settings;
|
||||
}
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT config_business_days, config_business_hours_start, config_business_hours_end, config_sla_warning_percent, config_sla_notification_email, config_ticket_from_name, config_ticket_from_email, config_mail_from_email, config_mail_from_name FROM settings WHERE company_id = 1");
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
|
||||
// ISO weekday numbers (1 = Monday .. 7 = Sunday)
|
||||
$business_days = [];
|
||||
foreach (explode(',', strval($row['config_business_days'])) as $day) {
|
||||
$day = intval($day);
|
||||
if ($day >= 1 && $day <= 7) {
|
||||
$business_days[] = $day;
|
||||
}
|
||||
}
|
||||
|
||||
$sla_settings = [
|
||||
'business_days' => $business_days,
|
||||
'business_hours_start' => $row['config_business_hours_start'],
|
||||
'business_hours_end' => $row['config_business_hours_end'],
|
||||
'warning_percent' => intval($row['config_sla_warning_percent']),
|
||||
'notification_email' => $row['config_sla_notification_email'],
|
||||
'ticket_from_name' => $row['config_ticket_from_name'] ?: $row['config_mail_from_name'],
|
||||
'ticket_from_email' => $row['config_ticket_from_email'] ?: $row['config_mail_from_email'],
|
||||
];
|
||||
|
||||
return $sla_settings;
|
||||
}
|
||||
|
||||
// Add $minutes of business time to a datetime, honouring the configured
|
||||
// business days and hours. Returns a Y-m-d H:i:s string in the app timezone
|
||||
// (includes/inc_set_timezone.php has already set it). With no usable business
|
||||
// calendar configured the clock is treated as 24x7.
|
||||
function addBusinessMinutes($start_datetime, $minutes)
|
||||
{
|
||||
$minutes = intval($minutes);
|
||||
$cursor = new DateTime($start_datetime);
|
||||
|
||||
if ($minutes <= 0) {
|
||||
return $cursor->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$sla_settings = getSlaSettings();
|
||||
$business_days = $sla_settings['business_days'];
|
||||
$day_start = $sla_settings['business_hours_start'];
|
||||
$day_end = $sla_settings['business_hours_end'];
|
||||
|
||||
if (empty($business_days) || empty($day_start) || empty($day_end) || $day_start >= $day_end) {
|
||||
$cursor->modify("+$minutes minutes");
|
||||
return $cursor->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$remaining_seconds = $minutes * 60;
|
||||
|
||||
// Walk forward a day at a time consuming available business time. Interval
|
||||
// math is done on timestamps (real elapsed time), window edges by wall
|
||||
// clock - so a DST-shortened business day yields less SLA time, which is
|
||||
// the honest reading. Guard: two years of calendar.
|
||||
for ($i = 0; $i < 731; $i++) {
|
||||
|
||||
if (in_array(intval($cursor->format('N')), $business_days)) {
|
||||
|
||||
$window_start = new DateTime($cursor->format('Y-m-d') . " $day_start");
|
||||
$window_end = new DateTime($cursor->format('Y-m-d') . " $day_end");
|
||||
|
||||
if ($cursor < $window_start) {
|
||||
$cursor = $window_start;
|
||||
}
|
||||
|
||||
if ($cursor < $window_end) {
|
||||
$available_seconds = $window_end->getTimestamp() - $cursor->getTimestamp();
|
||||
|
||||
if ($remaining_seconds <= $available_seconds) {
|
||||
$cursor->setTimestamp($cursor->getTimestamp() + $remaining_seconds);
|
||||
return $cursor->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$remaining_seconds -= $available_seconds;
|
||||
}
|
||||
}
|
||||
|
||||
// Start of the next calendar day
|
||||
$cursor = new DateTime($cursor->format('Y-m-d') . ' 00:00:00');
|
||||
$cursor->modify('+1 day');
|
||||
}
|
||||
|
||||
// Unreachable with a sane configuration - fail open rather than loop
|
||||
$cursor->setTimestamp($cursor->getTimestamp() + $remaining_seconds);
|
||||
return $cursor->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Resolve which SLA (if any) applies to a client + priority combination.
|
||||
// Returns an sla_id, or 0 when no SLA applies.
|
||||
function getTicketSlaId($client_id, $priority)
|
||||
{
|
||||
global $mysqli;
|
||||
|
||||
$client_id = intval($client_id);
|
||||
$priority = escapeSql($priority);
|
||||
|
||||
$sla_id = null;
|
||||
|
||||
// Client-level assignment wins; a row pointing at SLA 0 is an explicit
|
||||
// "no SLA for this client/priority" override of the global default
|
||||
if ($client_id > 0) {
|
||||
$sql = mysqli_query($mysqli, "SELECT sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = $client_id AND sla_assignment_priority = '$priority' LIMIT 1");
|
||||
if (mysqli_num_rows($sql)) {
|
||||
$sla_id = intval(mysqli_fetch_assoc($sql)['sla_assignment_sla_id']);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the global default (client 0)
|
||||
if (is_null($sla_id)) {
|
||||
$sql = mysqli_query($mysqli, "SELECT sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = 0 AND sla_assignment_priority = '$priority' LIMIT 1");
|
||||
if (mysqli_num_rows($sql)) {
|
||||
$sla_id = intval(mysqli_fetch_assoc($sql)['sla_assignment_sla_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($sla_id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ignore assignments pointing at archived SLAs
|
||||
$sql = mysqli_query($mysqli, "SELECT sla_id FROM slas WHERE sla_id = $sla_id AND sla_archived_at IS NULL LIMIT 1");
|
||||
if (!mysqli_num_rows($sql)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $sla_id;
|
||||
}
|
||||
|
||||
// Stamp (or re-stamp) a ticket's SLA and computed due dates. Call after ticket
|
||||
// creation and after anything that changes which SLA applies (priority edit,
|
||||
// client change, manual SLA change). Pass $forced_sla_id to pin a specific SLA
|
||||
// (0 = explicitly none) instead of resolving from the assignments.
|
||||
function applyTicketSla($ticket_id, $forced_sla_id = null)
|
||||
{
|
||||
global $mysqli;
|
||||
|
||||
$ticket_id = intval($ticket_id);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT ticket_client_id, ticket_priority, ticket_created_at, ticket_first_response_at, ticket_resolved_at FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
|
||||
if (!$sql || !mysqli_num_rows($sql)) {
|
||||
return;
|
||||
}
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
|
||||
if (is_null($forced_sla_id)) {
|
||||
$sla_id = getTicketSlaId($row['ticket_client_id'], $row['ticket_priority']);
|
||||
} else {
|
||||
$sla_id = intval($forced_sla_id);
|
||||
}
|
||||
|
||||
// No SLA applies - clear any previous targets
|
||||
if ($sla_id == 0) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_sla_id = 0, ticket_response_due_at = NULL, ticket_resolution_due_at = NULL, ticket_response_sla_met = NULL, ticket_resolution_sla_met = NULL, ticket_response_sla_alert_stage = 0, ticket_resolution_sla_alert_stage = 0 WHERE ticket_id = $ticket_id");
|
||||
return;
|
||||
}
|
||||
|
||||
$sla_sql = mysqli_query($mysqli, "SELECT sla_response_minutes, sla_resolution_minutes FROM slas WHERE sla_id = $sla_id LIMIT 1");
|
||||
if (!$sla_sql || !mysqli_num_rows($sla_sql)) {
|
||||
return;
|
||||
}
|
||||
$sla = mysqli_fetch_assoc($sla_sql);
|
||||
|
||||
$created_at = $row['ticket_created_at'];
|
||||
|
||||
$response_due_at = addBusinessMinutes($created_at, $sla['sla_response_minutes']);
|
||||
|
||||
$resolution_due_at = null;
|
||||
$resolution_due_at_set = "NULL";
|
||||
if (intval($sla['sla_resolution_minutes']) > 0) {
|
||||
$resolution_due_at = addBusinessMinutes($created_at, $sla['sla_resolution_minutes']);
|
||||
$resolution_due_at_set = "'$resolution_due_at'";
|
||||
}
|
||||
|
||||
// Re-judge met flags for milestones already reached; alert stages reset so
|
||||
// the SLA cron re-evaluates pending milestones against the new targets
|
||||
$response_met_set = "NULL";
|
||||
if (!empty($row['ticket_first_response_at'])) {
|
||||
$response_met_set = strtotime($row['ticket_first_response_at']) <= strtotime($response_due_at) ? 1 : 0;
|
||||
}
|
||||
|
||||
$resolution_met_set = "NULL";
|
||||
if (!empty($row['ticket_resolved_at']) && !is_null($resolution_due_at)) {
|
||||
$resolution_met_set = strtotime($row['ticket_resolved_at']) <= strtotime($resolution_due_at) ? 1 : 0;
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_sla_id = $sla_id, ticket_response_due_at = '$response_due_at', ticket_resolution_due_at = $resolution_due_at_set, ticket_response_sla_met = $response_met_set, ticket_resolution_sla_met = $resolution_met_set, ticket_response_sla_alert_stage = 0, ticket_resolution_sla_alert_stage = 0 WHERE ticket_id = $ticket_id");
|
||||
}
|
||||
|
||||
// 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
|
||||
// the timestamp.
|
||||
function setTicketFirstResponse($ticket_id)
|
||||
{
|
||||
global $mysqli;
|
||||
|
||||
$ticket_id = intval($ticket_id);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT ticket_first_response_at, ticket_response_due_at FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
|
||||
if (!$sql || !mysqli_num_rows($sql)) {
|
||||
return;
|
||||
}
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
|
||||
if (!empty($row['ticket_first_response_at'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response_met_set = "NULL";
|
||||
if (!empty($row['ticket_response_due_at'])) {
|
||||
$response_met_set = time() <= strtotime($row['ticket_response_due_at']) ? 1 : 0;
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW(), ticket_response_sla_met = $response_met_set WHERE ticket_id = $ticket_id");
|
||||
}
|
||||
|
||||
// Judge the resolution SLA when a ticket is resolved (or closed without being
|
||||
// resolved, which also stops the clock). No-op for tickets without a
|
||||
// resolution target.
|
||||
function setTicketResolutionSlaMet($ticket_id)
|
||||
{
|
||||
global $mysqli;
|
||||
|
||||
$ticket_id = intval($ticket_id);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT ticket_resolution_due_at, ticket_resolved_at FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
|
||||
if (!$sql || !mysqli_num_rows($sql)) {
|
||||
return;
|
||||
}
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
|
||||
if (empty($row['ticket_resolution_due_at'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ended_at = !empty($row['ticket_resolved_at']) ? strtotime($row['ticket_resolved_at']) : time();
|
||||
$resolution_met = $ended_at <= strtotime($row['ticket_resolution_due_at']) ? 1 : 0;
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_met = $resolution_met WHERE ticket_id = $ticket_id");
|
||||
}
|
||||
|
||||
// A reopened ticket goes back on the resolution clock (original due date - no
|
||||
// pause/extend logic yet, that arrives with SLA pausing)
|
||||
function resetTicketResolutionSla($ticket_id)
|
||||
{
|
||||
global $mysqli;
|
||||
|
||||
$ticket_id = intval($ticket_id);
|
||||
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_met = NULL, ticket_resolution_sla_alert_stage = 0 WHERE ticket_id = $ticket_id");
|
||||
}
|
||||
Reference in New Issue
Block a user