diff --git a/.gitignore b/.gitignore index ad22028c1..abecda13c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ uploads/tickets/* !uploads/tickets/index.php uploads/ticket_templates/* !uploads/ticket_templates/index.php +uploads/canned_responses/* +!uploads/canned_responses/index.php .idea/* plugins/htmlpurifier/standalone/HTMLPurifier/DefinitionCache/Serializer/HTML/* !plugins/htmlpurifier/standalone/HTMLPurifier/DefinitionCache/Serializer/HTML/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index 882ec300d..2b98d78af 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.3) 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.4) in the same pass. Nothing else is required. -This is a big release, with forty-four database steps in one go, so take a backup before you update. +This is a big release, with forty-five 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,7 +45,7 @@ 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. None sets up anything on the reply until you give it a reply type, status or time worked on its Sets up tab. +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. None sets up anything on the reply until you give it a reply type, status or time worked on its Sets up tab. Every canned response already there is shared - offered to everyone, as before - and none has files. 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. @@ -262,6 +262,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Open Ticket Aging: the open tickets right now by how long they have been open, per agent and per client, with whose move it is, their SLA and when anything last happened. - Client Profitability: income, direct costs and time per client, what you earn per hour against your target rate, and recurring revenue. - Quotes Pipeline: quotes won, lost and open, the win rate by count and by value, and open quotes about to expire. + - Canned Responses: how often each canned response goes into a reply, on how many tickets and by how many agents, against the period before, and when it was last used - every current one, so the ones not used show - and by agent, how often each reaches for one, how many different ones and how many of their own. An admin sees every agent's own; anyone else sees the shared ones and their own. - Ticket Templates: how often each template is used and how - by agents, from the client portal's request forms, by recurring tickets and by project templates - with what is resolved and still open, how long its tickets take and the time spent on them, and the templates not used in the period. - Mileage: trips by agent and by client, valued at your rate per mile. - Budget vs Actual: set a monthly budget for each expense category right on the report, or copy last year's, and see spending against it by month and category. @@ -515,6 +516,13 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Sending a reply a canned response went into with something still in square brackets - [Date of visit] - asks first: Fill them in selects the first one to type over, or Send anyway. - Update & Reply on the tickets list offers the responses for the tickets' categories and the ones for every ticket. [First Name], [Ticket], [Ticket Link] and the other placeholders in what you write there are filled in for each ticket as it is sent, and a response picked into it counts as used on each. - Admin > Categories counts the canned responses offered on each ticket category, linked to the list of them, and merging categories moves them over. + - Agents can keep canned responses of their own, in their account under Canned Responses - offered to them alone, first in the picker as Yours. The page lists theirs and the shared ones, with how often they have used each, and their own can be changed, copied, archived and deleted there. A shared one can be copied to theirs, to change as they like. + - Save your reply as a canned response, at the foot of the picker, opens what you have written with the ticket's contact, first name, client, ticket number, subject, link and your name swapped back for placeholders, offered on the ticket's category, to change before saving. A switch puts it back as you wrote it. It is saved without leaving the ticket: your reply stays as it was, and the new one is in the picker straight away. + - Admin > Canned Responses has an Agents' own tab, saying whose each is, and Share with everyone moves one to the shared ones. A copy made there is shared. An admin can share one of their own from their account too, or tick Share with everyone when saving a reply. + - Files can go with a canned response - a how-to, a form to fill in - up to ten, on its Files tab, of the kinds a ticket takes. Picking it shows them under the picker, each with an x to leave it off, and the rest are copied onto the ticket with the reply and sent with its email - on a ticket and in Update & Reply. The lists show how many files each has. + - Export and import: Admin > Canned Responses exports every current shared one, the ones picked (archived ones too, if you like), or one from its menu, as a file to import into another ITFlow or keep. Each goes with what it says, the ticket categories it is offered on and what it sets up, and its files unless you leave them out. Importing brings them in shared, matches the categories by name and makes the ones missing, matches the status after the reply by name, and for one already here by name leaves yours, adds the file's as well under a name of its own, or replaces yours with it - keeping how often yours has been used. What comes in is made safe, and a file of a kind a ticket doesn't take is left out and said. + - Starter Content has a Canned Responses pack for an MSP: received, waiting on you, following up, resolved, closing with no reply, a remote session, an onsite visit, escalated to the vendor, an internal handover, billable work, password reset, multi-factor sign-in set up again, account unlocked, licence assigned, phishing reported (phishing or safe), a possible security incident, a new starter ready, a leaver done, warranty repair, printer fixed, file restored, a monitoring alert cleared, internet down with the provider, voicemail PIN reset, a quote sent and an invoice question. Each is filled in from the ticket, sets the reply up (who sees it, the status after it, time worked), and the ones for one kind of ticket are offered on its category. Update brings the ones you added before up to the current version. + - The New Canned Response button's menu has Import, Export, the report and the starter ones. - **Assets** - The asset list has: - type icons, colored status pills and OS icons @@ -1478,6 +1486,16 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The picker: `agent/includes/inc_canned_response_picker.php` (`$canned_picker_groups`, `$canned_picker_id`, `$canned_picker_editor`, `$canned_picker_ticket_id` - 0 for Update & Reply) and `agent/js/canned_response_picker.js`, which exposes `window.itflowCannedPick(pickerId, id)` and asks before a submit with bracketed words left. `js/app.js` `itflowCannedAutocompleter(editor)` adds the / menu to the `.tinymce` and `.tinymceTicket` editors whose textarea names a picker in `data-canned-picker`. `get_canned_response` answers `found`, `id`, `name`, `body`, `reply_type`, `reply_type_name`, `ticket_status`, `status_name`, `minutes` and `minutes_text`. - `bulk_ticket_reply` fills the placeholders for each ticket with `cannedResponsePlaceholders()` - its email too - and records `canned_response_ids[]` on each. `agent/js/ticket_time_tracking.js` resets the timer on the reply form's submit, when it isn't prevented, rather than on a click of Send. - 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`). + - Database 3.2.4: `canned_responses` gains `canned_response_user_id` (0 for a shared one, otherwise the agent whose own it is - indexed) and `canned_response_created_by`. New `canned_response_files` (`canned_response_file_id`, `canned_response_file_canned_response_id` - deleted with its response - `canned_response_file_name`, `canned_response_file_reference_name`, `canned_response_file_size`, `canned_response_file_created_at`); the files are kept in `uploads/canned_responses//`. + - `functions/canned_response.php`: `cannedResponseUsableSql($user_id)` and `cannedResponseUsable($row, $user_id)` (shared, or theirs). `cannedResponseSave($id, $fields, $owner_id = 0)` sets the owner and maker of a new one. `cannedResponseCopy($id, $owner_id = null)` copies the files too, and is the same owner's unless given one. `cannedResponsePickerGroups($category_ids, $user_id)` and `cannedResponsesForTicket($category_id, $user_id)` take the agent - Yours first, the agent's own for those categories or every ticket - and each row has `file_count`. Files: `CANNED_RESPONSE_MAX_FILES`, `cannedResponseFileDir()`, `cannedResponseFilesFor($ids)`, `cannedResponseFiles($id)`, `cannedResponseSaveFiles($id, $field = 'files')` (returns the names refused), `cannedResponseRemoveFiles($id, $file_ids)`, `cannedResponseCopyFiles($from, $to)`, `cannedResponseDeleteFiles($id)`, `cannedResponseAttachFiles($file_ids, $ticket_id, $reply_id, $user_id)` (copies onto the ticket and returns what `saveTicketAttachments()` does - only the files of responses the agent may use) and `cannedResponseFileSizeText()`. `cannedResponseUnfill($html, $ticket_id, $agent_name)` returns `[$html, $placeholders]`, swapping in the text only. + - `functions/files.php`: `TICKET_ATTACHMENT_EXTENSIONS`, which `saveTicketAttachments()` uses. + - The response window's fields moved to `includes/canned_response_form.php`, with a Files tab (`files[]`, `remove_file_ids[]`), shared by the admin windows and the account ones, which post as multipart. `admin/canned_responses.php` takes `show=personal`. `admin/post/canned_response.php` has GET `share_canned_response`, `copy_canned_response` makes a shared copy, and add and edit keep and remove files. + - Your account: `agent/user/user_canned_responses.php` (`q`, `category`, `sort`, `order`, `page`, `show=shared`), in the account nav for Support at Modify. `agent/user/modals/canned_response_add.php` (`category`), `canned_response_edit.php` (`id`) and `canned_response_save.php` (`ticket_id`, `editor`, `picker`) with `agent/js/canned_response_save.js`. `agent/user/post/canned_response.php`: `add_your_canned_response` - with `respond_json=1` (and `ticket_id`, and `share` for an admin) it answers JSON: `ok`, `id`, `name`, `preview`, `sets_up`, `group` (where the picker on that ticket offers it, or ''), `shared`, `message` and `refused`, or `ok: false` with `error` - `edit_your_canned_response`, and GET `copy_your_canned_response`, `copy_canned_response_to_yours`, `archive_your_canned_response`, `restore_your_canned_response` and `delete_your_canned_response`, each the agent's own only. Their audit entries say "their own". + - `agent/ajax.php`: `get_canned_response` answers only a shared one or the agent's own, with `files` (`id`, `name`, `size`). POST `canned_response_unfill` (`csrf_token`, `ticket_id`, `html`) answers `html`, `original` and `placeholders`, for Support at Modify. `agent/canned_response_file.php?file_id=` (`action=view` for inline) streams a file to those who may use its response, and to admins. + - `add_ticket_reply` and `bulk_ticket_reply` take `canned_response_file_ids[]`, put on the reply with `cannedResponseAttachFiles()` and sent with its email. The picker is drawn on a ticket even with none on offer (`[data-canned-empty]`), ends with `[data-canned-save-open]` and a link to the agent's own, and shows the files in `[data-canned-files]`; the ticket's reply box always names it in `data-canned-picker`. + - Export and import: new `functions/canned_response_transfer.php` - `cannedResponsesExport($ids, $archived, $with_files)` (the file's contents: `format` itflow-canned-responses, `version` 1, `responses`, each with `name`, `body`, `categories` by name, `reply_type` - `internal`, `public`, `email` or null - `status` by name, `minutes` and, when exported with them, `files` as `name` and base64 `data`, each up to `CANNED_RESPONSE_TRANSFER_FILE_MAX`), `cannedResponsesExportFileName()`, `cannedResponsesImportRead($contents, &$error)` (checks and tidies a file - the body through `documentHtmlPurify()`, statuses matched to `cannedResponseStatuses()`, files to `TICKET_ATTACHMENT_EXTENSIONS` - and takes the starter pack's array the same way), `cannedResponsesImport($responses, $existing, $only_existing)` (`skip`, `copy` or `replace`, against shared ones only; a response without `files` keeps its own), `cannedResponseImportCategory()`, `cannedResponseAddFileData($id, $name, $bytes)` and `cannedResponsesImportSummary()`. Handlers in `admin/post/canned_response.php`: POST `export_canned_responses` (`canned_response_ids[]`, `archived`, `files`), GET `export_canned_response` and POST `import_canned_responses` (`file`, `existing`), with `admin/modals/canned_response/canned_response_export.php` and `canned_response_import.php`. Audit entries are Canned Response Export and Import. + - Starter Content: a pack can say how its ones already here are updated (`update` - `button`, `confirm` - in `starterContentPacks()`), which draws the Update button. New `canned_responses` pack: `starterContentCannedResponses()` in the file format, `starterLoadCannedResponses()` through `cannedResponsesImport()`, and `starterUpdateCannedResponses()` with the `update_starter_canned_responses` handler. + - Report: `agent/reports/canned_responses.php` (`client_id`, `agent` - who used it - and the period; `export=csv` by response, `table=agents` by agent), `canned_responses` in `reportCatalog()` under Service Desk. - 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 484a85f49..012b29d55 100644 --- a/admin/canned_responses.php +++ b/admin/canned_responses.php @@ -6,10 +6,16 @@ * 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) 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. + * 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 @@ -18,7 +24,15 @@ $order = "ASC"; require_once "includes/inc_all_admin.php"; -$show_archived = ($_GET['show'] ?? '') === 'archived'; +// 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'] ?? ''); @@ -41,14 +55,16 @@ if ($category_filter === 'every') { $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_created_at, canned_response_updated_at, canned_response_archived_at, +$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 canned_response_archived_at IS " . ($show_archived ? 'NOT NULL' : 'NULL') . " + WHERE $filters AND {$views[$show]['where']} ORDER BY {$sortable_columns[$sort]}, canned_responses.canned_response_id ASC LIMIT $record_from, $record_to"); @@ -59,15 +75,19 @@ 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 COALESCE(SUM(canned_response_archived_at IS NULL), 0), COALESCE(SUM(canned_response_archived_at IS NOT NULL), 0) FROM canned_responses WHERE $filters")); +$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 '' . $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' : ''])); +$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) : ''); @@ -79,12 +99,32 @@ $csrf = $_SESSION['csrf_token'];

Canned Responses

- +
- +
@@ -104,8 +144,9 @@ $csrf = $_SESSION['csrf_token'];
@@ -133,6 +174,8 @@ $csrf = $_SESSION['csrf_token']; : 'Every ticket'; $preview = cannedResponsePreview($row['canned_response_body']); $sets_up = cannedResponseSetsUpHtml($row); + $files = $response_files[$canned_response_id] ?? []; + $owner_id = intval($row['canned_response_user_id']); ?> @@ -143,8 +186,16 @@ $csrf = $_SESSION['csrf_token'];
- -
+ +
+ + + file + +
+ + +
Archived
@@ -165,9 +216,18 @@ $csrf = $_SESSION['csrf_token']; Edit - - Copy + + + + Export + + + + Share with everyone + + @@ -197,6 +257,12 @@ $csrf = $_SESSION['csrf_token']; '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 Save as canned response 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". [Contact], [Ticket], [Agent] and the like are filled in from the ticket.', 'actions' => [['label' => 'New Canned Response', 'modal' => $add_url, 'size' => 'lg']], ]; diff --git a/admin/database_updates/3.2.4.php b/admin/database_updates/3.2.4.php new file mode 100644 index 000000000..c953c4088 --- /dev/null +++ b/admin/database_updates/3.2.4.php @@ -0,0 +1,29 @@ +/ + // and copied onto the ticket of each reply it goes into + mysqli_query($mysqli, "CREATE TABLE `canned_response_files` ( + `canned_response_file_id` int(11) NOT NULL AUTO_INCREMENT, + `canned_response_file_canned_response_id` int(11) NOT NULL, + `canned_response_file_name` varchar(255) NOT NULL, + `canned_response_file_reference_name` varchar(255) NOT NULL, + `canned_response_file_size` int(11) NOT NULL DEFAULT 0, + `canned_response_file_created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`canned_response_file_id`), + KEY `canned_response_file_canned_response_id` (`canned_response_file_canned_response_id`), + CONSTRAINT `canned_response_files_ibfk_1` FOREIGN KEY (`canned_response_file_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 739b25171..8a72d6449 100644 --- a/admin/modals/canned_response/canned_response_add.php +++ b/admin/modals/canned_response/canned_response_add.php @@ -1,7 +1,7 @@ New Canned Response
-
+ - +
+
0; + // With none on offer at all the list says so already + none.hidden = shown > 0 || itemsOf(picker).length === 0; } } @@ -85,6 +90,47 @@ } } + // The files that go with a response, under the picker - each once + function addFiles(picker, files) { + const box = picker.querySelector('[data-canned-files]'); + let added = 0; + if (!box || !files || !files.length) { + return added; + } + files.forEach(function (file) { + if (box.querySelector('[data-canned-file-id="' + file.id + '"]')) { + return; + } + const chip = document.createElement('span'); + chip.className = 'badge rounded-pill text-bg-light border fw-normal d-inline-flex align-items-center gap-1 itflow-canned-file'; + chip.dataset.cannedFileId = file.id; + chip.title = 'Copied onto the ticket, and sent with the reply when it is emailed'; + const icon = document.createElement('i'); + icon.className = 'fas fa-fw fa-paperclip text-secondary'; + const name = document.createElement('span'); + name.className = 'text-truncate'; + name.textContent = file.name; + const size = document.createElement('span'); + size.className = 'text-body-secondary'; + size.textContent = file.size; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'btn-close'; + remove.title = 'Leave it off'; + remove.setAttribute('aria-label', 'Leave ' + file.name + ' off'); + remove.setAttribute('data-canned-file-remove', ''); + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'canned_response_file_ids[]'; + input.value = file.id; + chip.append(icon, name, size, remove, input); + box.appendChild(chip); + added++; + }); + box.hidden = !box.querySelector('[data-canned-file-id]'); + return added; + } + function pad(n) { return (n < 10 ? '0' : '') + n; } @@ -187,6 +233,10 @@ } insert(picker.dataset.editor, data.body); const done = apply(form, data); + const files = addFiles(picker, data.files); + if (files) { + done.push('put ' + (files === 1 ? 'a file' : files + ' files') + ' with it'); + } say(picker, data.name + ' went in' + (done.length ? ', and ' + done.join(', ') : '') + '.'); }) .catch(function () { @@ -243,12 +293,27 @@ } }); - // A click on one picks it + // A click on one picks it; a file's x leaves it off; Save your reply as a canned response closes the list as its window opens document.addEventListener('click', function (e) { const item = e.target.closest('[data-canned-picker] [data-canned-id]'); if (item) { e.preventDefault(); pick(item.closest('[data-canned-picker]'), item.dataset.cannedId); + return; + } + const remove = e.target.closest('[data-canned-picker] [data-canned-file-remove]'); + if (remove) { + const box = remove.closest('[data-canned-files]'); + remove.closest('[data-canned-file-id]').remove(); + box.hidden = !box.querySelector('[data-canned-file-id]'); + return; + } + const save = e.target.closest('[data-canned-picker] [data-canned-save-open]'); + if (save && window.bootstrap) { + const toggle = save.closest('[data-canned-picker]').querySelector('[data-canned-toggle]'); + if (toggle) { + bootstrap.Dropdown.getOrCreateInstance(toggle).hide(); + } } }); diff --git a/agent/js/canned_response_save.js b/agent/js/canned_response_save.js new file mode 100644 index 000000000..485c42ad5 --- /dev/null +++ b/agent/js/canned_response_save.js @@ -0,0 +1,241 @@ +/* + * Save your reply as a canned response (agent/user/modals/canned_response_save.php), + * from the picker above a ticket's reply. + * + * - The reply being written comes into the window's editor with the ticket's + * details swapped for placeholders (agent/ajax.php canned_response_unfill), + * saying which. Turning the swap off puts the reply back as it was written; + * when it has been changed in the window since, it asks first. + * - Saving posts the window (agent/user/post/canned_response.php, answered in + * JSON) without leaving the ticket, so the reply stays as it is. The new one + * goes into the picker where it is offered on this ticket, and a toast says + * it was saved. + * + * The window is an ajax modal that runs this each time it opens: each opening + * sets up its own form, and the save listener is bound once. + */ +(function () { + + function editorReady(id, then, tries) { + const editor = window.tinymce ? tinymce.get(id) : null; + if (editor && editor.initialized) { + then(editor); + } else if ((tries || 0) < 200) { + setTimeout(function () { + editorReady(id, then, (tries || 0) + 1); + }, 50); + } + } + + function showError(form, text) { + const box = form.querySelector('[data-canned-save-error]'); + if (box) { + box.textContent = text; + box.hidden = false; + } + } + + function swappedText(placeholders) { + return placeholders.length + ? 'Swapped for placeholders: ' + placeholders.map(function (name) { + return '[' + name + ']'; + }).join(', ') + ' - filled in from each ticket it goes into.' + : "None of this ticket's details were in it to swap."; + } + + // Set up the window just opened + function setUp(form) { + form.dataset.cannedSaveReady = '1'; + const source = window.tinymce ? tinymce.get(form.dataset.editor) : null; + const textarea = document.getElementById(form.dataset.editor); + const written = source ? source.getContent() : (textarea ? textarea.value : ''); + const swapped = form.querySelector('[data-canned-save-swapped]'); + const toggle = form.querySelector('[data-canned-save-unfill]'); + const state = { original: written, unfilled: written, placeholders: [], lastSet: null }; + + function put(html) { + editorReady('cannedResponseBody', function (editor) { + editor.setContent(html); + state.lastSet = editor.getContent(); + }); + } + + const body = new FormData(); + body.append('csrf_token', form.querySelector('input[name="csrf_token"]').value); + body.append('ticket_id', form.dataset.ticketId); + body.append('html', written); + fetch('/agent/ajax.php?canned_response_unfill', { method: 'POST', body: body, credentials: 'same-origin' }) + .then(function (response) { + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + return response.json(); + }) + .then(function (data) { + state.original = data.original; + state.unfilled = data.html; + state.placeholders = data.placeholders || []; + swapped.textContent = written.trim() === '' ? 'Your reply is empty - write the response here.' : swappedText(state.placeholders); + put(toggle.checked ? state.unfilled : state.original); + }) + .catch(function () { + swapped.textContent = "Couldn't swap this ticket's details - your reply is here as you wrote it."; + toggle.checked = false; + toggle.disabled = true; + put(written); + }); + + // Swapped, or as written - asking first when it was changed here since + toggle.addEventListener('change', function () { + const editor = window.tinymce ? tinymce.get('cannedResponseBody') : null; + if (!editor) { + return; + } + const wanted = toggle.checked ? state.unfilled : state.original; + const go = function () { + put(wanted); + swapped.textContent = toggle.checked ? swappedText(state.placeholders) : 'As you wrote it, with this ticket\'s details in it.'; + }; + if (state.lastSet === null || editor.getContent() === state.lastSet || typeof Swal === 'undefined') { + go(); + return; + } + Swal.fire({ + theme: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light', + title: 'Start again from your reply?', + text: 'What you changed here goes.', + icon: 'question', + showCancelButton: true, + confirmButtonText: 'Start again', + cancelButtonText: 'Keep my changes', + reverseButtons: true, + focusCancel: true, + buttonsStyling: false, + customClass: { + confirmButton: 'btn btn-primary mx-1', + cancelButton: 'btn btn-secondary mx-1' + } + }).then(function (result) { + if (result.isConfirmed) { + go(); + } else { + toggle.checked = !toggle.checked; + } + }); + }); + } + + // The saved one, into the picker where it is offered on this ticket + function addToPicker(pickerId, data) { + const picker = pickerId ? document.getElementById(pickerId) : null; + const list = picker ? picker.querySelector('[data-canned-list]') : null; + if (!list || !data.group) { + return; + } + let group = Array.from(list.querySelectorAll('[data-canned-group]')).find(function (each) { + const header = each.querySelector('.dropdown-header'); + return header && header.textContent === data.group; + }); + if (!group) { + group = document.createElement('div'); + group.setAttribute('data-canned-group', ''); + const header = document.createElement('h6'); + header.className = 'dropdown-header'; + header.textContent = data.group; + group.appendChild(header); + // Yours first, Every ticket last, a category's before it + const groups = list.querySelectorAll('[data-canned-group]'); + const every = Array.from(groups).find(function (each) { + const h = each.querySelector('.dropdown-header'); + return h && h.textContent === 'Every ticket'; + }); + const end = list.querySelector('[data-canned-empty], [data-canned-none]'); + list.insertBefore(group, (data.group === 'Yours' ? groups[0] : every) || end); + } + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'dropdown-item itflow-canned-item'; + item.dataset.cannedId = data.id; + item.dataset.cannedName = data.name; + item.dataset.cannedPreview = data.preview; + item.dataset.cannedSearchText = (data.name + ' ' + data.preview).toLowerCase(); + const name = document.createElement('span'); + name.className = 'd-block fw-semibold text-truncate'; + name.textContent = data.name; + item.appendChild(name); + if (data.preview) { + const preview = document.createElement('span'); + preview.className = 'd-block small text-body-secondary itflow-canned-preview'; + preview.textContent = data.preview; + item.appendChild(preview); + } + // What it sets up, as the server drew it + if (data.sets_up) { + const setsUp = document.createElement('span'); + setsUp.className = 'd-block mt-1'; + setsUp.innerHTML = data.sets_up; + item.appendChild(setsUp); + } + // In the group by name + const after = Array.from(group.querySelectorAll('[data-canned-id]')).find(function (each) { + return each.dataset.cannedName.localeCompare(data.name, undefined, { sensitivity: 'base', numeric: true }) > 0; + }); + group.insertBefore(item, after || null); + const none = picker.querySelector('[data-canned-empty]'); + if (none) { + none.remove(); + } + } + + document.querySelectorAll('form[data-canned-save]').forEach(function (form) { + if (!form.dataset.cannedSaveReady) { + setUp(form); + } + }); + + if (window.itflowCannedSaveBound) { + return; + } + window.itflowCannedSaveBound = true; + + // Saved without leaving the ticket + document.addEventListener('submit', function (e) { + const form = e.target.closest ? e.target.closest('form[data-canned-save]') : null; + if (!form) { + return; + } + e.preventDefault(); + if (window.tinymce) { + tinymce.triggerSave(); + } + const button = form.querySelector('button[type="submit"]'); + button.disabled = true; + form.querySelector('[data-canned-save-error]').hidden = true; + fetch(form.action, { method: 'POST', body: new FormData(form), credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) + .then(function (response) { + if (!response.ok) { + throw new Error(response.status === 403 ? 'your session may have expired - reload the page and try again' : 'HTTP ' + response.status); + } + return response.json(); + }) + .then(function (data) { + if (!data.ok) { + showError(form, data.error || "It wasn't saved."); + button.disabled = false; + return; + } + addToPicker(form.dataset.picker, data); + const modal = form.closest('.modal'); + if (modal && window.bootstrap) { + bootstrap.Modal.getOrCreateInstance(modal).hide(); + } + if (typeof itflowToast === 'function') { + itflowToast(data.message, data.refused && data.refused.length ? 'warning' : 'success'); + } + }) + .catch(function (error) { + showError(form, "Couldn't save it - " + error.message + '.'); + button.disabled = false; + }); + }); +})(); diff --git a/agent/modals/ticket/ticket_bulk_reply.php b/agent/modals/ticket/ticket_bulk_reply.php index 2cf3c1c58..d87c17bb8 100644 --- a/agent/modals/ticket/ticket_bulk_reply.php +++ b/agent/modals/ticket/ticket_bulk_reply.php @@ -16,7 +16,7 @@ if ($ticket_ids) { $bulk_category_ids[] = intval($row[0]); } } -$canned_picker_groups = cannedResponsePickerGroups($bulk_category_ids); +$canned_picker_groups = cannedResponsePickerGroups($bulk_category_ids, $session_user_id); $canned_picker_id = 'cannedPickerBulk'; $canned_picker_editor = 'bulkReplyDetails'; $canned_picker_ticket_id = 0; diff --git a/agent/post/ticket.php b/agent/post/ticket.php index ddc7f83d0..d48474adc 100644 --- a/agent/post/ticket.php +++ b/agent/post/ticket.php @@ -846,6 +846,7 @@ if (isset($_POST['bulk_ticket_reply'])) { // The placeholders are filled in for each ticket, below $reply_raw = (string) ($_POST['bulk_reply_details'] ?? ''); $canned_response_ids = (array) ($_POST['canned_response_ids'] ?? []); + $canned_response_file_ids = (array) ($_POST['canned_response_file_ids'] ?? []); $status_input = intval($_POST['bulk_status'] ?? 0); $time_worked = escapeSql(ticketTimeWorked($_POST['time'] ?? '')); $private_note = intval($_POST['bulk_private_reply'] ?? 0); @@ -875,7 +876,10 @@ if (isset($_POST['bulk_ticket_reply'])) { $reply_text = cannedResponsePlaceholders($reply_raw, $ticket_id, stripslashes($session_name)); $reply = mysqli_real_escape_string($mysqli, $reply_text); mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$reply', ticket_reply_time_worked = '$time_worked', ticket_reply_type = '$reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id"); + $bulk_reply_id = mysqli_insert_id($mysqli); cannedResponseRecordUses($canned_response_ids, $ticket_id, $session_user_id); + // The files of the canned responses that went into it, on each ticket and in its email + $bulk_attachments = filterEmailableAttachments(cannedResponseAttachFiles($canned_response_file_ids, $ticket_id, $bulk_reply_id, $session_user_id)); mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $ticket_status, ticket_updated_at = NOW() WHERE ticket_id = $ticket_id"); syncTicketSlaClock($ticket_id); @@ -899,7 +903,7 @@ if (isset($_POST['bulk_ticket_reply'])) { // Email the client and watchers about a public update if (!$private_note) { - ticketMailReply($ticket_id, $reply_text, stripslashes($session_name)); + ticketMailReply($ticket_id, $reply_text, stripslashes($session_name), false, $bulk_attachments['send']); } // Tell the agent it is assigned to, and whoever opened it @@ -1101,8 +1105,9 @@ if (isset($_POST['add_ticket_reply'])) { $ticket_reply_id = mysqli_insert_id($mysqli); // Store any attached files against this reply before the email is built, so - // a public reply can carry them - $emailable_attachments = filterEmailableAttachments(saveTicketAttachments($ticket_id, $ticket_reply_id)); + // a public reply can carry them - the canned responses' that went into it too + $emailable_attachments = filterEmailableAttachments(array_merge(saveTicketAttachments($ticket_id, $ticket_reply_id), + cannedResponseAttachFiles((array) ($_POST['canned_response_file_ids'] ?? []), $ticket_id, $ticket_reply_id, $session_user_id))); // The canned responses picked into it, for how often each is used cannedResponseRecordUses((array) ($_POST['canned_response_ids'] ?? []), $ticket_id, $session_user_id); @@ -1132,7 +1137,8 @@ if (isset($_POST['add_ticket_reply'])) { } else { // A file uploaded without any accompanying text has no reply to hang off, // so it attaches to the ticket itself - $emailable_attachments = filterEmailableAttachments(saveTicketAttachments($ticket_id, null)); + $emailable_attachments = filterEmailableAttachments(array_merge(saveTicketAttachments($ticket_id, null), + cannedResponseAttachFiles((array) ($_POST['canned_response_file_ids'] ?? []), $ticket_id, null, $session_user_id))); flashAlert("Ticket updated"); } diff --git a/agent/reports/canned_responses.php b/agent/reports/canned_responses.php new file mode 100644 index 000000000..c1f8c85cf --- /dev/null +++ b/agent/reports/canned_responses.php @@ -0,0 +1,207 @@ + 0 + WHERE $visible AND (canned_response_archived_at IS NULL OR canned_responses.canned_response_id IN (SELECT canned_response_use_canned_response_id FROM $uses WHERE $filter AND $in_period))"); +while ($row = mysqli_fetch_assoc($sql)) { + $owner = intval($row['canned_response_user_id']); + $responses[intval($row['canned_response_id'])] = ['id' => intval($row['canned_response_id']), 'response' => $row['canned_response_name'], 'owner' => $owner, + 'whose' => $owner ? ($row['user_name'] !== null ? $row['user_name'] : 'A removed agent') : 'Shared', 'archived' => !empty($row['canned_response_archived_at']), + 'offered' => 'Every ticket', 'used' => 0, 'tickets' => 0, 'agents' => 0, 'prev' => 0, 'last' => null]; +} +foreach (cannedResponseCategoriesFor(array_keys($responses)) as $id => $categories) { + $responses[$id]['offered'] = implode(', ', array_column($categories, 'category_name')); +} + +// Used in the period, on how many tickets, by how many agents +$sql = mysqli_query($mysqli, "SELECT canned_response_use_canned_response_id AS id, COUNT(*) AS n, COUNT(DISTINCT canned_response_use_ticket_id) AS t, COUNT(DISTINCT canned_response_use_user_id) AS a + FROM $uses WHERE $filter AND $in_period GROUP BY canned_response_use_canned_response_id"); +while ($row = mysqli_fetch_assoc($sql)) { + if (isset($responses[intval($row['id'])])) { + $responses[intval($row['id'])]['used'] = intval($row['n']); + $responses[intval($row['id'])]['tickets'] = intval($row['t']); + $responses[intval($row['id'])]['agents'] = intval($row['a']); + } +} +// The period before, and when each was last used +$sql = mysqli_query($mysqli, "SELECT canned_response_use_canned_response_id AS id, COUNT(*) AS n FROM $uses WHERE $filter AND " . reportRangeSql('canned_response_use_created_at', $period, true) . " GROUP BY canned_response_use_canned_response_id"); +while ($row = mysqli_fetch_assoc($sql)) { + if (isset($responses[intval($row['id'])])) { + $responses[intval($row['id'])]['prev'] = intval($row['n']); + } +} +$sql = mysqli_query($mysqli, "SELECT canned_response_use_canned_response_id AS id, MAX(canned_response_use_created_at) AS last FROM $uses WHERE $filter GROUP BY canned_response_use_canned_response_id"); +while ($row = mysqli_fetch_assoc($sql)) { + if (isset($responses[intval($row['id'])])) { + $responses[intval($row['id'])]['last'] = $row['last']; + } +} +foreach ($responses as &$row) { + $row['change'] = reportChange($row['used'], $row['prev']); +} +unset($row); +uasort($responses, function ($a, $b) { + return $b['used'] <=> $a['used'] ?: strcasecmp($a['response'], $b['response']); +}); +$responses = array_values($responses); + +// By agent: how often each used one, how many different ones, and their own +$agents = []; +$sql = mysqli_query($mysqli, "SELECT canned_response_use_user_id AS id, user_name, user_avatar, COUNT(*) AS n, COUNT(DISTINCT canned_response_use_canned_response_id) AS r, + SUM(canned_response_user_id = canned_response_use_user_id) AS own + FROM $uses LEFT JOIN users ON user_id = canned_response_use_user_id + WHERE $filter AND $in_period GROUP BY canned_response_use_user_id ORDER BY n DESC, user_name ASC"); +while ($row = mysqli_fetch_assoc($sql)) { + $agents[intval($row['id'])] = ['id' => intval($row['id']), 'agent' => $row['user_name'] ?? 'A removed agent', 'avatar' => (string) $row['user_avatar'], + 'used' => intval($row['n']), 'responses' => intval($row['r']), 'own' => intval($row['own']), 'top' => '']; +} +if ($agents) { + // Each agent's most used + $sql = mysqli_query($mysqli, "SELECT canned_response_use_user_id AS id, canned_response_name AS name, COUNT(*) AS n FROM $uses + WHERE $filter AND $in_period GROUP BY canned_response_use_user_id, canned_response_use_canned_response_id ORDER BY n DESC, canned_response_name ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + if (isset($agents[intval($row['id'])]) && $agents[intval($row['id'])]['top'] === '') { + $agents[intval($row['id'])]['top'] = $row['name']; + } + } +} +$agents = array_values($agents); + +$columns = [ + 'response' => ['label' => 'Response', 'html' => function ($row) use ($admin, $session_user_id) { + $name = escapeHtml($row['response']); + if ($admin) { + $link = '/admin/canned_responses.php?' . http_build_query(array_filter(['q' => $row['response'], 'show' => $row['archived'] ? 'archived' : ($row['owner'] ? 'personal' : '')])); + } elseif ($row['owner'] === intval($session_user_id)) { + $link = '/agent/user/user_canned_responses.php?' . http_build_query(['q' => $row['response']]); + } else { + $link = ''; + } + return ($link !== '' ? '' . $name . '' : $name) + . ($row['archived'] ? ' Archived' : ''); + }], + 'whose' => ['label' => 'Whose', 'html' => function ($row) { + return $row['owner'] ? '' . escapeHtml($row['whose']) . '' : 'Shared'; + }], + 'offered' => ['label' => 'Offered on', 'html' => function ($row) { + return '' . escapeHtml($row['offered']) . ''; + }], + 'used' => ['label' => 'Used', 'type' => 'count', 'class' => 'fw-semibold'], + 'change' => ['label' => 'Change', 'type' => 'percent', 'csv' => false, 'html' => function ($row) { + return $row['change'] === null ? '–' : '' . reportChangeHtml($row['used'], $row['prev'], 'up') . ''; + }], + 'tickets' => ['label' => 'Tickets', 'type' => 'count'], + 'agents' => ['label' => 'Agents', 'type' => 'count'], + 'last' => ['label' => 'Last used', 'type' => 'date', 'html' => function ($row) { + return $row['last'] ? '' . timeAgo($row['last']) . '' : 'Never'; + }], +]; +$agent_columns = [ + 'agent' => ['label' => 'Agent', 'html' => function ($row) { + return reportAgentCell($row['id'], $row['agent'], $row['avatar'], 'A removed agent'); + }], + 'used' => ['label' => 'Used', 'type' => 'count', 'class' => 'fw-semibold'], + 'responses' => ['label' => 'Different ones', 'type' => 'count'], + 'own' => ['label' => 'Their own', 'type' => 'count', 'title' => 'Times one of their own went in'], + 'top' => ['label' => 'Most used', 'html' => function ($row) { + return '' . escapeHtml($row['top']) . ''; + }], +]; + +// Across them all - tickets and agents counted once each +$overall = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS n, COUNT(DISTINCT canned_response_use_ticket_id) AS t, COUNT(DISTINCT canned_response_use_user_id) AS a FROM $uses WHERE $filter AND $in_period")); +$previous = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM $uses WHERE $filter AND " . reportRangeSql('canned_response_use_created_at', $period, true)))[0]); +$totals = ['response' => 'Total', 'used' => intval($overall['n']), 'tickets' => intval($overall['t']), 'agents' => intval($overall['a'])]; +$agent_totals = ['agent' => 'Total', 'used' => intval($overall['n']), 'own' => array_sum(array_column($agents, 'own'))]; + +if (reportExporting('agents')) { + reportCsv('Canned Responses by Agent ' . $period['start'] . ' to ' . $period['end'], $agent_columns, $agents, $agent_totals); +} +if (reportExporting()) { + reportCsv('Canned Responses ' . $period['start'] . ' to ' . $period['end'], $columns, $responses, $totals); +} + +require_once "includes/inc_report_layout.php"; + +// The tickets agents replied to in the period, for the share +$replied = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(DISTINCT ticket_reply_ticket_id) FROM ticket_replies JOIN tickets ON ticket_id = ticket_reply_ticket_id + WHERE " . reportTicketSql($client_id) . " AND ticket_reply_type IN ('Public', 'Internal') AND ticket_reply_archived_at IS NULL" + . ($agent_id ? " AND ticket_reply_by = $agent_id" : '') . " AND " . reportRangeSql('ticket_reply_created_at', $period)))[0]); +$current = array_filter($responses, function ($r) { return !$r['archived']; }); +$used = count(array_filter($current, function ($r) { return $r['used'] > 0; })); +$unused = count($current) - $used; +$top = array_slice(array_filter($responses, function ($r) { return $r['used'] > 0; }), 0, 12); + +?> + +
+ + $period, + 'years' => reportPastYears("SELECT DISTINCT YEAR(canned_response_use_created_at) AS y FROM canned_response_uses"), + 'client' => $client_id, + 'agent' => $agent_id, + 'export' => ['' => 'By response', 'agents' => 'By agent'], + 'print_meta' => $client_id ? reportClientName($client_id) : '', + ]); ?> + + 'Times used', 'value' => reportCount($totals['used']), 'sub' => $previous || $totals['used'] ? reportChangeHtml($totals['used'], $previous, 'up', 'the period before') : ''], + ['label' => 'Tickets they went into', 'value' => reportCount($totals['tickets']), 'sub' => $replied ? reportPercent(reportShare(min($totals['tickets'], $replied), $replied)) . ' of the tickets agents replied to' : ''], + ['label' => 'Responses used', 'value' => $used . ' of ' . count($current), 'sub' => $unused ? $unused . ' not used in this period' : 'Every one'], + ['label' => 'Most used', 'value' => $top ? $top[0]['response'] : '-', 'sub' => $top ? $top[0]['used'] . ' time' . ($top[0]['used'] === 1 ? '' : 's') . ' · ' . reportPercent(reportShare($top[0]['used'], $totals['used'])) . ' of them' : ''], + ]) ?> + + + +
+ 'bar', 'horizontal' => true, 'labels' => array_column($top, 'response'), + 'series' => [['label' => 'Used', 'tone' => 'accent', 'data' => array_column($top, 'used')]], 'format' => 'count'], '', 'Times each was used') ?> +
+
+ + + + 'There are no canned responses yet.', 'totals' => $totals]) ?> + +
+ + + 'Nobody used a canned response in this period.', 'totals' => $agent_totals]) ?> + + + + + + diff --git a/agent/ticket.php b/agent/ticket.php index 42244d3ae..aed9e4e0d 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -749,13 +749,15 @@ if (isset($_GET['ticket_id'])) {
- +
diff --git a/agent/user/includes/user_side_nav.php b/agent/user/includes/user_side_nav.php index 222851fcc..14e75c784 100644 --- a/agent/user/includes/user_side_nav.php +++ b/agent/user/includes/user_side_nav.php @@ -30,6 +30,10 @@ 'user_preferences.php' => ['fa-sliders-h', 'Preferences'], 'user_activity.php' => ['fa-history', 'Activity'], ]; + // Your own canned responses - for those who reply on tickets + if (intval(lookupUserPermission('module_support')) >= 2) { + $account_pages = array_slice($account_pages, 0, 3, true) + ['user_canned_responses.php' => ['fa-comment-dots', 'Canned Responses']] + $account_pages; + } foreach ($account_pages as $page => [$icon, $label]) { ?> +
@@ -113,6 +120,36 @@ $minutes = intval($r['canned_response_minutes'] ?? 0);
+
+

Copied onto the ticket with the reply it goes into, and sent with its email - a how-to, a form to fill in. Up to ; the agent can leave any of them off.

+ + + + + + +
+ + "> +
+ The kinds a ticket takes - PDFs, documents, spreadsheets, images, zips. +
+ diff --git a/js/canned_response_form.js b/js/canned_response_form.js index f49ecf415..553a95902 100644 --- a/js/canned_response_form.js +++ b/js/canned_response_form.js @@ -1,5 +1,5 @@ /* - * The canned response window (admin/modals/canned_response/canned_response_form.php): + * The canned response window (includes/canned_response_form.php): * a placeholder under the editor goes in where the cursor is. The window is an * ajax modal that runs this each time it opens, so the listener is bound once. */ diff --git a/uploads/canned_responses/index.php b/uploads/canned_responses/index.php new file mode 100644 index 000000000..e69de29bb