From 3ee183a09a331df9596343af1801959dc5da9c10 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 18 Oct 2026 19:47:57 -0400 Subject: [PATCH] Document templates stage 2, filled in from the client: placeholders [Client], [Website], [Site], [Address], [Site Phone], [Contact], [First Name], [Contact Email], [Contact Phone], [Agent], [Date], [Month], [Quarter] and [Year] are filled in across a template's name, summary and content when a document is made, using the client's primary site and contact unless another or none is picked; tables of records [Contacts], [Locations], [Assets], [Servers], [Workstations], [Network Equipment], [Printers], [Networks], [Domains], [Certificates], [Licenses], [Vendors] and [Services] put in the client's current records, as a table on a line of their own (an empty row when there are none) or as names in a sentence, all made safe; any other bracketed word is a fill-in asked for when a document is made, set up on the template page as text, date, list or yes/no, required or with help, renamed along with the template text, reset or removed, and dragged into order, with answers checked by ticketFillInAnswers and blanks left in the document; the template page gets Fill-ins and Filled in from the client cards with each kind of word marked in its own colour, and the editor gets a button for each placeholder and table to insert it at the cursor (js/document_template_form.js); New document for a client and Document from template draw the site and contact pickers and fill-ins once the template and client are picked (agent/ajax.php document_template_asks, agent/js/document_template_make.js) and open larger; copying a template copies its fill-ins; Draft it with AI is told the placeholders and tables; migration 3.2.6 adds document_template_fill_ins; fixed the fill-in add and reset messages showing raw HTML around the bracketed name on document and ticket templates; changelog updated --- CHANGELOG.md | 18 +- admin/database_updates/3.2.6.php | 24 + admin/document_template.php | 123 ++++- admin/document_templates.php | 2 +- .../document_template_fill_in.php | 118 +++++ .../document_template_form.php | 18 +- .../document_template_use.php | 18 +- admin/post/document_template.php | 129 +++++- admin/post/ticket_template.php | 4 +- agent/ajax.php | 76 +++- agent/files.php | 2 +- agent/js/document_template_make.js | 68 +++ .../document/document_add_from_template.php | 15 +- agent/post/document.php | 15 +- db.sql | 22 + functions/document_template.php | 429 +++++++++++++++++- js/document_template_form.js | 23 + 17 files changed, 1054 insertions(+), 50 deletions(-) create mode 100644 admin/database_updates/3.2.6.php create mode 100644 admin/modals/document_template/document_template_fill_in.php create mode 100644 agent/js/document_template_make.js create mode 100644 js/document_template_form.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e80b8c8..034956f42 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.5) 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.6) in the same pass. Nothing else is required. -This is a big release, with forty-six database steps in one go, so take a backup before you update. +This is a big release, with forty-seven 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: @@ -47,7 +47,7 @@ Ticket templates keep their subject, details and tasks, and set nothing else on 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. -Document templates keep their names, summaries, what they say and their images. Documents made from a template before the update don't know which one, so a template's use is counted from the update. +Document templates keep their names, summaries, what they say and their images. Words in square brackets already in them are now filled in when a document is made: [Client], [Site], [Contact], [Date] and the others listed under New Features (Document templates) from the client, and the rest are asked for as plain, optional text until you set them up on the template's page - one left blank stays as it is, as before. Documents made from a template before the update don't know which one, so a template's use 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. @@ -527,9 +527,13 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The New Canned Response button's menu has Import, Export, the report and the starter ones. - **Document templates** (rebuilt) - Admin > Document Templates lists what each is for, how many documents have been made from it and for how many clients, when it was last used and when it last changed, and by whom - searched by name, summary or what it says, sorted from the column headings, a page at a time, with Active and Archived tabs. - - A template's page shows what it says, with the words in square brackets left to fill in marked and listed, who made it and last changed it, its images, and the latest documents made from it with their client. New document for a client, from there or the list, makes one for the client you pick and opens it. + - A template's page shows what it says, with its words in square brackets marked - filled in from the client, a table of records, or asked for - who made it and last changed it, its images, and the latest documents made from it with their client. New document for a client, from there or the list, makes one for the client you pick and opens it. + - Filled in from the client: [Client], [Website], [Site], [Address], [Site Phone], [Contact], [First Name], [Contact Email], [Contact Phone], [Agent], [Date], [Month], [Quarter] and [Year] in a template's name, summary and what it says are filled in when a document is made from it. The site and contact are the client's primary ones, unless you pick another - or none, to leave them to fill in - when you make it. One with nothing to put in (a client with no website) stays as it is. + - Tables of records: [Contacts], [Locations], [Assets], [Servers], [Workstations], [Network Equipment], [Printers], [Networks], [Domains], [Certificates], [Licenses], [Vendors] and [Services] put in the client's records as they are when the document is made. On a line of its own, it becomes a table - [Networks] gives each network's VLAN, network, gateway, DHCP range and site, [Servers] each server's operating system, IP address and site, [Domains] each domain's registrar and expiry - with an empty row to fill in when the client has none. In a sentence, it becomes their names. The template's page lists the ones it uses and what each holds, and the template window has a button for each placeholder and table to put it in. + - Fill-ins: any other word in square brackets in a template's name, summary or what it says - [Firewall Model], [Review Date] - is asked for when a document is made, and the answer goes in wherever it is. On the template's page each fill-in can be made a date, a choice from a list or yes or no, made required, given a line of help, renamed (it is renamed in the template too) and dragged into the order it is asked in. One left blank stays for whoever writes the document. + - New document for a client and Document from template show what the template fills in from the client, the site and contact to use, and its fill-ins, as soon as the template and client are picked. A copied template keeps its fill-ins. - Templates can be copied (with their images), archived - not offered for new documents, while the documents made from them stay - restored and deleted. A document made from one remembers it, and its page says which template it came from. - - The template window has its icons, a summary, and Draft it with AI when an AI model is set up for documentation. + - The template window has its icons, a summary, and Draft it with AI when an AI model is set up for documentation. A draft uses the placeholders and tables of records, and puts what else is to be filled in in square brackets. - Document from template in a client's Files shows each template's summary and how often it has been used. A document made with no name or summary given takes the template's. - **Assets** - The asset list has: @@ -1509,6 +1513,10 @@ If you are coming from 26.08 or earlier, the database update still has to be run - New `functions/document_template.php`: `documentTemplateDir()`, `documentTemplateRow($id)` (with `created_by_name` and `updated_by_name`), `documentTemplateUsage($ids)` (`count`, `clients`, `last` - documents not archived), `documentTemplateFields($post, &$error)` (`name`, `description`, `content`), `documentTemplateSave($id, $fields)` (images through `saveBase64Images()`, the unused ones through `cleanupUnusedImages()`; a new one's first save leaves `document_template_updated_at` empty), `documentTemplateCopyImages()`, `documentTemplateCopy($id)`, `documentTemplateDelete($id)` (clears the template from its documents and takes its folder away), `documentTemplateMakeDocument($id, $client_id, $name, $description, $folder_id)` (the template's name and summary when none given), `documentTemplatePreview()`, `documentTemplateBracketsIn()` and `documentTemplateMarkBrackets()`. - Admin: `admin/document_templates.php` takes `q`, `sort` - `document_template_name`, `used_count`, `last_used` or `changed_at` - `order`, `page` and `show=archived`. `admin/document_template.php` is rebuilt on the ticket template page's layout. `admin/modals/document_template/document_template_form.php` is shared by `document_template_add.php` and `document_template_edit.php` (`id`); new `document_template_use.php` (`id`) posts `add_document_from_template` to `agent/post.php`. `admin/post/document_template.php`: `add_document_template` (redirects to its page), `edit_document_template`, GET `copy_document_template`, `archive_document_template`, `restore_document_template` and `delete_document_template` (`from_page`). Audit entries are Document Template Create, Edit, Archive, Restore and Delete. - `add_document_from_template` makes the document with `documentTemplateMakeDocument()`, checks the client exists and redirects to `/agent/document.php`. `agent/modals/document/document_add_from_template.php` takes `document_template_id` to pick one, and shows each one's use. + - Database 3.2.6: new `document_template_fill_ins` (`document_fill_in_id`, `document_fill_in_document_template_id` - deleted with its template - `document_fill_in_name`, unique per template, `document_fill_in_type` - `text`, `date`, `list` or `yesno` - `document_fill_in_options`, one per line, `document_fill_in_required`, `document_fill_in_help` and `document_fill_in_order`). + - `functions/document_template.php`: `DOCUMENT_TEMPLATE_PLACEHOLDERS`, `DOCUMENT_TEMPLATE_SITE_PLACEHOLDERS`, `DOCUMENT_TEMPLATE_CONTACT_PLACEHOLDERS` and `DOCUMENT_TEMPLATE_TABLES` (name => what it is); `documentFillInName()` ('' for a placeholder's or table's name), `documentTemplateWordKind($word)` (`placeholder`, `table` or `fill_in`), `documentTemplateBracketsInAll($template)` and `documentTemplateWordsUsed($template)` (its name, summary and content), `documentTemplateFillInRowsFor($ids)`, `documentTemplateFillIns($template, $rows)` and `documentTemplateFillInsFor($id)` (the ones set up, then the rest in the text as optional text, each with `in_text`) - in the shape `ticketFillInAnswers()` and `ticketFillInFieldHtml()` take - `documentTemplateRenameFillIn()`, `documentTemplateClientLocations()` and `documentTemplateClientContacts()` (primary first), `documentTemplateValues($client_id, $location_id, $contact_id, $agent_name)` (-1 for none, 0 or another client's for the primary), `documentTemplateTableData($table, $client_id)` (`head`, `rows`, `names`), `documentTemplateTableHtml()` and `documentTemplateFill($html, $client_id, $values)`. `documentTemplateMakeDocument()` takes `$location_id`, `$contact_id` and `$answers` (from `ticketFillInAnswers()`) after `$folder_id`, and fills the name, summary and content. `documentTemplateCopy()` copies the fill-ins, and `documentTemplateMarkBrackets()` marks each kind in its own color. + - Admin: new `admin/modals/document_template/document_template_fill_in.php` (`document_template_id`, `name`). `admin/post/document_template.php`: `save_document_template_fill_in` (`original_name` to change or rename one), GET `delete_document_template_fill_in` (reset while still in the text, otherwise removed) and POST `reorder_document_template_fill_ins` (`names[]`, answers JSON). Audit entries are Document Template Edit. + - `agent/ajax.php?document_template_asks=ID&client_id=ID` returns `{html}` - what the template fills in, the `location_id` and `contact_id` pickers when it uses them, and its fill-ins as `fill_ins[Name]` - or '' without Support at edit level, access to the client, or a live template. `add_document_from_template` checks the answers with `ticketFillInAnswers()` and takes `location_id` and `contact_id`. New `agent/js/document_template_make.js` draws it in a form with `data-doc-template-make`, from its `data-doc-template-select` and `data-doc-template-client` fields into `data-doc-template-asks`; new `js/document_template_form.js` puts a `data-document-template-word` button's word in the editor (on a line of its own with `data-document-template-table`). `ai_create_document_template` tells the model the placeholders and tables. - 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/database_updates/3.2.6.php b/admin/database_updates/3.2.6.php new file mode 100644 index 000000000..fabafa2ae --- /dev/null +++ b/admin/database_updates/3.2.6.php @@ -0,0 +1,24 @@ + 0, 'clients' => 0, 'last' => null]; $content = trim((string) $template['document_template_content']) !== '' ? documentTemplateMarkBrackets(documentHtmlPurify($template['document_template_content'])) : ''; -$brackets = documentTemplateBracketsIn($template['document_template_content']); +$used = documentTemplateWordsUsed($template); +$fill_ins = documentTemplateFillIns($template, documentTemplateFillInRowsFor([$document_template_id])[$document_template_id]); +$fill_in_url = "modals/document_template/document_template_fill_in.php?document_template_id=$document_template_id"; $images = is_dir(documentTemplateDir($document_template_id)) ? count(glob(documentTemplateDir($document_template_id) . '/*')) : 0; $csrf = $_SESSION['csrf_token']; @@ -64,7 +68,7 @@ $use_url = "modals/document_template/document_template_use.php?id=$document_temp
- +
- -
-

Left to fill in

-
-
- - [] +
+
+

Fill-ins

+ + +
+
+ +

Asked for when a document is made from it - each answer goes in wherever its [Name] is, and one left blank stays for whoever writes it. Drag to change the order they are asked in.

+
+ +
+ +
+
+ + + Required + Not in the text +
+
+
+ +
-
Words in square brackets stay in each document made from it, for whoever writes it to fill in.
+ +
Nothing to ask. Put a word in square brackets in what it says - [Firewall Model], [Review Date] - and it is asked for when a document is made from it, then filled in. Add one here to make it a date, a choice from a list or yes or no, or to make it required.
+ +
+
+ + +
+

Filled in from the client

+
+ +
+ $word) { ?> + [] + +
+ +
The - the client's primary, unless another is picked when the document is made.
+ + + +
The client's records, as they are then
+
    + $word) { ?> +
  • []
  • + +
+
@@ -158,6 +230,29 @@ $use_url = "modals/document_template/document_template_use.php?id=$document_temp
+ + Edit - + New document for a client diff --git a/admin/modals/document_template/document_template_fill_in.php b/admin/modals/document_template/document_template_fill_in.php new file mode 100644 index 000000000..c6b8c8a8d --- /dev/null +++ b/admin/modals/document_template/document_template_fill_in.php @@ -0,0 +1,118 @@ + + + + + + +
+ + + + + + + + +
+ + + +What it says
-
Words in square brackets - [Client], [Date] - are left in the document for whoever writes it to fill in. Pasted images are kept with the template and copied into each document made from it.
+
Filled in from the client as a document is made - click one to put it where the cursor is:
+
+ $means) { ?> + + +
+
The client's records as a table, on a line of their own - as they are when the document is made:
+
+ $holds) { ?> + + +
+
Any other word in square brackets - [Firewall Model] - is asked for when a document is made, and set up on the template's page. Pasted images are kept with the template and copied into each document made from it.
+ + Document Templates) - making, changing, - * copying, archiving and deleting them, with their images. The work is in + * copying, archiving and deleting them, with their images, and setting up + * what they ask for when a document is made (their fill-ins). The work is in * functions/document_template.php; a document is made from one by * add_document_from_template (agent/post/document.php). */ @@ -95,3 +96,129 @@ if (isset($_GET['delete_document_template'])) { redirect(!empty($_GET['from_page']) ? 'document_templates.php' : null); } + +if (isset($_POST['save_document_template_fill_in'])) { + + // A fill-in set up - a new one, or one in the text or already set up, changed + validateCSRFToken(); + + $row = documentTemplateForRequest($_POST['document_template_id'] ?? 0); + $document_template_id = intval($row['document_template_id']); + $original = trim((string) ($_POST['original_name'] ?? '')); + + $name = documentFillInName($_POST['name'] ?? ''); + if ($name === '') { + flashAlert("A fill-in's name is what goes in square brackets - a letter first, then letters, digits, spaces, _ - or /, up to 41 in all. [Client], [Site] and the others filled in from the client, and the tables of records, are taken.", 'error'); + redirect(); + } + $type = isset(TICKET_FILL_IN_TYPES[$_POST['type'] ?? '']) ? $_POST['type'] : 'text'; + $options = $type === 'list' ? ticketFillInOptions($_POST['options'] ?? '') : []; + if ($type === 'list' && count($options) < 2) { + flashAlert('A list needs at least two choices - one per line.', 'error'); + redirect(); + } + $required = intval($_POST['required'] ?? 0) ? 1 : 0; + $help = trim(mb_substr(preg_replace('/\s+/', ' ', strip_tags((string) ($_POST['help'] ?? ''))), 0, 255)); + + // One name, one fill-in - adding one already in the text but not set up yet sets that one up + foreach (documentTemplateFillInsFor($document_template_id) as $fill_in) { + if ($original === '' && !$fill_in['id'] && strtolower($fill_in['name']) === strtolower($name)) { + $original = $fill_in['name']; + continue; + } + if (strtolower($fill_in['name']) === strtolower($name) && strtolower($fill_in['name']) !== strtolower($original)) { + flashAlert("" . escapeHtml($row['document_template_name']) . " already asks for " . escapeHtml($fill_in['name']) . ".", 'error'); + redirect(); + } + } + + $existing = $original !== '' ? mysqli_fetch_row(mysqli_query($mysqli, "SELECT document_fill_in_id FROM document_template_fill_ins + WHERE document_fill_in_document_template_id = $document_template_id AND document_fill_in_name = '" . escapeSql($original) . "' LIMIT 1")) : null; + + // Renamed: its [Name] in what the template says goes with it + if ($original !== '' && $original !== $name) { + documentTemplateRenameFillIn($document_template_id, $original, $name); + } + $set = "document_fill_in_name = '" . escapeSql($name) . "', document_fill_in_type = '$type', document_fill_in_options = " . ($options ? "'" . escapeSql(implode("\n", $options)) . "'" : 'NULL') . ", + document_fill_in_required = $required, document_fill_in_help = " . ($help !== '' ? "'" . escapeSql($help) . "'" : 'NULL'); + if ($existing) { + mysqli_query($mysqli, "UPDATE document_template_fill_ins SET $set WHERE document_fill_in_id = " . intval($existing[0])); + } else { + $order = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(MAX(document_fill_in_order), 0) + 1 FROM document_template_fill_ins WHERE document_fill_in_document_template_id = $document_template_id"))[0]); + mysqli_query($mysqli, "INSERT INTO document_template_fill_ins SET document_fill_in_document_template_id = $document_template_id, document_fill_in_order = $order, $set"); + } + + logAudit("Document Template", "Edit", "$session_name " . ($original === '' ? 'added' : 'changed') . " fill-in " . escapeSql($name) . " on document template " . escapeSql($row['document_template_name']), 0, $document_template_id); + + flashAlert("Fill-in " . escapeHtml($name) . " saved" . ($original === '' ? " - put [" . escapeHtml($name) . "] in what it says where the answer goes" : '')); + + redirect(); + +} + +if (isset($_GET['delete_document_template_fill_in'])) { + + // A fill-in's setup removed - gone, or, while its [Name] is still in the text, asked as plain optional text + validateCSRFToken(); + + $fill_in_id = intval($_GET['delete_document_template_fill_in']); + $fill_in = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT document_fill_in_name, document_fill_in_document_template_id FROM document_template_fill_ins WHERE document_fill_in_id = $fill_in_id")); + $row = documentTemplateForRequest($fill_in['document_fill_in_document_template_id'] ?? 0); + $document_template_id = intval($row['document_template_id']); + $name = (string) $fill_in['document_fill_in_name']; + + mysqli_query($mysqli, "DELETE FROM document_template_fill_ins WHERE document_fill_in_id = $fill_in_id"); + + $still_asked = false; + foreach (documentTemplateFillInsFor($document_template_id) as $other) { + $still_asked = $still_asked || strtolower($other['name']) === strtolower($name); + } + + logAudit("Document Template", "Edit", "$session_name removed fill-in " . escapeSql($name) . " from document template " . escapeSql($row['document_template_name']), 0, $document_template_id); + + flashAlert($still_asked + ? "Fill-in " . escapeHtml($name) . " reset - [" . escapeHtml($name) . "] is still in what it says, so it is asked for as plain, optional text" + : "Fill-in " . escapeHtml($name) . " removed", 'error'); + + redirect(); + +} + +if (isset($_POST['reorder_document_template_fill_ins'])) { + + // Dragged into order on the template's page - answers with JSON, as the page saves it in the background. + // Every one dragged keeps its place from now on, so each gets a row. + header('Content-Type: application/json'); + if (!hash_equals((string) ($_SESSION['csrf_token'] ?? ''), (string) ($_POST['csrf_token'] ?? ''))) { + echo json_encode(['ok' => false, 'error' => 'The page is out of date - reload it']); + exit; + } + $row = documentTemplateRow($_POST['document_template_id'] ?? 0); + if (!$row) { + echo json_encode(['ok' => false, 'error' => 'That document template no longer exists']); + exit; + } + $document_template_id = intval($row['document_template_id']); + $fill_ins = []; + foreach (documentTemplateFillInsFor($document_template_id) as $fill_in) { + $fill_ins[strtolower($fill_in['name'])] = $fill_in; + } + $order = 0; + foreach ((array) ($_POST['names'] ?? []) as $name) { + $fill_in = $fill_ins[strtolower((string) $name)] ?? null; + if (!$fill_in) { + continue; + } + $order++; + if ($fill_in['id']) { + mysqli_query($mysqli, "UPDATE document_template_fill_ins SET document_fill_in_order = $order WHERE document_fill_in_id = " . intval($fill_in['id'])); + } else { + mysqli_query($mysqli, "INSERT INTO document_template_fill_ins SET document_fill_in_document_template_id = $document_template_id, document_fill_in_name = '" . escapeSql($fill_in['name']) . "', document_fill_in_type = 'text', document_fill_in_order = $order"); + } + } + logAudit("Document Template", "Edit", "$session_name reordered the fill-ins on document template " . escapeSql($row['document_template_name']), 0, $document_template_id); + + echo json_encode(['ok' => true]); + exit; + +} diff --git a/admin/post/ticket_template.php b/admin/post/ticket_template.php index 53caf0626..539d64a30 100644 --- a/admin/post/ticket_template.php +++ b/admin/post/ticket_template.php @@ -250,7 +250,7 @@ if (isset($_POST['save_ticket_template_fill_in'])) { logAudit("Ticket Template", "Edit", "$session_name " . ($original === '' ? 'added' : 'changed') . " fill-in " . escapeSql($name) . " on ticket template $template_name", 0, $ticket_template_id); - flashAlert("Fill-in " . escapeHtml($name) . " saved" . ($original === '' ? " - put [" . escapeHtml($name) . "] in the text where the answer goes" : '')); + flashAlert("Fill-in " . escapeHtml($name) . " saved" . ($original === '' ? " - put [" . escapeHtml($name) . "] in the text where the answer goes" : '')); redirect(); @@ -278,7 +278,7 @@ if (isset($_GET['delete_ticket_template_fill_in'])) { logAudit("Ticket Template", "Edit", "$session_name removed fill-in " . escapeSql($name) . " from ticket template " . escapeSql($row['ticket_template_name']), 0, $ticket_template_id); flashAlert($still_asked - ? "Fill-in " . escapeHtml($name) . " reset - [" . escapeHtml($name) . "] is still in the text, so it is asked for as plain, optional text" + ? "Fill-in " . escapeHtml($name) . " reset - [" . escapeHtml($name) . "] is still in the text, so it is asked for as plain, optional text" : "Fill-in " . escapeHtml($name) . " removed", 'error'); redirect(); diff --git a/agent/ajax.php b/agent/ajax.php index 49d04df70..96680d372 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -1025,6 +1025,72 @@ if (isset($_GET['global_search'])) { * than rendered into every ticket page, because the bodies are full HTML replies and an * install can have a lot of them. */ +/* + * What making a document from a template asks for, for a client - the site + * and contact its placeholders come from (when it uses them; the client's + * primary ones picked), and its fill-ins - as the window's fields (HTML), with + * a line on what is filled in from the client. agent/js/document_template_make.js + * draws it in the Document from template and New document for a client windows. + */ +if (isset($_GET['document_template_asks'])) { + header('Content-Type: application/json'); + + $asks_template = documentTemplateRow($_GET['document_template_asks']); + $asks_client_id = intval($_GET['client_id'] ?? 0); + if (intval(lookupUserPermission('module_support')) < 2 || !$asks_template || $asks_template['document_template_archived_at'] || !$asks_client_id || !hasClientAccess($asks_client_id)) { + echo json_encode(['html' => '']); + exit; + } + $asks_used = documentTemplateWordsUsed($asks_template); + $asks_fill_ins = documentTemplateFillIns($asks_template, documentTemplateFillInRowsFor([$asks_template['document_template_id']])[$asks_template['document_template_id']]); + $asks_site = array_intersect(array_keys($asks_used['placeholder']), DOCUMENT_TEMPLATE_SITE_PLACEHOLDERS); + $asks_contact = array_intersect(array_keys($asks_used['placeholder']), DOCUMENT_TEMPLATE_CONTACT_PLACEHOLDERS); + + $html = ''; + if ($asks_used['placeholder'] || $asks_used['table']) { + $filled = array_map(function ($word) { + return '[' . $word . ']'; + }, array_values($asks_used['placeholder'] + $asks_used['table'])); + $html .= '
Filled in from the client: ' . escapeHtml(implode(' ', $filled)) . '' + . ($asks_used['table'] ? ' - its records as they are now' : '') . '
'; + } + if ($asks_site || $asks_contact) { + $html .= '
'; + if ($asks_site) { + $html .= '
' + . '
'; + } + if ($asks_contact) { + $html .= '
' + . '
'; + } + $html .= '
'; + } + if ($asks_fill_ins) { + $html .= '
It asks for
' + . '
Each answer goes in wherever its [Name] is. Leave one blank to fill it in on the document later.
'; + foreach ($asks_fill_ins as $fill_in) { + $html .= '
' . ticketFillInFieldHtml($fill_in, '') . '
'; + } + $html .= '
'; + } + + echo json_encode(['html' => $html !== '' ? '
' . $html . '
' : '']); + exit; +} + if (isset($_GET['get_canned_response'])) { enforceUserPermission('module_support'); @@ -1939,7 +2005,15 @@ if (isset($_GET['ai_create_document_template'])) { } // Prepare prompt - $system_message = "You are a helpful IT documentation assistant. You will create a well-structured HTML template for IT documentation based on a given prompt. Include headings, subheadings, bullet points, and possibly tables for clarity. No Lorem Ipsum, use realistic placeholders and professional language."; + // It writes in the words a document template fills in from the client, and asks for the rest + $system_message = "You are a helpful IT documentation assistant. You will create a well-structured HTML template for IT documentation based on a given prompt. Include headings, subheadings, bullet points, and possibly tables for clarity. No Lorem Ipsum, professional language." + . " These words in square brackets are filled in from the client when a document is made from the template - use them where those details belong: " . implode(', ', array_map(function ($word) { + return "[$word]"; + }, array_keys(DOCUMENT_TEMPLATE_PLACEHOLDERS))) . "." + . " These put in the client's records, as a table when on a line of their own (

[Networks]

) and as a list of names in a sentence - use them instead of drawing those tables yourself: " . implode(', ', array_map(function ($word) { + return "[$word]"; + }, array_keys(DOCUMENT_TEMPLATE_TABLES))) . "." + . " For anything else someone fills in, use a short name of its own in square brackets, like [Firewall Model] or [Backup Schedule] - letters, digits and spaces, starting with a letter."; $user_message = "Create an HTML formatted IT documentation template based on the following request:\n\n\"$prompt\"\n\nThe template should be structured, professional, and useful for IT staff. Include relevant sections, instructions, prerequisites, and best practices."; $result = callAiApi($model, [ diff --git a/agent/files.php b/agent/files.php index f40b33b36..3b595b0d5 100644 --- a/agent/files.php +++ b/agent/files.php @@ -324,7 +324,7 @@ $preview = []; // the grid's previewer payload New document - + Document from template diff --git a/agent/js/document_template_make.js b/agent/js/document_template_make.js new file mode 100644 index 000000000..b1f1116cd --- /dev/null +++ b/agent/js/document_template_make.js @@ -0,0 +1,68 @@ +/* + * Making a document from a template - the Document from template window + * (agent/modals/document/document_add_from_template.php) and New document for + * a client (admin/modals/document_template/document_template_use.php). When + * the template or the client is picked, what it asks for - the site and + * contact its placeholders come from, and its fill-ins - is drawn in the + * form's [data-doc-template-asks] box (agent/ajax.php document_template_asks). + * + * The windows are ajax modals that run this each time they open: each form is + * drawn as it opens, and the change listener is bound once. + */ +(function () { + + function draw(form) { + const box = form.querySelector('[data-doc-template-asks]'); + const template = form.querySelector('[data-doc-template-select]'); + const client = form.querySelector('[data-doc-template-client]'); + if (!box || !template || !client) { + return; + } + const templateId = parseInt(template.value, 10) || 0; + const clientId = parseInt(client.value, 10) || 0; + const key = templateId + ':' + clientId; + if (box.dataset.drawn === key) { + return; + } + box.dataset.drawn = key; + if (!templateId || !clientId) { + box.innerHTML = ''; + box.hidden = true; + return; + } + fetch('/agent/ajax.php?document_template_asks=' + templateId + '&client_id=' + clientId, { credentials: 'same-origin' }) + .then(function (response) { + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + return response.json(); + }) + .then(function (data) { + // Picked again meanwhile - the later answer draws it + if (box.dataset.drawn !== key) { + return; + } + box.innerHTML = data.html || ''; + box.hidden = !data.html; + }) + .catch(function () { + box.innerHTML = ''; + box.hidden = true; + }); + } + + document.querySelectorAll('form[data-doc-template-make]').forEach(draw); + + if (window.itflowDocumentTemplateMakeBound) { + return; + } + window.itflowDocumentTemplateMakeBound = true; + + document.addEventListener('change', function (e) { + const field = e.target.closest ? e.target.closest('[data-doc-template-select], [data-doc-template-client]') : null; + const form = field ? field.closest('form[data-doc-template-make]') : null; + if (form) { + draw(form); + } + }); +})(); diff --git a/agent/modals/document/document_add_from_template.php b/agent/modals/document/document_add_from_template.php index 92fafdb96..cfa20d99d 100644 --- a/agent/modals/document/document_add_from_template.php +++ b/agent/modals/document/document_add_from_template.php @@ -4,7 +4,9 @@ * New document from one of the admin's document templates - its content and * its images, copied, in the folder it was opened from (add_document_from_template, * functions/document_template.php). Each template is offered with its summary - * and how often it has been used; ?document_template_id picks one. + * and how often it has been used; ?document_template_id picks one. What the + * one picked asks for - the site and contact its placeholders come from, and + * its fill-ins - is drawn under it (agent/js/document_template_make.js). */ require_once '../../../includes/modal_header.php'; @@ -33,9 +35,9 @@ ob_start();
-
+ - + + + +
@@ -100,5 +105,7 @@ ob_start();
+ + /. A document made from one * gets a copy of what it says and its images, and remembers which template it * came from (documents.document_template_id, from database 3.2.5 on), so each - * template's use is counted. Words in square brackets - [Client], [Date] - are - * left for whoever writes the document to fill in. + * template's use is counted. + * + * As a document is made it is filled in: placeholders from the client - + * [Client], [Site], [Contact], [Date] and the rest (DOCUMENT_TEMPLATE_PLACEHOLDERS), + * the site and contact the ones picked - tables of the client's records as + * they are then - [Networks], [Servers], [Domains] (DOCUMENT_TEMPLATE_TABLES) - + * and the answers to what it asks for: any other [word] is a fill-in, asked + * as plain text unless set up on the template's page as a date, a choice from + * a list or yes or no, required or with help (document_template_fill_ins, + * from database 3.2.6 on). One left unanswered stays in the document. * * Shared by admin/document_templates.php, admin/document_template.php, * admin/modals/document_template/*, admin/post/document_template.php and the @@ -116,7 +124,7 @@ function documentTemplateCopyImages($document_template_id, $to_dir, $to_web, $ht return str_replace("/uploads/document_templates/$document_template_id/", $to_web, (string) $html); } -/* A new template starting from this one - "Copy of", with copies of its images. Its id, or 0. */ +/* A new template starting from this one - "Copy of", with copies of its images and its fill-ins. Its id, or 0. */ function documentTemplateCopy($document_template_id) { global $mysqli, $session_user_id; $row = documentTemplateRow($document_template_id); @@ -129,6 +137,10 @@ function documentTemplateCopy($document_template_id) { $new_id = intval(mysqli_insert_id($mysqli)); $content = documentTemplateCopyImages($row['document_template_id'], documentTemplateDir($new_id), "/uploads/document_templates/$new_id/", $row['document_template_content']); mysqli_query($mysqli, "UPDATE document_templates SET document_template_content = '" . mysqli_real_escape_string($mysqli, $content) . "', document_template_updated_at = NULL WHERE document_template_id = $new_id"); + // And what it asks for + mysqli_query($mysqli, "INSERT INTO document_template_fill_ins (document_fill_in_document_template_id, document_fill_in_name, document_fill_in_type, document_fill_in_options, document_fill_in_required, document_fill_in_help, document_fill_in_order) + SELECT $new_id, document_fill_in_name, document_fill_in_type, document_fill_in_options, document_fill_in_required, document_fill_in_help, document_fill_in_order + FROM document_template_fill_ins WHERE document_fill_in_document_template_id = " . intval($row['document_template_id'])); return $new_id; } @@ -144,26 +156,38 @@ function documentTemplateDelete($document_template_id) { /* * Make a client's document from a template - what it says and its images, * copied, in $folder_id, called $name (the template's when '') - remembering - * the template. The new document's id, or 0 when the template is gone or - * archived. + * the template. Filled in for the client (documentTemplateFill()): its + * placeholders - the site and contact the ones picked ($location_id, + * $contact_id), or the client's primary ones - its tables of records, and + * the answers to its fill-ins ($answers, from ticketFillInAnswers()); the + * name and summary too. The new document's id, or 0 when the template is gone + * or archived. */ -function documentTemplateMakeDocument($document_template_id, $client_id, $name = '', $description = '', $folder_id = 0) { - global $mysqli, $session_user_id; +function documentTemplateMakeDocument($document_template_id, $client_id, $name = '', $description = '', $folder_id = 0, $location_id = 0, $contact_id = 0, array $answers = []) { + global $mysqli, $session_user_id, $session_name; $row = documentTemplateRow($document_template_id); if (!$row || $row['document_template_archived_at']) { return 0; } $template_id = intval($row['document_template_id']); $client_id = intval($client_id); - $name = trim((string) $name) !== '' ? mb_substr(trim((string) $name), 0, 200) : (string) $row['document_template_name']; - $description = trim((string) $description) !== '' ? trim((string) $description) : (string) $row['document_template_description']; + $values = documentTemplateValues($client_id, $location_id, $contact_id, stripslashes((string) ($session_name ?? ''))); + foreach ($answers as $answer) { + $text = ticketFillInText($answer); + if ($text !== '') { + $values[strtolower($answer['name'])] = $values[strtolower($answer['name'])] ?? $text; + } + } + $name = mb_substr(ticketPlaceholderFill(trim((string) $name) !== '' ? trim((string) $name) : (string) $row['document_template_name'], $values), 0, 200); + $description = ticketPlaceholderFill(trim((string) $description) !== '' ? trim((string) $description) : (string) $row['document_template_description'], $values); mysqli_query($mysqli, "INSERT INTO documents SET document_name = '" . escapeSql($name) . "', document_description = '" . escapeSql($description) . "', document_content = '', document_content_raw = '', document_folder_id = " . intval($folder_id) . ", document_created_by = " . intval($session_user_id ?? 0) . ", document_client_id = $client_id, document_template_id = $template_id"); $document_id = intval(mysqli_insert_id($mysqli)); - // The template's images come along, and its HTML is pointed at the copies + // The template's images come along, and its HTML is pointed at the copies - then it is filled in $html = documentTemplateCopyImages($template_id, dirname(__DIR__) . "/uploads/documents/$document_id", "/uploads/documents/$document_id/", $row['document_template_content']); + $html = documentTemplateFill($html, $client_id, $values); mysqli_query($mysqli, "UPDATE documents SET document_content = '" . mysqli_real_escape_string($mysqli, $html) . "', document_content_raw = '" . escapeSql(documentContentRaw($name, $html)) . "' WHERE document_id = $document_id"); return $document_id; @@ -189,13 +213,394 @@ function documentTemplateBracketsIn($html) { return array_values($found); } -/* A template's HTML (already made safe) with its [words] marked, in its text only - for its page. */ +/* + * A template's HTML (already made safe) with its [words] marked, in its text + * only - for its page: a placeholder grey, a table of records teal, a fill-in blue. + */ function documentTemplateMarkBrackets($html) { + $tones = [ + 'placeholder' => 'text-bg-light border', + 'table' => 'text-info-emphasis bg-info-subtle border border-info-subtle', + 'fill_in' => 'text-primary-emphasis bg-primary-subtle border border-primary-subtle', + ]; $pieces = preg_split('/(<[^>]*>)/', (string) $html, -1, PREG_SPLIT_DELIM_CAPTURE); foreach ($pieces as $i => $piece) { if ($i % 2 === 0 && $piece !== '') { - $pieces[$i] = preg_replace('/\[([A-Za-z][A-Za-z0-9 _\-\/]{0,40})\]/', '[$1]', $piece); + $pieces[$i] = preg_replace_callback('/\[([A-Za-z][A-Za-z0-9 _\-\/]{0,40})\]/', function ($match) use ($tones) { + return '[' . $match[1] . ']'; + }, $piece); } } return implode('', $pieces); } + +/* ---------------------------------------------------------------- filled in as a document is made */ + +/** The placeholders filled in from the client as a document is made - what each becomes. */ +const DOCUMENT_TEMPLATE_PLACEHOLDERS = [ + 'Client' => "the client's name", + 'Website' => "the client's website", + 'Site' => 'the site picked - its name', + 'Address' => "the site's address, on one line", + 'Site Phone' => "the site's phone number", + 'Contact' => 'the contact picked - their name', + 'First Name' => "the contact's first name", + 'Contact Email' => "the contact's email", + 'Contact Phone' => "the contact's phone number", + 'Agent' => 'you, making it', + 'Date' => "today's date", + 'Month' => 'the month - October 2026', + 'Quarter' => 'the quarter - Q4 2026', + 'Year' => 'the year', +]; + +/** The placeholders that come from the site picked, and from the contact picked - lowercase. */ +const DOCUMENT_TEMPLATE_SITE_PLACEHOLDERS = ['site', 'address', 'site phone']; +const DOCUMENT_TEMPLATE_CONTACT_PLACEHOLDERS = ['contact', 'first name', 'contact email', 'contact phone']; + +/* + * The client's records that go in as a table, as they are when the document is + * made - what each holds. On a line of its own, [Networks] becomes the table; + * in a sentence, their names. + */ +const DOCUMENT_TEMPLATE_TABLES = [ + 'Contacts' => 'the contacts - name, title, email and phone', + 'Locations' => 'the sites - name, address, phone and hours', + 'Assets' => 'every asset - name, type, make and model, serial, site and who has it', + 'Servers' => 'servers and virtual machines - name, operating system, IP address and site', + 'Workstations' => 'desktops and laptops - name, make and model, serial and who has it', + 'Network Equipment' => 'firewalls, routers, switches and access points - name, make and model, IP address and site', + 'Printers' => 'printers - name, make and model, IP address and site', + 'Networks' => 'networks - name, VLAN, network, gateway, DHCP range and site', + 'Domains' => 'domains - name, registrar and when it expires', + 'Certificates' => 'certificates - name, domain, issuer and when it expires', + 'Licenses' => 'licenses - name, type, seats and when it expires', + 'Vendors' => 'vendors - name, phone, email, website and account number', + 'Services' => 'services - name, category and importance', +]; + +/* + * A fill-in's name, tidied - as it goes in square brackets - or '' when it + * can't be one: the placeholders and the tables of records are taken. + */ +function documentFillInName($name) { + $name = trim(preg_replace('/\s+/', ' ', strip_tags((string) $name)), " []"); + $taken = array_change_key_case(DOCUMENT_TEMPLATE_PLACEHOLDERS + DOCUMENT_TEMPLATE_TABLES); + if (!preg_match('/^' . TICKET_PLACEHOLDER_NAME . '$/', $name) || isset($taken[strtolower($name)])) { + return ''; + } + return $name; +} + +/* What a [word] is in a document template - 'placeholder', 'table' or 'fill_in'. */ +function documentTemplateWordKind($word) { + $word = strtolower(preg_replace('/\s+/', ' ', trim((string) $word))); + if (isset(array_change_key_case(DOCUMENT_TEMPLATE_PLACEHOLDERS)[$word])) { + return 'placeholder'; + } + return isset(array_change_key_case(DOCUMENT_TEMPLATE_TABLES)[$word]) ? 'table' : 'fill_in'; +} + +/* + * The fill-ins set up on templates - [template id => [['id', 'name', 'type', + * 'options' => [..], 'required' => bool, 'help'], ...]], in their order. + */ +function documentTemplateFillInRowsFor(array $document_template_ids) { + global $mysqli; + $ids = array_values(array_filter(array_map('intval', $document_template_ids))); + $rows = array_fill_keys($ids, []); + if (!$ids) { + return $rows; + } + $sql = mysqli_query($mysqli, "SELECT document_fill_in_id, document_fill_in_document_template_id, document_fill_in_name, document_fill_in_type, document_fill_in_options, document_fill_in_required, document_fill_in_help + FROM document_template_fill_ins WHERE document_fill_in_document_template_id IN (" . implode(',', $ids) . ") ORDER BY document_fill_in_order ASC, document_fill_in_id ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $rows[intval($row['document_fill_in_document_template_id'])][] = [ + 'id' => intval($row['document_fill_in_id']), + 'name' => (string) $row['document_fill_in_name'], + 'type' => isset(TICKET_FILL_IN_TYPES[$row['document_fill_in_type']]) ? $row['document_fill_in_type'] : 'text', + 'options' => ticketFillInOptions($row['document_fill_in_options']), + 'required' => (bool) intval($row['document_fill_in_required']), + 'help' => (string) $row['document_fill_in_help'], + ]; + } + return $rows; +} + +/* The words in square brackets in a template's name, summary and what it says (documentTemplateBracketsIn()). */ +function documentTemplateBracketsInAll(array $template) { + return documentTemplateBracketsIn(escapeHtml($template['document_template_name'] ?? '') . "\n" . escapeHtml($template['document_template_description'] ?? '') . "\n" . ($template['document_template_content'] ?? '')); +} + +/* + * Everything a template asks for when a document is made, in order - the ones + * set up (their rows), then the other bracketed words in its name, summary + * and what it says that aren't placeholders or tables, as plain optional + * text. Each ['id' (0 when not set up), 'name', 'type', 'options', 'required', + * 'help', 'in_text']. + */ +function documentTemplateFillIns(array $template, array $rows) { + $in_text = []; + foreach (documentTemplateBracketsInAll($template) as $name) { + if (documentTemplateWordKind($name) === 'fill_in') { + $in_text[strtolower($name)] = $name; + } + } + $fill_ins = []; + foreach ($rows as $row) { + $fill_ins[strtolower($row['name'])] = $row + ['in_text' => isset($in_text[strtolower($row['name'])])]; + } + foreach ($in_text as $key => $name) { + $fill_ins[$key] = $fill_ins[$key] ?? ['id' => 0, 'name' => $name, 'type' => 'text', 'options' => [], 'required' => false, 'help' => '', 'in_text' => true]; + } + return array_values($fill_ins); +} + +/** Everything one template asks for (documentTemplateFillIns()) - [] when it is gone. */ +function documentTemplateFillInsFor($document_template_id) { + $template = documentTemplateRow($document_template_id); + if (!$template) { + return []; + } + $id = intval($template['document_template_id']); + return documentTemplateFillIns($template, documentTemplateFillInRowsFor([$id])[$id]); +} + +/* + * A fill-in renamed: its [Name] in the template's name, summary and what it + * says goes with it. The new name holds nothing HTML could misread + * (documentFillInName()). + */ +function documentTemplateRenameFillIn($document_template_id, $old, $new) { + global $mysqli; + $template = documentTemplateRow($document_template_id); + if (!$template || $old === $new) { + return; + } + $rename = [strtolower($old) => "[$new]"]; + mysqli_query($mysqli, "UPDATE document_templates SET + document_template_name = '" . escapeSql(ticketPlaceholderFill($template['document_template_name'], $rename)) . "', + document_template_description = '" . escapeSql(ticketPlaceholderFill($template['document_template_description'], $rename)) . "', + document_template_content = '" . mysqli_real_escape_string($mysqli, ticketPlaceholderFill($template['document_template_content'], $rename)) . "' + WHERE document_template_id = " . intval($template['document_template_id'])); +} + +/* The placeholders, tables and fill-ins a template uses, in its name, summary and what it says - lowercase name => name, by kind. */ +function documentTemplateWordsUsed(array $template) { + $used = ['placeholder' => [], 'table' => [], 'fill_in' => []]; + foreach (documentTemplateBracketsInAll($template) as $word) { + $used[documentTemplateWordKind($word)][strtolower($word)] = $word; + } + return $used; +} + +/** A client's current sites, the primary one first - [id => row]. */ +function documentTemplateClientLocations($client_id) { + global $mysqli; + $rows = []; + $sql = mysqli_query($mysqli, "SELECT location_id, location_name, location_address, location_city, location_state, location_zip, location_country, location_phone_country_code, location_phone, location_hours, location_primary + FROM locations WHERE location_client_id = " . intval($client_id) . " AND location_archived_at IS NULL ORDER BY location_primary DESC, location_name ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $rows[intval($row['location_id'])] = $row; + } + return $rows; +} + +/** A client's current contacts, the primary one first - [id => row]. */ +function documentTemplateClientContacts($client_id) { + global $mysqli; + $rows = []; + $sql = mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_title, contact_email, contact_phone_country_code, contact_phone, contact_extension, contact_mobile_country_code, contact_mobile, contact_primary + FROM contacts WHERE contact_client_id = " . intval($client_id) . " AND contact_archived_at IS NULL ORDER BY contact_primary DESC, contact_name ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $rows[intval($row['contact_id'])] = $row; + } + return $rows; +} + +/* + * What the placeholders become for a client - lowercase name => value, raw; + * '' for what it doesn't have. The site and contact are the ones picked when + * they are the client's, none for -1 (- None - picked), else its primary + * ones. $agent_name: who is making it. + */ +function documentTemplateValues($client_id, $location_id, $contact_id, $agent_name) { + global $mysqli; + $client = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, client_website FROM clients WHERE client_id = " . intval($client_id))); + if (!$client) { + return []; + } + $locations = documentTemplateClientLocations($client_id); + $location = intval($location_id) < 0 ? null : ($locations[intval($location_id)] ?? (reset($locations) ?: null)); + $contacts = documentTemplateClientContacts($client_id); + $contact = intval($contact_id) < 0 ? null : ($contacts[intval($contact_id)] ?? (reset($contacts) ?: null)); + $month = intval(date('n')); + $contact_name = trim((string) ($contact['contact_name'] ?? '')); + return [ + 'client' => (string) $client['client_name'], + 'website' => (string) $client['client_website'], + 'site' => (string) ($location['location_name'] ?? ''), + 'address' => $location ? formatAddress($location['location_address'], $location['location_city'], $location['location_state'], $location['location_zip'], $location['location_country'], ', ') : '', + 'site phone' => $location && trim((string) $location['location_phone']) !== '' ? (string) formatPhoneNumber($location['location_phone'], $location['location_phone_country_code']) : '', + 'contact' => $contact_name, + 'first name' => $contact_name !== '' ? preg_split('/\s+/u', $contact_name)[0] : '', + 'contact email' => (string) ($contact['contact_email'] ?? ''), + 'contact phone' => $contact && trim((string) $contact['contact_phone']) !== '' ? (string) formatPhoneNumber($contact['contact_phone'], $contact['contact_phone_country_code']) + : ($contact && trim((string) $contact['contact_mobile']) !== '' ? (string) formatPhoneNumber($contact['contact_mobile'], $contact['contact_mobile_country_code']) : ''), + 'agent' => (string) $agent_name, + 'date' => date('M j, Y'), + 'month' => date('F Y'), + 'quarter' => 'Q' . intval(ceil($month / 3)) . ' ' . date('Y'), + 'year' => date('Y'), + ]; +} + +/* + * One of DOCUMENT_TEMPLATE_TABLES for a client, as it is now - ['head' => + * [column labels], 'rows' => [[cell text, ...], ...], 'names' => [the + * records' names]]. Raw text. Null for no such table. + */ +function documentTemplateTableData($table, $client_id) { + global $mysqli; + $client_id = intval($client_id); + $date = function ($value) { + return $value && strtotime($value) ? date('M j, Y', strtotime($value)) : ''; + }; + $phone = function ($number, $code) { + return trim((string) $number) !== '' ? (string) formatPhoneNumber($number, $code) : ''; + }; + $assets = function (array $types, array $columns) use ($mysqli, $client_id) { + $where = $types ? " AND asset_type IN ('" . implode("','", array_map('escapeSql', $types)) . "')" : ''; + $sql = mysqli_query($mysqli, "SELECT asset_name, asset_type, asset_make, asset_model, asset_serial, asset_os, location_name, contact_name, + (SELECT interface_ip FROM asset_interfaces WHERE interface_asset_id = asset_id AND interface_archived_at IS NULL ORDER BY interface_primary DESC, interface_id ASC LIMIT 1) AS ip + FROM assets + LEFT JOIN locations ON location_id = asset_location_id AND asset_location_id > 0 + LEFT JOIN contacts ON contact_id = asset_contact_id AND asset_contact_id > 0 + WHERE asset_client_id = $client_id AND asset_archived_at IS NULL$where ORDER BY asset_name ASC"); + $labels = ['name' => 'Name', 'type' => 'Type', 'model' => 'Make and model', 'serial' => 'Serial', 'os' => 'Operating system', 'ip' => 'IP address', 'site' => 'Site', 'who' => 'Assigned to']; + $data = ['head' => array_map(function ($column) use ($labels) { + return $labels[$column]; + }, $columns), 'rows' => [], 'names' => []]; + while ($row = mysqli_fetch_assoc($sql)) { + $cells = ['name' => $row['asset_name'], 'type' => $row['asset_type'], 'model' => trim($row['asset_make'] . ' ' . $row['asset_model']), 'serial' => $row['asset_serial'], + 'os' => $row['asset_os'], 'ip' => $row['ip'], 'site' => $row['location_name'], 'who' => $row['contact_name']]; + $data['rows'][] = array_map(function ($column) use ($cells) { + return (string) ($cells[$column] ?? ''); + }, $columns); + $data['names'][] = (string) $row['asset_name']; + } + return $data; + }; + $rows = function ($sql, array $head, callable $cells, $name_key) use ($mysqli) { + $data = ['head' => $head, 'rows' => [], 'names' => []]; + $result = mysqli_query($mysqli, $sql); + while ($row = mysqli_fetch_assoc($result)) { + $data['rows'][] = array_map('strval', $cells($row)); + $data['names'][] = (string) $row[$name_key]; + } + return $data; + }; + + switch ($table) { + case 'contacts': + return $rows("SELECT contact_name, contact_title, contact_email, contact_phone, contact_phone_country_code, contact_mobile, contact_mobile_country_code, contact_primary FROM contacts + WHERE contact_client_id = $client_id AND contact_archived_at IS NULL ORDER BY contact_primary DESC, contact_name ASC", ['Name', 'Title', 'Email', 'Phone', 'Mobile'], function ($r) use ($phone) { + return [$r['contact_name'] . (intval($r['contact_primary']) ? ' (primary)' : ''), $r['contact_title'], $r['contact_email'], $phone($r['contact_phone'], $r['contact_phone_country_code']), $phone($r['contact_mobile'], $r['contact_mobile_country_code'])]; + }, 'contact_name'); + case 'locations': + return $rows("SELECT location_name, location_address, location_city, location_state, location_zip, location_country, location_phone, location_phone_country_code, location_hours, location_primary FROM locations + WHERE location_client_id = $client_id AND location_archived_at IS NULL ORDER BY location_primary DESC, location_name ASC", ['Site', 'Address', 'Phone', 'Hours'], function ($r) use ($phone) { + return [$r['location_name'] . (intval($r['location_primary']) ? ' (primary)' : ''), formatAddress($r['location_address'], $r['location_city'], $r['location_state'], $r['location_zip'], $r['location_country'], ', '), + $phone($r['location_phone'], $r['location_phone_country_code']), $r['location_hours']]; + }, 'location_name'); + case 'assets': + return $assets([], ['name', 'type', 'model', 'serial', 'site', 'who']); + case 'servers': + return $assets(['Server', 'Virtual Machine'], ['name', 'type', 'os', 'ip', 'site']); + case 'workstations': + return $assets(['Desktop', 'Laptop'], ['name', 'type', 'model', 'serial', 'who']); + case 'network equipment': + return $assets(['Firewall/Router', 'Switch', 'Access Point'], ['name', 'type', 'model', 'ip', 'site']); + case 'printers': + return $assets(['Printer'], ['name', 'model', 'ip', 'site']); + case 'networks': + return $rows("SELECT network_name, network_vlan, network, network_gateway, network_dhcp_range, location_name FROM networks + LEFT JOIN locations ON location_id = network_location_id AND network_location_id > 0 + WHERE network_client_id = $client_id AND network_archived_at IS NULL ORDER BY network_name ASC", ['Name', 'VLAN', 'Network', 'Gateway', 'DHCP range', 'Site'], function ($r) { + return [$r['network_name'], $r['network_vlan'], $r['network'], $r['network_gateway'], $r['network_dhcp_range'], $r['location_name']]; + }, 'network_name'); + case 'domains': + return $rows("SELECT domain_name, vendor_name, domain_expire FROM domains + LEFT JOIN vendors ON vendor_id = domain_registrar AND domain_registrar > 0 + WHERE domain_client_id = $client_id AND domain_archived_at IS NULL ORDER BY domain_name ASC", ['Domain', 'Registrar', 'Expires'], function ($r) use ($date) { + return [$r['domain_name'], $r['vendor_name'], $date($r['domain_expire'])]; + }, 'domain_name'); + case 'certificates': + return $rows("SELECT certificate_name, certificate_domain, certificate_issued_by, certificate_expire FROM certificates + WHERE certificate_client_id = $client_id AND certificate_archived_at IS NULL ORDER BY certificate_name ASC", ['Name', 'Domain', 'Issued by', 'Expires'], function ($r) use ($date) { + return [$r['certificate_name'], $r['certificate_domain'], $r['certificate_issued_by'], $date($r['certificate_expire'])]; + }, 'certificate_name'); + case 'licenses': + return $rows("SELECT software_name, software_type, software_license_type, software_seats, software_expire FROM software + WHERE software_client_id = $client_id AND software_archived_at IS NULL ORDER BY software_name ASC", ['Name', 'Type', 'License', 'Seats', 'Expires'], function ($r) use ($date) { + return [$r['software_name'], $r['software_type'], $r['software_license_type'], $r['software_seats'], $date($r['software_expire'])]; + }, 'software_name'); + case 'vendors': + return $rows("SELECT vendor_name, vendor_phone, vendor_phone_country_code, vendor_email, vendor_website, vendor_account_number FROM vendors + WHERE vendor_client_id = $client_id AND vendor_archived_at IS NULL ORDER BY vendor_name ASC", ['Name', 'Phone', 'Email', 'Website', 'Account number'], function ($r) use ($phone) { + return [$r['vendor_name'], $phone($r['vendor_phone'], $r['vendor_phone_country_code']), $r['vendor_email'], $r['vendor_website'], $r['vendor_account_number']]; + }, 'vendor_name'); + case 'services': + return $rows("SELECT service_name, service_category, service_importance FROM services + WHERE service_client_id = $client_id AND service_archived_at IS NULL ORDER BY service_name ASC", ['Name', 'Category', 'Importance'], function ($r) { + return [$r['service_name'], $r['service_category'], $r['service_importance']]; + }, 'service_name'); + } + return null; +} + +/* A table of records (documentTemplateTableData()) as HTML - with one empty row to fill in when there are none. */ +function documentTemplateTableHtml(array $data) { + $html = ''; + foreach ($data['head'] as $label) { + $html .= ''; + } + $html .= ''; + foreach ($data['rows'] ?: [array_fill(0, count($data['head']), '')] as $cells) { + $html .= ''; + foreach ($cells as $cell) { + $html .= ''; + } + $html .= ''; + } + return $html . '
' . escapeHtml($label) . '
' . escapeHtml($cell) . '
'; +} + +/* + * What a template says, filled in for a client: its tables of records - a + * [Networks] on a line of its own becomes the table, one in a sentence their + * names - then its placeholders and the fill-ins' answers ($values, lowercase + * name => value). A [word] with nothing to put in stays as it is. + */ +function documentTemplateFill($html, $client_id, array $values) { + $html = (string) $html; + $space = '(?:\s| |\x{00A0})'; + foreach (array_keys(DOCUMENT_TEMPLATE_TABLES) as $table) { + $words = implode($space . '+', array_map(function ($word) { + return preg_quote($word, '/'); + }, explode(' ', $table))); + if (!preg_match('/\[' . $words . '\]/iu', $html)) { + continue; + } + $data = documentTemplateTableData(strtolower($table), $client_id); + // On a line of its own - a paragraph or heading holding nothing else - the table + $html = preg_replace_callback('/<(p|div|h[1-6])\b[^>]*>' . $space . '*\[' . $words . '\]' . $space . '*<\/\1>/iu', function () use ($data) { + return documentTemplateTableHtml($data); + }, $html); + // In a sentence, the names + $html = preg_replace_callback('/\[' . $words . '\]/iu', function () use ($data) { + return escapeHtml($data['names'] ? implode(', ', $data['names']) : 'none'); + }, $html); + } + return ticketPlaceholderFill($html, $values, true); +} diff --git a/js/document_template_form.js b/js/document_template_form.js new file mode 100644 index 000000000..38e0050e0 --- /dev/null +++ b/js/document_template_form.js @@ -0,0 +1,23 @@ +/* + * The document template window (admin/modals/document_template/document_template_form.php): + * a placeholder under the editor goes in where the cursor is, and a table of the + * client's records goes in on a line of its own. The window is an ajax modal that + * runs this each time it opens, so the listener is bound once. + */ +itflowBindOnce('itflowDocumentTemplateWord', 'click', '[data-document-template-word]', function () { + const word = '[' + this.dataset.documentTemplateWord + ']'; + const table = this.hasAttribute('data-document-template-table'); + const editor = window.tinymce ? tinymce.get('documentTemplateContent') : null; + if (editor) { + editor.insertContent(table ? '

' + word + '

' : word); + editor.focus(); + return; + } + const textarea = document.getElementById('documentTemplateContent'); + if (textarea) { + const at = textarea.selectionStart ?? textarea.value.length; + const text = table ? '

' + word + '

' : word; + textarea.value = textarea.value.slice(0, at) + text + textarea.value.slice(textarea.selectionEnd ?? at); + textarea.focus(); + } +});