Files
itflow/functions/ticket_status.php

397 lines
18 KiB
PHP

<?php
/*
* Ticket statuses - the five ITFlow has (New, Open, On Hold, Resolved and
* Closed, rows 1-5, which the code relies on) and your own, like Waiting on
* Vendor or Scheduled. Each has a name, a colour, what it is for, whether
* the SLA's resolution clock runs in it, what a client's reply does to a
* ticket in it, and a place in the order the pickers and the board show
* them in. A status can be switched off - it leaves the pickers and the
* board - and one of your own deleted; either way the tickets in it can be
* moved first. Admin > Ticket Statuses (admin/ticket_statuses.php) and its
* handlers (admin/post/ticket_status.php) are built on these.
*
* The built-in five keep their names, stay switched on, and have fixed SLA
* behaviour (getTicketStatusSlaLock() in functions/app.php) and a fixed
* answer to a client's reply - back to Open. Their colour, description and
* place in the order can change.
*/
/*
* The five built-in statuses - id => name, icon, what it means (the default
* description), and why it is set as it is.
*/
function ticketStatusBuiltIns() {
return [
TICKET_STATUS_NEW => ['name' => 'New', 'icon' => 'fa-inbox', 'about' => 'Just come in - nobody has picked it up yet.', 'set_by' => 'Set when a ticket comes in. Assigning it or replying opens it.'],
TICKET_STATUS_OPEN => ['name' => 'Open', 'icon' => 'fa-folder-open', 'about' => 'Being worked on.', 'set_by' => 'Where a ticket goes when it is picked up, and when a client replies.'],
TICKET_STATUS_ON_HOLD => ['name' => 'On Hold', 'icon' => 'fa-pause-circle', 'about' => 'Waiting on someone else - the client, a vendor or a part.', 'set_by' => 'Set by hand. The SLA\'s resolution clock is paused here.'],
TICKET_STATUS_RESOLVED => ['name' => 'Resolved', 'icon' => 'fa-check', 'about' => 'Fixed, and waiting to be closed - a reply from the client reopens it.', 'set_by' => 'Set by Resolve, which tells the client. Closes by itself after the days set in Admin > Settings > Tickets.'],
TICKET_STATUS_CLOSED => ['name' => 'Closed', 'icon' => 'fa-check-circle', 'about' => 'Finished - it can\'t be replied to.', 'set_by' => 'Set by Close, or when a resolved ticket closes by itself.'],
];
}
/** Whether a status is one of the five ITFlow has. */
function ticketStatusIsBuiltIn($ticket_status_id) {
return isset(ticketStatusBuiltIns()[intval($ticket_status_id)]);
}
/** What a client's reply does to a ticket in a status - 1 moves it back to Open, 0 leaves it where it is. */
const TICKET_STATUS_CLIENT_REPLY = [
1 => ['label' => 'Move it back to Open', 'short' => 'Back to Open', 'icon' => 'fa-undo-alt'],
0 => ['label' => 'Leave it in this status', 'short' => 'Stays', 'icon' => 'fa-equals'],
];
/** Whether the 3.4.0 update has run - ticket_status_description and ticket_status_client_reply. */
function ticketStatusColumnsReady() {
static $ready = null;
if ($ready === null) {
$ready = defined('CURRENT_DATABASE_VERSION') ? version_compare(CURRENT_DATABASE_VERSION, '3.4.0', '>=') : true;
}
return $ready;
}
/** The columns a status row is read with - the 3.4.0 ones only once they are there. */
function ticketStatusColumnsSql() {
return 'ticket_status_id, ticket_status_name, ticket_status_color, ticket_status_active, ticket_status_pauses_sla, ticket_status_order'
. (ticketStatusColumnsReady() ? ', ticket_status_description, ticket_status_client_reply' : ", '' AS ticket_status_description, 1 AS ticket_status_client_reply");
}
/** One status, or null. */
function ticketStatusRow($ticket_status_id) {
global $mysqli;
$ticket_status_id = intval($ticket_status_id);
if ($ticket_status_id <= 0) {
return null;
}
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT " . ticketStatusColumnsSql() . " FROM ticket_statuses WHERE ticket_status_id = $ticket_status_id LIMIT 1")) ?: null;
}
/*
* Every status, in the pickers' order - id => row. Read once a page; pass
* $fresh after changing one.
*/
function ticketStatusesAll($fresh = false) {
global $mysqli;
static $statuses = null;
if ($statuses === null || $fresh) {
$statuses = [];
$sql = mysqli_query($mysqli, "SELECT " . ticketStatusColumnsSql() . " FROM ticket_statuses ORDER BY ticket_status_order ASC, ticket_status_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$statuses[intval($row['ticket_status_id'])] = $row;
}
}
return $statuses;
}
/** What a status is for - its description, or a built-in's meaning when it has none. */
function ticketStatusAbout(array $row) {
$description = trim((string) ($row['ticket_status_description'] ?? ''));
if ($description !== '') {
return $description;
}
return ticketStatusBuiltIns()[intval($row['ticket_status_id'] ?? 0)]['about'] ?? '';
}
/** A status's colour as #rrggbb - grey when it hasn't a usable one. */
function ticketStatusColor($color) {
$color = categoryColor($color);
return $color !== '' ? $color : '#6c757d';
}
/*
* A status as its badge - in its colour, with text that reads on it, and
* what it is for on hover. $style 'list' is the padded pill of the lists,
* 'detail' the plain one.
*/
function ticketStatusBadgeHtml($name, $color, $style = 'detail', $about = '') {
$color = ticketStatusColor($color);
return '<span class="badge rounded-pill itflow-ticket-status' . ($style === 'list' ? ' p-2' : '') . '" style="--itflow-status-bg: ' . $color . '; --itflow-status-text: ' . tagTextColor($color) . '"'
. ($about !== '' ? ' title="' . escapeHtml($about) . '"' : '') . '>' . escapeHtml($name) . '</span>';
}
/** A colour for a new status - the first of the palette no status has yet. */
function ticketStatusNextColor() {
$used = array_map(function ($row) {
return ticketStatusColor($row['ticket_status_color']);
}, ticketStatusesAll());
foreach (CATEGORY_COLORS as $color) {
if (!in_array($color, $used, true)) {
return $color;
}
}
return CATEGORY_COLORS[count($used) % count(CATEGORY_COLORS)];
}
/* The statuses' names - id => name - for a picker or a filter, the active ones and any of $keep. */
function ticketStatusOptions(array $keep = []) {
$options = [];
foreach (ticketStatusesAll() as $id => $row) {
if (intval($row['ticket_status_active']) === 1 || in_array($id, array_map('intval', $keep), true)) {
$options[$id] = $row;
}
}
return $options;
}
/*
* How many tickets are in each status - id => ['shown' (not archived - what
* the tickets list shows), 'open' (nor resolved or closed), 'all'].
*/
function ticketStatusTicketCounts() {
global $mysqli;
$counts = [];
$sql = mysqli_query($mysqli, "SELECT ticket_status, COUNT(*) AS n_all, SUM(ticket_archived_at IS NULL) AS n_shown,
SUM(ticket_resolved_at IS NULL AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL) AS n_open
FROM tickets GROUP BY ticket_status");
while ($row = mysqli_fetch_assoc($sql)) {
$counts[intval($row['ticket_status'])] = ['shown' => intval($row['n_shown']), 'open' => intval($row['n_open']), 'all' => intval($row['n_all'])];
}
return $counts;
}
/** How many canned responses set this status. */
function ticketStatusCannedCount($ticket_status_id) {
global $mysqli;
$result = mysqli_query($mysqli, "SELECT COUNT(*) FROM canned_responses WHERE canned_response_ticket_status = " . intval($ticket_status_id));
return $result ? intval(mysqli_fetch_row($result)[0]) : 0;
}
/*
* Where a status's tickets can be moved when it is switched off or deleted:
* the active statuses an agent can put an open ticket in (not New, Resolved
* or Closed), other than itself - id => row.
*/
function ticketStatusMoveTargets($ticket_status_id) {
$targets = [];
foreach (ticketStatusesAll() as $id => $row) {
if ($id !== intval($ticket_status_id) && intval($row['ticket_status_active']) === 1 && !in_array($id, [TICKET_STATUS_NEW, TICKET_STATUS_RESOLVED, TICKET_STATUS_CLOSED], true)) {
$targets[$id] = $row;
}
}
return $targets;
}
/*
* A status's fields from a form - name, color, description, pauses_sla,
* client_reply and active. A built-in keeps its name, stays on, and has its
* fixed SLA and client-reply answers whatever was sent.
*/
function ticketStatusFromInput(array $in, $row = null) {
$id = $row ? intval($row['ticket_status_id']) : 0;
$builtin = $id && ticketStatusIsBuiltIn($id);
$name = trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($in['name'] ?? ''))));
$color = categoryColor($in['color'] ?? '');
$f = [
'name' => $builtin ? (string) $row['ticket_status_name'] : mb_substr($name, 0, 200),
'color' => $color !== '' ? $color : ($row ? ticketStatusColor($row['ticket_status_color']) : CATEGORY_COLORS[0]),
'description' => mb_substr(trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($in['description'] ?? '')))), 0, 255),
'pauses_sla' => intval($in['pauses_sla'] ?? 0) === 1 ? 1 : 0,
'client_reply' => intval($in['client_reply'] ?? 1) === 0 ? 0 : 1,
'active' => array_key_exists('active', $in) ? (intval($in['active']) === 1 ? 1 : 0) : ($row ? intval($row['ticket_status_active']) : 1),
];
if ($builtin) {
$f['pauses_sla'] = intval(getTicketStatusSlaLock($id));
$f['client_reply'] = 1;
$f['active'] = 1;
}
return $f;
}
/* What is wrong with a status's fields - '' when nothing. $id is the status being changed, 0 for a new one. */
function ticketStatusProblem(array $f, $id = 0) {
global $mysqli;
if ($f['name'] === '') {
return 'A status needs a name.';
}
$name = escapeSql($f['name']);
$taken = mysqli_fetch_row(mysqli_query($mysqli, "SELECT ticket_status_name FROM ticket_statuses WHERE ticket_status_name = '$name' AND ticket_status_id <> " . intval($id) . " LIMIT 1"));
if ($taken) {
return 'There is already a status called "' . $taken[0] . '".';
}
return '';
}
/** The SET clause for a status's fields. */
function ticketStatusSetSql(array $f) {
return "ticket_status_name = '" . escapeSql($f['name']) . "', ticket_status_color = '" . escapeSql($f['color']) . "',
ticket_status_pauses_sla = {$f['pauses_sla']}, ticket_status_active = {$f['active']}"
. (ticketStatusColumnsReady() ? ", ticket_status_description = " . ($f['description'] !== '' ? "'" . escapeSql($f['description']) . "'" : 'NULL') . ", ticket_status_client_reply = {$f['client_reply']}" : '');
}
/** A new status, at the end of the order - its id. */
function ticketStatusCreate(array $f) {
global $mysqli;
$order = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(MAX(ticket_status_order), 0) FROM ticket_statuses"))[0]) + 1;
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET " . ticketStatusSetSql($f) . ", ticket_status_order = $order");
$id = intval(mysqli_insert_id($mysqli));
ticketStatusesAll(true);
return $id;
}
/*
* Save a status's fields - what changed, as phrases for the audit log. A
* change to whether its clock is paused is applied to the tickets in it
* straight away.
*/
function ticketStatusUpdate(array $row, array $f) {
global $mysqli;
$id = intval($row['ticket_status_id']);
mysqli_query($mysqli, "UPDATE ticket_statuses SET " . ticketStatusSetSql($f) . " WHERE ticket_status_id = $id");
$changes = [];
$was = [
'name' => (string) $row['ticket_status_name'], 'color' => ticketStatusColor($row['ticket_status_color']),
'description' => trim((string) ($row['ticket_status_description'] ?? '')),
];
foreach (['name' => 'name', 'color' => 'colour', 'description' => 'description'] as $key => $label) {
if ($was[$key] !== $f[$key]) {
$changes[] = "$label from " . ($was[$key] !== '' ? $was[$key] : 'none') . ' to ' . ($f[$key] !== '' ? $f[$key] : 'none');
}
}
if (intval($row['ticket_status_pauses_sla']) !== $f['pauses_sla']) {
$changes[] = $f['pauses_sla'] ? 'the resolution clock now pauses' : 'the resolution clock now keeps running';
ticketStatusSyncClocks($id);
}
if (intval($row['ticket_status_client_reply'] ?? 1) !== $f['client_reply']) {
$changes[] = 'a client\'s reply now ' . ($f['client_reply'] ? 'moves the ticket back to Open' : 'leaves the ticket in it');
}
if (intval($row['ticket_status_active']) !== $f['active']) {
$changes[] = $f['active'] ? 'switched on' : 'switched off';
}
ticketStatusesAll(true);
return $changes;
}
/* Bring the SLA clocks of the open tickets in a status in line with it - after its pause setting changes. */
function ticketStatusSyncClocks($ticket_status_id) {
global $mysqli;
$sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_status = " . intval($ticket_status_id) . " AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL");
while ($ticket = mysqli_fetch_row($sql)) {
syncTicketSlaClock(intval($ticket[0]));
}
}
/*
* Move every ticket in one status to another - as when the status is
* switched off or deleted. Each says so in its history, and its SLA clock
* follows the new status. How many moved.
*/
function ticketStatusMoveTickets($from_id, $to_id, $why) {
global $mysqli, $session_name;
$from_id = intval($from_id);
$to_id = intval($to_id);
$statuses = ticketStatusesAll();
if (!isset($statuses[$from_id], $statuses[$to_id]) || $from_id === $to_id) {
return 0;
}
$from_name = escapeSql($statuses[$from_id]['ticket_status_name']);
$to_name = escapeSql($statuses[$to_id]['ticket_status_name']);
$ids = [];
$sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_status = $from_id");
while ($ticket = mysqli_fetch_row($sql)) {
$ids[] = intval($ticket[0]);
}
foreach ($ids as $ticket_id) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $to_id WHERE ticket_id = $ticket_id AND ticket_status = $from_id");
syncTicketSlaClock($ticket_id);
// $session_name is SQL-safe already
logTicketHistory($ticket_id, ($session_name ?? 'ITFlow') . " moved it from $from_name to $to_name - " . escapeSql($why));
}
return count($ids);
}
/*
* Switch a status off or on. Switching off can move its tickets to another
* status first ($move_to, 0 to leave them). Built-in statuses stay on.
* [ok, how many tickets moved].
*/
function ticketStatusSetActive(array $row, $active, $move_to = 0) {
global $mysqli;
$id = intval($row['ticket_status_id']);
if (!$active && ticketStatusIsBuiltIn($id)) {
return [false, 0];
}
$moved = 0;
if (!$active && intval($move_to) && isset(ticketStatusMoveTargets($id)[intval($move_to)])) {
$moved = ticketStatusMoveTickets($id, $move_to, 'the status was switched off');
}
mysqli_query($mysqli, "UPDATE ticket_statuses SET ticket_status_active = " . ($active ? 1 : 0) . " WHERE ticket_status_id = $id");
ticketStatusesAll(true);
return [true, $moved];
}
/*
* Delete one of your own statuses. Its tickets have to go somewhere -
* $move_to, one of ticketStatusMoveTargets() - and canned responses that set
* it leave the status as it is instead. [ok, tickets moved, responses changed].
*/
function ticketStatusDelete(array $row, $move_to) {
global $mysqli;
$id = intval($row['ticket_status_id']);
if (ticketStatusIsBuiltIn($id)) {
return [false, 0, 0];
}
$counts = ticketStatusTicketCounts()[$id] ?? ['all' => 0];
$move_to = intval($move_to);
if ($counts['all'] && !isset(ticketStatusMoveTargets($id)[$move_to])) {
return [false, 0, 0];
}
$moved = $counts['all'] ? ticketStatusMoveTickets($id, $move_to, 'the status was deleted') : 0;
$canned = ticketStatusCannedCount($id);
mysqli_query($mysqli, "UPDATE canned_responses SET canned_response_ticket_status = NULL WHERE canned_response_ticket_status = $id");
mysqli_query($mysqli, "DELETE FROM ticket_statuses WHERE ticket_status_id = $id");
ticketStatusesAll(true);
return [true, $moved, $canned];
}
/*
* Put the statuses in this order - the ids as listed, then any others in
* the order they had. Numbered from 1. How many moved.
*/
function ticketStatusReorder(array $ids) {
global $mysqli;
$rows = [];
foreach (ticketStatusesAll(true) as $id => $row) {
$rows[$id] = intval($row['ticket_status_order']);
}
$ordered = [];
foreach ($ids as $id) {
$id = intval($id);
if (isset($rows[$id]) && !in_array($id, $ordered, true)) {
$ordered[] = $id;
}
}
foreach (array_keys($rows) as $id) {
if (!in_array($id, $ordered, true)) {
$ordered[] = $id;
}
}
$moved = 0;
foreach ($ordered as $position => $id) {
if ($rows[$id] !== $position + 1) {
mysqli_query($mysqli, "UPDATE ticket_statuses SET ticket_status_order = " . ($position + 1) . " WHERE ticket_status_id = $id");
$moved++;
}
}
ticketStatusesAll(true);
return $moved;
}
/*
* The status a ticket goes to when its client replies - back to Open, unless
* it is in one of your own statuses set to leave it there (Scheduled, say,
* where a "thanks, see you Tuesday" shouldn't send it back to the queue).
*/
function ticketStatusAfterClientReply($current_status_id) {
$current_status_id = intval($current_status_id);
if (ticketStatusIsBuiltIn($current_status_id)) {
return TICKET_STATUS_OPEN;
}
$row = ticketStatusesAll()[$current_status_id] ?? null;
if ($row && intval($row['ticket_status_active']) === 1 && intval($row['ticket_status_client_reply']) === 0) {
return $current_status_id;
}
return TICKET_STATUS_OPEN;
}