From 86381b775c55faa8067b58f41d875a9deb2887cd Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 18 Oct 2026 11:53:51 -0400 Subject: [PATCH] Canned responses stage 1: Admin > Canned Responses on the standard list showing what each says, the ticket categories it is offered on, how often it has gone into a reply and when it last did, searched, filtered by where it is offered (a category or every ticket), sorted by name, use or last use, paged, with Active and Archived tabs; a response can be offered on several ticket categories or on every ticket (migration 3.2.2 moves canned_response_category_id into canned_response_categories, one whose category had been deleted staying on every ticket, and adds canned_response_uses); one shared add and edit window with the placeholders under the editor to click in where the cursor is; Copy, Archive and Restore alongside Delete; [Contact], [First Name], [Client], [Ticket], [Subject], [Ticket Link], [Asset], [Site], [Project], [Agent] (the agent replying), [Date], [Month], [Quarter] and [Year] filled in from the ticket as a response goes into a reply after the client access check, anything else in brackets left for the agent to fill in; the picker lists the ticket's own category first then every ticket's, and each reply records which responses went into it; Admin > Categories counts the canned responses on a ticket category, links to them and moves them on merge through link-table support in categoryKinds(); new functions/canned_response.php and js/canned_response_form.js; changelog updated --- CHANGELOG.md | 18 +- admin/canned_responses.php | 225 +++++++++++------- admin/database_updates/3.2.2.php | 40 ++++ .../canned_response/canned_response_add.php | 36 +-- .../canned_response/canned_response_edit.php | 57 ++--- .../canned_response/canned_response_form.php | 56 +++++ admin/post/canned_response.php | 84 ++++--- agent/ajax.php | 35 +-- agent/post/ticket.php | 3 + agent/ticket.php | 48 ++-- api/v1/categories/categories_model.php | 2 +- db.sql | 41 +++- functions.php | 1 + functions/canned_response.php | 215 +++++++++++++++++ functions/categories.php | 31 ++- js/canned_response_form.js | 20 ++ 16 files changed, 678 insertions(+), 234 deletions(-) create mode 100644 admin/database_updates/3.2.2.php create mode 100644 admin/modals/canned_response/canned_response_form.php create mode 100644 functions/canned_response.php create mode 100644 js/canned_response_form.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e247bad..110daf4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.2.1) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.2.2) in the same pass. Nothing else is required. -This is a big release, with forty-two database steps in one go, so take a backup before you update. +This is a big release, with forty-three database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -45,6 +45,8 @@ What the API makes from now on - tickets, replies, clients and the rest - is sta Ticket templates keep their subject, details and tasks, and set nothing else on a ticket until you give them a category, priority, agent, due time or tags. Words in square brackets already in a template, like [Employee Name], are asked for as plain, optional text when a ticket is raised from it, until you set them up on the template's page (see New Features, Ticket templates). Tickets raised before the update don't know which template they came from, so a template's use is counted from the update on. The starter ticket templates you added from Maintenance > Starter Content stay as they are; Update on that page brings them up to the new versions, which ask what they need and come with request forms. +Canned responses keep their names, what they say and their ticket category. One whose category had been deleted is offered on every ticket, as it already was. How often each is used is counted from the update. + If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. ### Breaking Changes and Notes @@ -503,6 +505,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The MSP starter templates in Starter Content are rebuilt on all of this: each asks what it needs (New Hire Onboarding asks the start date, job title, manager, whose access to copy and whether they need a computer or a phone extension), is set up with its category, priority, tags and due time, adds the tasks for the answers given, and the ones clients raise come with a request form - Onboard a new employee, Someone is leaving, Ask for a new computer and Report a suspicious email. Two new ones, Software Request and Access Change Request, have request forms too. An install that added the starter templates before can bring them up to date with Update - their tickets, agent and request form switch stay. - A ticket keeps which template it came from, and its history says so. Admin > Ticket Templates shows what each sets up, its tasks and their time, how many tickets it has raised and when it was last used, and the recurring tickets and project templates that raise it - searched, sorted by name, tasks, use or last use from the column headings, a page at a time, with Active and Archived tabs. A template's page shows the ticket it starts with its placeholders marked, its tasks (added with their time, dragged into order) and its latest tickets, and New ticket from it opens New Ticket with it filled in. - Templates can be copied, archived - not offered for new tickets, while the recurring tickets and project templates that use them keep them - restored and deleted. +- **Canned responses** + - Admin > Canned Responses lists what each says, the ticket categories it is offered on, how often it has gone into a reply and when it last did - searched, filtered by where it is offered, sorted by name, use or last use, a page at a time, with Active and Archived tabs. + - A response can be offered on several ticket categories, or on every ticket. + - [Contact], [First Name], [Client], [Ticket], [Subject], [Ticket Link], [Asset], [Site], [Project], [Agent], [Date], [Month], [Quarter] and [Year] are filled in from the ticket as a response goes into a reply. [Agent] is you, the agent replying, and [Ticket Link] is a link to the ticket, as its emails have. The response window lists them under the editor to click in where the cursor is. Anything else in square brackets - [Date of visit] - stays as it is, for you to fill in before sending. + - Responses can be copied, archived - no longer offered on tickets - restored and deleted. + - Admin > Categories counts the canned responses offered on each ticket category, linked to the list of them, and merging categories moves them over. - **Assets** - The asset list has: - type icons, colored status pills and OS icons @@ -1455,6 +1463,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Database 3.2.1: request forms. `ticket_templates` gains `ticket_template_portal`, `ticket_template_portal_title`, `ticket_template_portal_description`, `ticket_template_portal_icon` (one of `TICKET_TEMPLATE_PORTAL_ICONS`), `ticket_template_portal_who` (`everyone` or `managers`) and `ticket_template_portal_all_clients`; new `ticket_template_portal_clients` (cascading with the template and the client). `fill_in_client`, `ticket_fill_in_client` and `recurring_ticket_fill_in_client` say whether the client is asked a fill-in and sees its answer (default yes). - Request forms: `ticketTemplatePortalForms($client_id, $manager)` and `ticketTemplatePortalForm()` (what a contact may use - `$manager` is `portalSeesAllTickets()`), `ticketTemplatePortalTitle()`, `ticketTemplatePortalIcon()`, `ticketTemplateMentionsAsset()`, `ticketTemplatePortalAnswers()` (the client's answers checked, the agents' left blank), `ticketTemplatePortalRaise()` and `ticketTemplatePortalRequests($ids)`; `ticketTemplatePortalClientIds()` and `ticketTemplateSetPortalClients()`. `ticketTemplateFields()` and `ticketTemplateSave()` take the portal fields, and `ticketTemplateFinish()` takes the history line. `client/ticket_add.php` takes `?form=` (`problem` for the usual form) and posts `add_ticket_from_form` to `client/post.php`; its What happens next is `client/includes/ticket_next_steps.php`. `[Agent]` is no longer filled in with a client's name for a ticket raised in the portal with no agent - only an agent who raised it counts. - `inc_ticket_template_select.php` draws a `[data-fill-ins-box]` under the picker (`$ticket_template_fill_in_values` starts it with answers) and the options carry `data-template-fill-ins`; `ticket_tasks_modal.js` draws the fields, hides and disables the task rows for other answers, and binds its document listeners once (a window flag). `js/app.js` opens the tab of a required field left empty on a hidden tab (a capturing `invalid` listener). +- Canned responses: + - Database 3.2.2: `canned_response_categories` (`canned_response_id`, `category_id`) takes over from `canned_responses.canned_response_category_id`, which is dropped - a response with none is offered on every ticket. `canned_response_uses` (`canned_response_use_canned_response_id`, `canned_response_use_user_id`, `canned_response_use_ticket_id`, `canned_response_use_created_at`) keeps each time one goes into a reply. + - New `functions/canned_response.php`: `CANNED_RESPONSE_PLACEHOLDERS`, `cannedResponseRow()`, `cannedResponseCategoryIds()`, `cannedResponseCategoriesFor($ids)`, `cannedResponseFields($post, &$error)` (`name`, `body`, `category_ids`), `cannedResponseSave($id, $fields)`, `cannedResponseSetCategories()`, `cannedResponseCopy()`, `cannedResponsePreview()`, `cannedResponsesForTicket($category_id)` (`category` and `general`), `cannedResponseValues($ticket_id, $agent_name)`, `cannedResponseFill($html, $ticket_id, $agent_name)` (purified, then filled through `ticketPlaceholderFill()`) and `cannedResponseRecordUses($ids, $ticket_id, $user_id)`. + - Admin: `admin/canned_responses.php` takes `q`, `category` (an id, or `every`), `sort` - `canned_response_name`, `use_count` or `last_used` - `order`, `page` and `show=archived`. `admin/modals/canned_response/canned_response_form.php` is shared by `canned_response_add.php` (`category`) and `canned_response_edit.php` (`id`), with `js/canned_response_form.js`. `admin/post/canned_response.php`: `add_canned_response` and `edit_canned_response` (`canned_response_id`, `name`, `body`, `category_ids[]`), GET `copy_canned_response`, `archive_canned_response`, `restore_canned_response` and `delete_canned_response`. Audit entries are Canned Response Create, Edit, Archive, Restore and Delete. + - `agent/ajax.php?get_canned_response` takes `ticket_id` and answers the body filled in for that ticket, after the client access check. The picker adds `canned_response_ids[]` to the reply form, and `add_ticket_reply` records them when there is a reply. + - Categories: a use in `categoryKinds()` can be a link table - `link` (`owner`, `records`, `join`). `categoryUsage()` joins it, `categoryMoveRecords()` adds the new category and takes the old ones off, and the API's `used_by` counts them under `records` (still `canned_responses`). - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/canned_responses.php b/admin/canned_responses.php index 9a76e38bc..7810849f8 100644 --- a/admin/canned_responses.php +++ b/admin/canned_responses.php @@ -1,128 +1,187 @@ 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, and how often it has gone into a reply. A response + * can be changed, copied as the start of another, archived - not offered on + * tickets - or deleted. 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"; -/* - * 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" -); +$show_archived = ($_GET['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_created_at, canned_response_updated_at, canned_response_archived_at, + COALESCE(used.use_count, 0) AS use_count, used.last_used + FROM canned_responses + 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 canned_response_archived_at IS " . ($show_archived ? 'NOT NULL' : 'NULL') . " + 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)); + +$counts = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(canned_response_archived_at IS NULL), 0), COALESCE(SUM(canned_response_archived_at IS NOT NULL), 0) FROM canned_responses WHERE $filters")); + +$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) { + return '' + . $label . ($sort === $column ? " $order_icon" : '') . ''; +}; +$pill_url = function ($archived) use ($category_filter) { + return '?' . http_build_query(array_filter(['q' => $_GET['q'] ?? '', 'category' => $category_filter, 'show' => $archived ? 'archived' : ''])); +}; + +$add_url = 'modals/canned_response/canned_response_add.php' . (intval($category_filter) > 0 ? '?category=' . intval($category_filter) : ''); +$csrf = $_SESSION['csrf_token']; + ?>

Canned Responses

- +
+
- -
+
- - + +
- -
+
+
-
+
-
- - "> +
+
+ "> - - - + + + + + - $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 + ? '' . implode('', array_map(function ($category) { + return '' . categoryChip($category) . ''; + }, $categories)) . '' + : 'Every ticket'; + $preview = cannedResponsePreview($row['canned_response_body']); ?> - + - - + + + - -
- - Response - - - - Ticket Category - - ActionOffered on
-
- -
-
- - - -
-
+
+ +
+ + +
+ + +
Archived
+ + +
+
- - - - All ticket categories - - - Not yet' ?>> +
@@ -130,12 +189,14 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); $empty_state = [ 'thing' => 'canned responses', 'icon' => 'fa-comment-dots', - 'text' => 'Replies agents drop into a ticket in one click.', - 'actions' => [['label' => 'New Canned Response', 'modal' => 'modals/canned_response/canned_response_add.php', 'size' => 'lg']], + 'filtered' => $q !== '' || $category_filter !== '', + 'archived' => $show_archived, + 'views' => ['show'], + 'text' => 'Replies agents put into a ticket in one go - "Password reset done", "Waiting on you". [Contact], [Ticket], [Agent] 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"; ?> -
0"); + + mysqli_query($mysqli, "ALTER TABLE `canned_responses` DROP KEY `canned_response_category_id`, DROP `canned_response_category_id`"); + + // Each time one goes into a reply - who, on which ticket and when + mysqli_query($mysqli, "CREATE TABLE `canned_response_uses` ( + `canned_response_use_id` int(11) NOT NULL AUTO_INCREMENT, + `canned_response_use_canned_response_id` int(11) NOT NULL, + `canned_response_use_user_id` int(11) NOT NULL DEFAULT 0, + `canned_response_use_ticket_id` int(11) NOT NULL DEFAULT 0, + `canned_response_use_created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`canned_response_use_id`), + KEY `canned_response_use_canned_response_id` (`canned_response_use_canned_response_id`,`canned_response_use_created_at`), + KEY `canned_response_use_user_id` (`canned_response_use_user_id`), + CONSTRAINT `canned_response_uses_ibfk_1` FOREIGN KEY (`canned_response_use_canned_response_id`) REFERENCES `canned_responses` (`canned_response_id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); diff --git a/admin/modals/canned_response/canned_response_add.php b/admin/modals/canned_response/canned_response_add.php index a5d9b1d9e..739b25171 100644 --- a/admin/modals/canned_response/canned_response_add.php +++ b/admin/modals/canned_response/canned_response_add.php @@ -1,7 +1,15 @@ @@ -13,33 +21,7 @@ ob_start();