Feature: Add Ticket Canned Responses

This commit is contained in:
johnnyq
2026-08-15 17:29:17 -04:00
parent 4c4c172f59
commit 6ef84c4547
9 changed files with 504 additions and 2 deletions

135
admin/canned_responses.php Normal file
View File

@@ -0,0 +1,135 @@
<?php
// Default Column Sortby Filter
$sort = "canned_response_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
/*
* A canned response either belongs to one ticket category or, with category id 0, offers
* itself on every ticket. The LEFT JOIN is what puts the category name on the row; a
* response whose category was deleted since falls back to the general case below.
*/
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS canned_response_id, canned_response_name, canned_response_body,
canned_response_category_id, category_name
FROM canned_responses
LEFT JOIN categories ON canned_response_category_id = category_id AND category_type = 'Ticket'
WHERE (canned_response_name LIKE '%$q%' OR canned_response_body LIKE '%$q%')
AND canned_response_archived_at IS NULL
ORDER BY $sort $order
LIMIT $record_from, $record_to"
);
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
?>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-comment-dots me-2"></i>Canned Responses</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/canned_response/canned_response_add.php" data-modal-size="lg"><i class="fas fa-plus me-2"></i>New Canned Response</button>
</div>
</div>
<div class="card-body">
<form autocomplete="off">
<div class="row">
<div class="col-md-4">
<div class="input-group mb-3 mb-md-0">
<input type="search" class="form-control" name="q" value="<?php if(isset($q)){ echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search Canned Responses">
<button class="btn btn-dark"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-md-8">
</div>
</div>
</form>
<hr>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark <?php if($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=canned_response_name&order=<?= $disp ?>">
Response <?php if ($sort == 'canned_response_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=category_name&order=<?= $disp ?>">
Ticket Category <?php if ($sort == 'category_name') { echo $order_icon; } ?>
</a>
</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php
while($row = mysqli_fetch_assoc($sql)){
$canned_response_id = intval($row['canned_response_id']);
$canned_response_name = escapeHtml($row['canned_response_name']);
$canned_response_category_id = intval($row['canned_response_category_id']);
$canned_response_category_name = escapeHtml($row['category_name']);
// The body is HTML from TinyMCE. This is a preview, not a render - tags
// are stripped rather than escaped so the column reads as the sentence
// the agent will actually be inserting
$canned_response_preview = escapeHtml(mb_strimwidth(trim(html_entity_decode(strip_tags($row['canned_response_body']), ENT_QUOTES, 'UTF-8')), 0, 120, '...'));
?>
<tr>
<td>
<div class="d-flex">
<i class="fa fa-fw fa-2x fa-comment-dots me-3"></i>
<div class="flex-grow-1">
<div>
<a class="ajax-modal" href="#" data-modal-url="modals/canned_response/canned_response_edit.php?id=<?= $canned_response_id ?>" data-modal-size="lg">
<?= $canned_response_name ?>
</a>
</div>
<div><small class="text-secondary"><?= $canned_response_preview ?></small></div>
</div>
</div>
</td>
<td>
<?php if ($canned_response_category_id && $canned_response_category_name) { ?>
<span class="badge bg-secondary"><?= $canned_response_category_name ?></span>
<?php } else { ?>
<span class="badge bg-dark">All ticket categories</span>
<?php } ?>
</td>
<td>
<div class="dropdown dropstart text-center">
<button class="btn btn-secondary btn-sm" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/canned_response/canned_response_edit.php?id=<?= $canned_response_id ?>" data-modal-size="lg">
<i class="fas fa-fw fa-edit me-2"></i>Edit
</a>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_canned_response=<?= $canned_response_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php require_once "../includes/filter_footer.php";
?>
</div>
</div>
<?php
require_once "../includes/footer.php";

View File

@@ -0,0 +1,27 @@
<?php
/*
* ITFlow - Database update to version 2.6.9 (from 2.6.8)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Canned responses for ticket replies, managed from Admin > Canned Responses and
// picked from a dropdown on the ticket reply form.
//
// canned_response_category_id is a ticket category (categories.category_id where
// category_type = 'Ticket'), or 0 for one that offers itself on every ticket
// whatever its category.
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `canned_responses` (
`canned_response_id` int(11) NOT NULL AUTO_INCREMENT,
`canned_response_name` varchar(200) NOT NULL,
`canned_response_body` longtext DEFAULT NULL,
`canned_response_category_id` int(11) NOT NULL DEFAULT 0,
`canned_response_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`canned_response_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`canned_response_archived_at` datetime DEFAULT NULL,
PRIMARY KEY (`canned_response_id`),
KEY `canned_response_category_id` (`canned_response_category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");

View File

@@ -124,6 +124,12 @@
<p>Ticket Templates</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/canned_responses.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'canned_responses.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-comment-dots"></i>
<p>Canned Responses</p>
</a>
</li>
<?php } ?>
<?php if ($config_module_enable_itdoc) { ?>
<!-- 2025-11-16 JQ - Hide Contracts not yet ready

View File

@@ -0,0 +1,58 @@
<?php
require_once '../../includes/modal_header.php';
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-comment-dots me-2"></i>New Canned Response</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-comment-dots"></i></span>
<input type="text" class="form-control" name="name" placeholder="What an agent picks it by" maxlength="200" required autofocus>
</div>
</div>
<div class="mb-3">
<label>Ticket Category</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<select class="form-select select2" name="category">
<option value="0">- All ticket categories -</option>
<?php
$sql_categories = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories WHERE category_type = 'Ticket' AND category_archived_at IS NULL ORDER BY category_name ASC");
while ($row = mysqli_fetch_assoc($sql_categories)) {
$category_id_select = intval($row['category_id']);
$category_name_select = escapeHtml($row['category_name']); ?>
<option value="<?= $category_id_select ?>"><?= $category_name_select ?></option>
<?php } ?>
</select>
</div>
<small class="text-secondary">Leave this on all categories for a response that should be offered on every ticket.</small>
</div>
<div class="mb-3">
<label>Response</label>
<textarea class="form-control tinymceTicket" name="body"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="add_canned_response" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Create Canned Response</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -0,0 +1,72 @@
<?php
require_once '../../includes/modal_header.php';
$canned_response_id = intval($_GET['id']);
$sql = mysqli_query($mysqli, "SELECT canned_response_name, canned_response_body, canned_response_category_id
FROM canned_responses WHERE canned_response_id = $canned_response_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
$canned_response_name = escapeHtml($row['canned_response_name']);
$canned_response_category_id = intval($row['canned_response_category_id']);
// Into a textarea, so escaped rather than purified - it is markup being edited, not rendered
$canned_response_body = escapeHtml($row['canned_response_body']);
// Generate the HTML form content using output buffering.
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-comment-dots me-2"></i>Editing Canned Response: <strong><?= $canned_response_name ?></strong></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="canned_response_id" value="<?= $canned_response_id ?>">
<div class="modal-body">
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-comment-dots"></i></span>
<input type="text" class="form-control" name="name" maxlength="200" value="<?= $canned_response_name ?>" required>
</div>
</div>
<div class="mb-3">
<label>Ticket Category</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<select class="form-select select2" name="category">
<option value="0" <?php if ($canned_response_category_id == 0) { echo "selected"; } ?>>- All ticket categories -</option>
<?php
$sql_categories = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories WHERE category_type = 'Ticket' AND category_archived_at IS NULL ORDER BY category_name ASC");
while ($category_row = mysqli_fetch_assoc($sql_categories)) {
$category_id_select = intval($category_row['category_id']);
$category_name_select = escapeHtml($category_row['category_name']); ?>
<option value="<?= $category_id_select ?>" <?php if ($canned_response_category_id == $category_id_select) { echo "selected"; } ?>><?= $category_name_select ?></option>
<?php } ?>
</select>
</div>
<small class="text-secondary">Leave this on all categories for a response that should be offered on every ticket.</small>
</div>
<div class="mb-3">
<label>Response</label>
<textarea class="form-control tinymceTicket" name="body"><?= $canned_response_body ?></textarea>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="edit_canned_response" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Save</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -0,0 +1,67 @@
<?php
// Canned Responses
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['add_canned_response'])) {
validateCSRFToken();
$name = escapeSql($_POST['name']);
// The body is TinyMCE HTML, so it goes in the way ticket replies and ticket template
// details do - escaped for the query only, and purified where it is rendered
$body = mysqli_real_escape_string($mysqli, $_POST['body']);
// 0 is the general case: offered on every ticket whatever its category
$category_id = intval($_POST['category']);
mysqli_query($mysqli, "INSERT INTO canned_responses SET canned_response_name = '$name', canned_response_body = '$body', canned_response_category_id = $category_id");
$canned_response_id = mysqli_insert_id($mysqli);
logAudit("Canned Response", "Create", "$session_name created canned response $name", 0, $canned_response_id);
flashAlert("Canned Response <strong>$name</strong> created");
redirect();
}
if (isset($_POST['edit_canned_response'])) {
validateCSRFToken();
$canned_response_id = intval($_POST['canned_response_id']);
$name = escapeSql($_POST['name']);
$body = mysqli_real_escape_string($mysqli, $_POST['body']);
$category_id = intval($_POST['category']);
mysqli_query($mysqli, "UPDATE canned_responses SET canned_response_name = '$name', canned_response_body = '$body', canned_response_category_id = $category_id WHERE canned_response_id = $canned_response_id");
logAudit("Canned Response", "Edit", "$session_name edited canned response $name", 0, $canned_response_id);
flashAlert("Canned Response <strong>$name</strong> edited");
redirect();
}
if (isset($_GET['delete_canned_response'])) {
validateCSRFToken();
$canned_response_id = intval($_GET['delete_canned_response']);
$name = escapeSql(getFieldById('canned_responses', $canned_response_id, 'canned_response_name'));
mysqli_query($mysqli, "DELETE FROM canned_responses WHERE canned_response_id = $canned_response_id");
logAudit("Canned Response", "Delete", "$session_name deleted canned response $name", 0, $canned_response_id);
flashAlert("Canned Response <strong>$name</strong> deleted", 'error');
redirect();
}

View File

@@ -382,6 +382,37 @@ if (isset($_GET['get_active_clients'])) {
echo json_encode($response);
}
/*
* Returns the body of one canned response, for the picker on the ticket reply form.
* Fetched on demand rather than rendered into every ticket page, because the bodies are
* full HTML replies and an install can have a lot of them.
*/
if (isset($_GET['get_canned_response'])) {
enforceUserPermission('module_support');
$canned_response_id = intval($_GET['get_canned_response']);
$canned_sql = mysqli_query($mysqli, "SELECT canned_response_body FROM canned_responses
WHERE canned_response_id = $canned_response_id AND canned_response_archived_at IS NULL LIMIT 1");
$canned_row = mysqli_fetch_assoc($canned_sql);
// Purified here rather than at save time, the same way ticket replies and ticket
// template details are - what lands in the editor is what would have been rendered
require_once "../libs/htmlpurifier/HTMLPurifier.standalone.php";
$canned_purifier_config = HTMLPurifier_Config::createDefault();
$canned_purifier_config->set('Cache.DefinitionImpl', null);
$canned_purifier_config->set('URI.AllowedSchemes', ['data' => true, 'src' => true, 'http' => true, 'https' => true]);
$canned_purifier = new HTMLPurifier($canned_purifier_config);
$response = [];
$response['body'] = $canned_row ? $canned_purifier->purify($canned_row['canned_response_body']) : '';
echo json_encode($response);
}
/*
* Returns ordered list of active contacts for a specified client
*/

View File

@@ -727,8 +727,57 @@ if (isset($_GET['ticket_id'])) {
<?php } ?>
</div>
<?php
/*
* Canned responses offered on this ticket: the ones tied to its
* category, plus the general ones that are offered everywhere.
* Names only - the body is fetched when one is picked, so a
* shelf of long responses does not ride along with every ticket.
*/
$sql_canned_responses = mysqli_query($mysqli, "SELECT canned_response_id, canned_response_name, canned_response_category_id
FROM canned_responses
WHERE canned_response_archived_at IS NULL
AND (canned_response_category_id = 0 OR canned_response_category_id = $ticket_category)
ORDER BY canned_response_name ASC");
$canned_responses_for_category = [];
$canned_responses_general = [];
while ($canned_row = mysqli_fetch_assoc($sql_canned_responses)) {
if (intval($canned_row['canned_response_category_id']) === 0) {
$canned_responses_general[] = $canned_row;
} else {
$canned_responses_for_category[] = $canned_row;
}
}
if ($canned_responses_for_category || $canned_responses_general) { ?>
<div class="mb-3">
<select class="form-select" id="canned_response_picker">
<option value="">- Insert a canned response -</option>
<?php if ($canned_responses_for_category) { ?>
<optgroup label="<?= $ticket_category_display ?>">
<?php foreach ($canned_responses_for_category as $canned_row) { ?>
<option value="<?= intval($canned_row['canned_response_id']) ?>"><?= escapeHtml($canned_row['canned_response_name']) ?></option>
<?php } ?>
</optgroup>
<?php } ?>
<?php if ($canned_responses_general) { ?>
<optgroup label="All categories">
<?php foreach ($canned_responses_general as $canned_row) { ?>
<option value="<?= intval($canned_row['canned_response_id']) ?>"><?= escapeHtml($canned_row['canned_response_name']) ?></option>
<?php } ?>
</optgroup>
<?php } ?>
</select>
</div>
<?php } ?>
<div class="mb-3">
<textarea class="form-control tinymceTicket" name="ticket_reply" placeholder="Type a response"></textarea>
<textarea class="form-control tinymceTicket" id="ticket_reply" name="ticket_reply" placeholder="Type a response"></textarea>
</div>
<div class="mb-3">
@@ -1424,6 +1473,43 @@ require_once "../includes/footer.php";
});
}
// Canned responses - insert at the cursor rather than replacing, so picking one into a
// half-written reply adds to it. The body is fetched on demand; TinyMCE owns the field
// by now, so setting the textarea value directly would do nothing visible.
const cannedPicker = document.getElementById('canned_response_picker');
if (cannedPicker) {
cannedPicker.addEventListener('change', function () {
const cannedId = cannedPicker.value;
if (!cannedId) return;
cannedPicker.disabled = true;
fetch('ajax.php?get_canned_response=' + encodeURIComponent(cannedId))
.then(response => response.json())
.then(data => {
const editor = window.tinymce ? tinymce.get('ticket_reply') : null;
if (editor) {
editor.insertContent(data.body);
editor.focus();
} else {
// TinyMCE failed to load - fall back to the plain textarea
const textarea = document.getElementById('ticket_reply');
if (textarea) {
textarea.value += data.body;
}
}
})
.catch(() => {
alert('Could not load that canned response.');
})
.finally(() => {
cannedPicker.disabled = false;
cannedPicker.value = '';
});
});
}
// Conversation filter - show everything, only what the client can see, or only internal notes
const replyFilter = document.getElementById('replyFilter');
if (replyFilter) {

22
db.sql
View File

@@ -471,6 +471,26 @@ CREATE TABLE `calendars` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `canned_responses`
--
DROP TABLE IF EXISTS `canned_responses`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `canned_responses` (
`canned_response_id` int(11) NOT NULL AUTO_INCREMENT,
`canned_response_name` varchar(200) NOT NULL,
`canned_response_body` longtext DEFAULT NULL,
`canned_response_category_id` int(11) NOT NULL DEFAULT 0,
`canned_response_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`canned_response_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`canned_response_archived_at` datetime DEFAULT NULL,
PRIMARY KEY (`canned_response_id`),
KEY `canned_response_category_id` (`canned_response_category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `categories`
--
@@ -3151,4 +3171,4 @@ CREATE TABLE `vendors` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-08-15 16:40:00
-- Dump completed on 2026-08-15 17:28:46