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

This commit is contained in:
johnnyq
2026-10-18 19:47:57 -04:00
parent 9fdc2f7032
commit 3ee183a09a
17 changed files with 1054 additions and 50 deletions

View File

@@ -6,8 +6,16 @@
* images, kept in uploads/document_templates/<id>/. 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})\]/', '<span class="badge rounded-pill text-bg-light border font-monospace fw-normal">[$1]</span>', $piece);
$pieces[$i] = preg_replace_callback('/\[([A-Za-z][A-Za-z0-9 _\-\/]{0,40})\]/', function ($match) use ($tones) {
return '<span class="badge rounded-pill ' . $tones[documentTemplateWordKind($match[1])] . ' font-monospace fw-normal">[' . $match[1] . ']</span>';
}, $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 = '<table style="width:100%" border="1"><tbody><tr>';
foreach ($data['head'] as $label) {
$html .= '<th>' . escapeHtml($label) . '</th>';
}
$html .= '</tr>';
foreach ($data['rows'] ?: [array_fill(0, count($data['head']), '')] as $cells) {
$html .= '<tr>';
foreach ($cells as $cell) {
$html .= '<td>' . escapeHtml($cell) . '</td>';
}
$html .= '</tr>';
}
return $html . '</tbody></table>';
}
/*
* 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|&nbsp;|\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);
}