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();
}