Files
itflow/admin/canned_responses.php
johnnyq 81c80d8965 Canned responses stages 3 and 4: agents keep canned responses of their own in Your account > Canned Responses (on the standard list with Yours and Shared tabs, how often they used each, change, copy, archive and delete their own, Copy to yours from a shared one), offered to them alone and first in the picker as Yours; Save your reply as a canned response at the foot of the picker opens the draft with the ticket's contact, first name, client, ticket number, subject, link and agent swapped back to placeholders (canned_response_unfill, text only, a one-word subject left alone), offered on the ticket's category with a switch to put it back as written, and saves it in JSON without leaving the ticket so the reply stays and the new one goes straight into the picker; Admin > Canned Responses gains an Agents' own tab showing whose each is with Share with everyone, copies made there are shared, and admins can share their own or tick Share when saving a reply; files go with a response (Files tab, up to ten of the ticket attachment kinds, kept in uploads/canned_responses/<id>/, streamed by agent/canned_response_file.php to those who may use it), shown as chips under the picker with an x to leave one off, copied onto the ticket with the reply and sent with its email on a ticket and in Update & Reply; migration 3.2.4 adds canned_response_user_id and canned_response_created_by and the canned_response_files table, existing responses staying shared; the response window moves to includes/canned_response_form.php for the admin and account windows; export and import (functions/canned_response_transfer.php) of one, some or every shared response as a versioned JSON file with categories by name, what it sets up, status by name and files as base64, importing as shared with categories matched or made and leave, add as well or replace (keeping use and archived state), everything made safe and refused files named; an MSP starter pack of 27 canned responses in Starter Content loaded through the same import, with Update for the ones already added, and the Update button now driven by the pack registry; new Canned Responses report under Service Desk by response and by agent, filtered by client and by the agent who used it, admins seeing every agent's own; changelog updated
2026-10-18 15:36:00 -04:00

275 lines
19 KiB
PHP

<?php
/*
* Admin > Canned Responses - replies an agent puts into a ticket in one go,
* from the picker above the reply box. Each is offered on tickets in the
* ticket categories it is set to, or on every ticket, and its placeholders
* are filled in from the ticket as it goes in. Each row shows what it says,
* where it is offered, what picking it sets up on the reply (who sees it,
* the status after it, time worked), the files that go with it and how often
* it has gone into a reply. A response can be changed, copied as the start of
* another, exported, archived - not offered on tickets - or deleted. The New
* button's menu imports and exports them as a file, and leads to the
* Canned Responses report and the starter ones.
*
* Active and Archived hold the shared ones; Agents' own the ones agents made
* for themselves (in their account, or saved from a reply), each with whose
* it is - one can be shared with everyone from its menu. The work is in
* functions/canned_response.php; the handlers are admin/post/canned_response.php.
*/
// Default Column Sortby Filter
$sort = "canned_response_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
// The views: the shared ones offered now, the agents' own, and the archived ones
$views = [
'' => ['label' => 'Active', 'icon' => 'fa-comment-dots', 'where' => 'canned_response_archived_at IS NULL AND canned_response_user_id = 0', 'title' => 'Shared - offered to everyone'],
'personal' => ['label' => "Agents' own", 'icon' => 'fa-user', 'where' => 'canned_response_archived_at IS NULL AND canned_response_user_id > 0', 'title' => 'Offered to the agent who made each - share one with everyone from its menu'],
'archived' => ['label' => 'Archived', 'icon' => 'fa-archive', 'where' => 'canned_response_archived_at IS NOT NULL', 'title' => 'Not offered on tickets'],
];
$show = (string) ($_GET['show'] ?? '');
$show = isset($views[$show]) ? $show : '';
$show_archived = $show === 'archived';
// Where it is offered: any, every ticket (no category), or one ticket category
$category_filter = (string) ($_GET['category'] ?? '');
$category_filter = $category_filter === 'every' || intval($category_filter) > 0 ? ($category_filter === 'every' ? 'every' : (string) intval($category_filter)) : '';
// Only these sort - an unknown column would take the page down
$sortable_columns = [
'canned_response_name' => "canned_response_name $order",
'use_count' => "use_count $order, canned_response_name ASC",
'last_used' => "last_used IS NULL, last_used $order, canned_response_name ASC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'canned_response_name';
}
$filters = "(canned_response_name LIKE '%$q%' OR canned_response_body LIKE '%$q%')";
if ($category_filter === 'every') {
$filters .= " AND NOT EXISTS (SELECT 1 FROM canned_response_categories c WHERE c.canned_response_id = canned_responses.canned_response_id)";
} elseif ($category_filter !== '') {
$filters .= " AND EXISTS (SELECT 1 FROM canned_response_categories c WHERE c.canned_response_id = canned_responses.canned_response_id AND c.category_id = " . intval($category_filter) . ")";
}
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS canned_responses.canned_response_id, canned_response_name, canned_response_body, canned_response_reply_type, canned_response_ticket_status, canned_response_minutes,
canned_response_user_id, canned_response_created_at, canned_response_updated_at, canned_response_archived_at, owner.user_name AS owner_name,
COALESCE(used.use_count, 0) AS use_count, used.last_used
FROM canned_responses
LEFT JOIN users owner ON owner.user_id = canned_response_user_id AND canned_response_user_id > 0
LEFT JOIN (
SELECT canned_response_use_canned_response_id AS used_id, COUNT(*) AS use_count, MAX(canned_response_use_created_at) AS last_used
FROM canned_response_uses GROUP BY canned_response_use_canned_response_id
) used ON used.used_id = canned_responses.canned_response_id
WHERE $filters AND {$views[$show]['where']}
ORDER BY {$sortable_columns[$sort]}, canned_responses.canned_response_id ASC
LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$responses = [];
while ($row = mysqli_fetch_assoc($sql)) {
$responses[intval($row['canned_response_id'])] = $row;
}
$response_categories = cannedResponseCategoriesFor(array_keys($responses));
$response_files = cannedResponseFilesFor(array_keys($responses));
$counts = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', array_map(function ($view) {
return "COALESCE(SUM({$view['where']}), 0)";
}, $views)) . " FROM canned_responses WHERE $filters"));
$view_counts = array_combine(array_keys($views), $counts);
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
$pill_url = function ($view) use ($category_filter) {
return '?' . http_build_query(array_filter(['q' => $_GET['q'] ?? '', 'category' => $category_filter, 'show' => $view]));
};
$add_url = 'modals/canned_response/canned_response_add.php' . (intval($category_filter) > 0 ? '?category=' . intval($category_filter) : '');
$csrf = $_SESSION['csrf_token'];
?>
<div class="card">
<div class="card-header bg-dark 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">
<div class="btn-group">
<button type="button" class="btn btn-primary ajax-modal" data-modal-size="lg" data-modal-url="<?= escapeHtml($add_url) ?>">
<i class="fas fa-plus"></i><span class="d-none d-lg-inline ms-2">New Canned Response</span>
</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-label="More"></button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/canned_response/canned_response_import.php">
<i class="fas fa-fw fa-file-upload text-secondary me-2"></i>Import
</a>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/canned_response/canned_response_export.php">
<i class="fas fa-fw fa-file-download text-secondary me-2"></i>Export
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/agent/reports/canned_responses.php">
<i class="fas fa-fw fa-chart-bar text-secondary me-2"></i>How they are used
</a>
<a class="dropdown-item" href="starter_content.php">
<i class="fas fa-fw fa-seedling text-secondary me-2"></i>Starter canned responses
</a>
</div>
</div>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<?php if ($show !== '') { ?><input type="hidden" name="show" value="<?= escapeHtml($show) ?>"><?php } ?>
<div class="row g-2 align-items-center">
<div class="col-lg-5 col-md-6">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search canned responses..." title="Name or what it says">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-lg-3 col-md-6">
<select class="form-select select2" name="category" onchange="this.form.submit()" aria-label="Offered on">
<option value="">- Offered on any ticket -</option>
<option value="every" <?= $category_filter === 'every' ? 'selected' : '' ?>>Every ticket</option>
<?php foreach (categoryPicker('Ticket', $category_filter !== '' && $category_filter !== 'every' ? [intval($category_filter)] : []) as $category_id => $category_row) { ?>
<?= categoryOptionHtml($category_row, (string) intval($category_id) === $category_filter) ?>
<?php } ?>
</select>
</div>
</div>
</form>
<nav class="nav nav-pills itflow-asset-tabs mt-2">
<?php foreach ($views as $view_key => $view) { ?>
<a class="nav-link <?= $show === $view_key ? 'active' : '' ?>" href="<?= escapeHtml($pill_url($view_key)) ?>" title="<?= escapeHtml($view['title']) ?>"><i class="fas fa-fw <?= $view['icon'] ?> me-1"></i><?= escapeHtml($view['label']) ?> <span class="badge rounded-pill ms-1"><?= intval($view_counts[$view_key]) ?></span></a>
<?php } ?>
</nav>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="ps-3"><?= $sort_link('canned_response_name', 'Response') ?></th>
<th class="d-none d-lg-table-cell">Offered on</th>
<th class="d-none d-md-table-cell"><?= $sort_link('use_count', 'Used') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('last_used', 'Last used') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($responses as $canned_response_id => $row) {
$name = escapeHtml($row['canned_response_name']);
$edit_url = "modals/canned_response/canned_response_edit.php?id=$canned_response_id";
$use_count = intval($row['use_count']);
$used_text = $use_count ? $use_count . ' time' . ($use_count === 1 ? '' : 's') : '';
$categories = $response_categories[$canned_response_id] ?? [];
$offered_html = $categories
? '<span class="d-inline-flex flex-wrap gap-1">' . implode('', array_map(function ($category) {
return '<span class="badge rounded-pill text-bg-light border fw-normal">' . categoryChip($category) . '</span>';
}, $categories)) . '</span>'
: '<span class="badge rounded-pill text-bg-light border fw-normal"><i class="fas fa-fw fa-globe text-secondary me-1"></i>Every ticket</span>';
$preview = cannedResponsePreview($row['canned_response_body']);
$sets_up = cannedResponseSetsUpHtml($row);
$files = $response_files[$canned_response_id] ?? [];
$owner_id = intval($row['canned_response_user_id']);
?>
<tr data-canned-response-id="<?= $canned_response_id ?>">
<td class="ps-3">
<div class="d-flex align-items-center gap-3">
<span class="itflow-asset-icon itflow-asset-tone-other d-none d-sm-inline-flex" aria-hidden="true"><i class="fas fa-fw fa-comment-dots"></i></span>
<div class="min-w-0">
<a class="ajax-modal fw-semibold text-body" href="#" data-modal-size="lg" data-modal-url="<?= $edit_url ?>" title="Edit <?= $name ?>"><?= $name ?></a>
<?php if ($preview !== '') { ?>
<div class="small text-body-secondary"><?= escapeHtml($preview) ?></div>
<?php } ?>
<?php if ($sets_up !== '' || $files) { ?>
<div class="mt-1 d-flex flex-wrap gap-1">
<?php if ($sets_up !== '') { ?><span title="Set on the reply when it is picked"><?= $sets_up ?></span><?php } ?>
<?php if ($files) { ?>
<span class="badge rounded-pill text-bg-light border fw-normal" title="Goes out with the reply: <?= escapeHtml(implode(', ', array_column($files, 'canned_response_file_name'))) ?>"><i class="fas fa-fw fa-paperclip text-secondary me-1"></i><?= count($files) ?> file<?= count($files) === 1 ? '' : 's' ?></span>
<?php } ?>
</div>
<?php } ?>
<?php if ($owner_id) { ?>
<div class="small text-body-secondary" title="Only offered to them"><i class="fas fa-fw fa-user me-1"></i><?= $row['owner_name'] !== null ? escapeHtml($row['owner_name']) . "'s own" : 'A removed agent\'s own' ?></div>
<?php } ?>
<?php if ($row['canned_response_archived_at']) { ?>
<div class="small text-body-secondary"><i class="fas fa-fw fa-archive me-1"></i>Archived <?= date('M j, Y', strtotime($row['canned_response_archived_at'])) ?></div>
<?php } ?>
<!-- On smaller screens where it is offered and its use come along under the name -->
<div class="d-lg-none mt-1"><?= $offered_html ?></div>
<div class="d-md-none small text-body-secondary mt-1"><?= $use_count ? 'Used ' . $used_text . ' · last ' . timeAgo($row['last_used']) : 'Not used yet' ?></div>
</div>
</div>
</td>
<td class="d-none d-lg-table-cell"><?= $offered_html ?></td>
<td class="d-none d-md-table-cell small text-nowrap"><?= $use_count ? $used_text : '<span class="text-body-secondary">Not yet</span>' ?></td>
<td class="d-none d-md-table-cell small text-body-secondary text-nowrap"<?= $row['last_used'] ? ' title="' . escapeHtml($row['last_used']) . '"' : '' ?>><?= $row['last_used'] ? timeAgo($row['last_used']) : '-' ?></td>
<td class="text-end">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-size="lg" data-modal-url="<?= $edit_url ?>">
<i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit
</a>
<a class="dropdown-item" href="post.php?copy_canned_response=<?= $canned_response_id ?>&csrf_token=<?= $csrf ?>" title="<?= $owner_id ? 'A new shared response starting from this one' : 'A new response starting from this one' ?>">
<i class="fas fa-fw fa-copy text-secondary me-2"></i><?= $owner_id ? 'Copy as shared' : 'Copy' ?>
</a>
<a class="dropdown-item" href="post.php?export_canned_response=<?= $canned_response_id ?>&csrf_token=<?= $csrf ?>" title="A file of it, to import into another ITFlow">
<i class="fas fa-fw fa-file-download text-secondary me-2"></i>Export
</a>
<?php if ($owner_id) { ?>
<a class="dropdown-item confirm-link" href="post.php?share_canned_response=<?= $canned_response_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Share <?= $name ?> with everyone?" data-confirm-text="It moves to the shared ones and every agent is offered it. <?= escapeHtml($row['owner_name'] ?? 'Its agent') ?> can't change it after." data-confirm-button="Share">
<i class="fas fa-fw fa-users text-secondary me-2"></i>Share with everyone
</a>
<?php } ?>
<div class="dropdown-divider"></div>
<?php if ($row['canned_response_archived_at']) { ?>
<a class="dropdown-item" href="post.php?restore_canned_response=<?= $canned_response_id ?>&csrf_token=<?= $csrf ?>">
<i class="fas fa-fw fa-box-open text-secondary me-2"></i>Restore
</a>
<?php } else { ?>
<a class="dropdown-item text-danger" href="post.php?archive_canned_response=<?= $canned_response_id ?>&csrf_token=<?= $csrf ?>" title="Not offered on tickets">
<i class="fas fa-fw fa-archive me-2"></i>Archive
</a>
<?php } ?>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_canned_response=<?= $canned_response_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Delete <?= $name ?>?" data-confirm-text="It goes for good, with its record of use. Replies it went into stay as they are." data-confirm-button="Delete">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php
$empty_state = [
'thing' => 'canned responses',
'icon' => 'fa-comment-dots',
'filtered' => $q !== '' || $category_filter !== '',
'archived' => $show_archived,
'views' => ['show'],
'view' => $show === 'personal' ? [
'title' => "No agents' own canned responses",
'icon' => 'fa-user',
'text' => 'Agents make their own in their account, under Canned Responses, or with <strong>Save as canned response</strong> on a ticket reply. Share one with everyone from its menu here.',
'actions' => [],
] : [],
'text' => 'Replies agents put into a ticket in one go - "Password reset done", "Waiting on you". <span class="font-monospace">[Contact]</span>, <span class="font-monospace">[Ticket]</span>, <span class="font-monospace">[Agent]</span> and the like are filled in from the ticket.',
'actions' => [['label' => 'New Canned Response', 'modal' => $add_url, 'size' => 'lg']],
];
require_once "../includes/filter_footer.php";
?>
</div>
<?php
require_once "../includes/footer.php";