Ticket Templates makes them (admin/ticket_templates.php, * admin/ticket_template.php, admin/post/ticket_template.php). They are picked * on New Ticket, the New Tickets windows of the assets and clients lists and * recurring tickets (agent/includes/inc_ticket_template_select.php, which * fills in the form - agent/js/ticket_tasks_modal.js), and a project template * raises its ticket templates as they are (projectApplyTemplate()). * * Words in square brackets - [Client], [Contact], [Asset], [Site], [Agent], * [Project], [Ticket], [Date], [Month], [Quarter] and [Year] - are filled in * from the ticket when it is raised (ticketPlaceholdersFill()). Anything else * in brackets, [Employee Name] say, is a fill-in: asked for when the ticket is * raised (as text, a date, a choice from a list, or yes or no - optional * unless the template says otherwise), filled in wherever it appears, and * kept on the ticket as its summary (ticket_fill_ins). One left blank stays in * the text for whoever picks it up, and can be answered on the ticket later. * A template's task can be for some answers only - "Order a laptop" when * Needs a laptop is Yes. * * A ticket keeps which template it came from (tickets.ticket_template_id), so * a template shows how often it is used and its recent tickets. * * A template can be offered in the client portal as a request form - "Onboard * a new employee" - to everyone with portal access or the primary and * technical contacts only, at every client or the ones picked. Its fill-ins * the client is asked are the form; the ticket is raised with everything the * template sets up (ticketTemplatePortalRaise(), client/ticket_add.php). */ /** The placeholders filled in from the ticket - what each becomes. */ const TICKET_PLACEHOLDERS = [ 'Client' => "the client's name", 'Contact' => "the contact's name", 'Asset' => "the asset's name", 'Site' => "the site's name", 'Agent' => 'the agent it is assigned to, or the agent who raised it', 'Project' => "the project's name", 'Ticket' => 'its number - TCK-123', 'Date' => "today's date", 'Month' => 'the month - October 2026', 'Quarter' => 'the quarter - Q4 2026', 'Year' => 'the year', ]; /** What can go in square brackets - a letter, then up to 40 letters, digits, spaces, _ - and /. A regex fragment. */ const TICKET_PLACEHOLDER_NAME = '[A-Za-z][A-Za-z0-9 _\-\/]{0,40}'; /** One template with its category and the agent it goes to, or null. Archived ones too. */ function ticketTemplateRow($ticket_template_id) { global $mysqli; $ticket_template_id = intval($ticket_template_id); if (!$ticket_template_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_templates.*, category_name, category_color, category_icon, category_type, category_description, user_name AS assignee_name, user_avatar AS assignee_avatar FROM ticket_templates LEFT JOIN categories ON category_id = ticket_template_category_id AND ticket_template_category_id > 0 LEFT JOIN users ON user_id = ticket_template_assigned_to AND ticket_template_assigned_to > 0 WHERE ticket_template_id = $ticket_template_id LIMIT 1")); return $row ?: null; } /* * Templates' tasks, in order - [template id => [['id', 'name', 'estimate', * 'when' => ['fill_in', 'value'] or null], ...]]. 'when': only for that answer. */ function ticketTemplateTasksFor(array $ticket_template_ids) { global $mysqli; $ids = array_values(array_filter(array_map('intval', $ticket_template_ids))); $tasks = array_fill_keys($ids, []); if (!$ids) { return $tasks; } $sql = mysqli_query($mysqli, "SELECT task_template_id, task_template_ticket_template_id, task_template_name, task_template_completion_estimate, task_template_when_fill_in, task_template_when_value FROM task_templates WHERE task_template_ticket_template_id IN (" . implode(',', $ids) . ") ORDER BY task_template_order ASC, task_template_id ASC"); while ($row = mysqli_fetch_assoc($sql)) { $when = trim((string) $row['task_template_when_fill_in']) !== '' ? ['fill_in' => (string) $row['task_template_when_fill_in'], 'value' => (string) $row['task_template_when_value']] : null; $tasks[intval($row['task_template_ticket_template_id'])][] = ['id' => intval($row['task_template_id']), 'name' => (string) $row['task_template_name'], 'estimate' => intval($row['task_template_completion_estimate']), 'when' => $when]; } return $tasks; } /** One template's tasks, in order (ticketTemplateTasksFor()). */ function ticketTemplateTasks($ticket_template_id) { return ticketTemplateTasksFor([$ticket_template_id])[intval($ticket_template_id)] ?? []; } /** The ticket tags a template gives its tickets - ids, the archived ones too. */ function ticketTemplateTagIds($ticket_template_id) { global $mysqli; $ids = []; $sql = mysqli_query($mysqli, "SELECT tag_id FROM ticket_template_tags WHERE ticket_template_id = " . intval($ticket_template_id) . " ORDER BY tag_id"); while ($row = mysqli_fetch_row($sql)) { $ids[] = intval($row[0]); } return $ids; } /** "4 hours", "1 day", "3 days" - when a template's tickets fall due; '' for not set. */ function ticketTemplateDueText($hours) { $hours = intval($hours); if ($hours <= 0) { return ''; } if ($hours % 24 === 0) { $days = intdiv($hours, 24); return $days === 1 ? '1 day' : "$days days"; } return $hours === 1 ? '1 hour' : "$hours hours"; } /** Tasks' time in words - "45m", "2h 30m". */ function ticketTemplateMinutesText($minutes) { $minutes = max(0, intval($minutes)); return formatDuration(sprintf('%02d:%02d:00', intdiv($minutes, 60), $minutes % 60)); } /** Square-bracket words in some text - [Client], [Employee Name] - each once, as first written. HTML is read as its text. */ function ticketTemplatePlaceholdersIn($text) { $text = str_replace("\u{00A0}", ' ', html_entity_decode(strip_tags((string) $text), ENT_QUOTES | ENT_HTML5, 'UTF-8')); preg_match_all('/\[(' . TICKET_PLACEHOLDER_NAME . ')\]/', $text, $matches); $found = []; foreach ($matches[1] as $name) { $name = preg_replace('/ {2,}/', ' ', trim($name)); $found[strtolower($name)] = $found[strtolower($name)] ?? $name; } return array_values($found); } /** When a ticket raised now from a template that is due in $hours is due - 'Y-m-d H:i:s', or null for not set. */ function ticketTemplateDueAt($hours) { $hours = intval($hours); return $hours > 0 ? date('Y-m-d H:i:s', time() + $hours * 3600) : null; } /* * A template's form, posted (admin/modals/ticket_template/ticket_template_form.php), * checked - its fields raw, or null with $error saying why. The due time is * due_amount in due_unit (hours or days). */ function ticketTemplateFields(array $post, &$error = '') { $name = trim(mb_substr(preg_replace('/\s+/', ' ', strip_tags((string) ($post['name'] ?? ''))), 0, 200)); if ($name === '') { $error = 'A template needs a name.'; return null; } $priority = (string) ($post['priority'] ?? ''); $billable = (string) ($post['billable'] ?? ''); if (intval($post['portal'] ?? 0) && ($post['portal_all_clients'] ?? '1') === '0' && !array_filter(array_map('intval', (array) ($post['portal_clients'] ?? [])))) { $error = 'Pick the clients that see it in the portal, or offer it at every client.'; return null; } $amount = max(0, intval($post['due_amount'] ?? 0)); $hours = ($post['due_unit'] ?? 'days') === 'hours' ? $amount : $amount * 24; return [ 'name' => $name, 'description' => trim(mb_substr(strip_tags((string) ($post['description'] ?? '')), 0, 500)), 'subject' => trim(mb_substr(strip_tags((string) ($post['subject'] ?? '')), 0, 500)), 'details' => (string) ($post['details'] ?? ''), 'category_id' => ticketCategoryId($post['category_id'] ?? 0), 'priority' => in_array($priority, TICKET_PRIORITIES, true) ? $priority : null, 'billable' => $billable === '1' ? 1 : ($billable === '0' ? 0 : null), 'assigned_to' => ticketAgentId($post['assigned_to'] ?? 0), 'due_hours' => min($hours, 24 * 366), 'tags' => (array) ($post['tags'] ?? []), // The client portal - its request form 'portal' => intval($post['portal'] ?? 0) ? 1 : 0, 'portal_title' => trim(mb_substr(preg_replace('/\s+/', ' ', strip_tags((string) ($post['portal_title'] ?? ''))), 0, 200)), 'portal_description' => trim(mb_substr(preg_replace('/\s+/', ' ', strip_tags((string) ($post['portal_description'] ?? ''))), 0, 500)), 'portal_icon' => isset(TICKET_TEMPLATE_PORTAL_ICONS[$post['portal_icon'] ?? '']) ? $post['portal_icon'] : '', 'portal_who' => isset(TICKET_TEMPLATE_PORTAL_WHO[$post['portal_who'] ?? '']) ? $post['portal_who'] : 'everyone', 'portal_all_clients' => ($post['portal_all_clients'] ?? '1') === '0' ? 0 : 1, 'portal_clients' => array_values(array_unique(array_filter(array_map('intval', (array) ($post['portal_clients'] ?? []))))), ]; } /** Save a template's fields (ticketTemplateFields()) - a new one when $ticket_template_id is 0. Its id. */ function ticketTemplateSave($ticket_template_id, array $f) { global $mysqli; $ticket_template_id = intval($ticket_template_id); $set = "ticket_template_name = '" . escapeSql($f['name']) . "', ticket_template_description = '" . escapeSql($f['description']) . "', ticket_template_subject = '" . escapeSql($f['subject']) . "', ticket_template_details = '" . mysqli_real_escape_string($mysqli, $f['details']) . "', ticket_template_category_id = " . intval($f['category_id']) . ", ticket_template_priority = " . ($f['priority'] === null ? 'NULL' : "'" . escapeSql($f['priority']) . "'") . ", ticket_template_billable = " . ($f['billable'] === null ? 'NULL' : intval($f['billable'])) . ", ticket_template_assigned_to = " . intval($f['assigned_to']) . ", ticket_template_due_hours = " . intval($f['due_hours']) . ", ticket_template_portal = " . intval($f['portal'] ?? 0) . ", ticket_template_portal_title = " . (($f['portal_title'] ?? '') !== '' ? "'" . escapeSql($f['portal_title']) . "'" : 'NULL') . ", ticket_template_portal_description = " . (($f['portal_description'] ?? '') !== '' ? "'" . escapeSql($f['portal_description']) . "'" : 'NULL') . ", ticket_template_portal_icon = " . (($f['portal_icon'] ?? '') !== '' ? "'" . escapeSql($f['portal_icon']) . "'" : 'NULL') . ", ticket_template_portal_who = '" . escapeSql($f['portal_who'] ?? 'everyone') . "', ticket_template_portal_all_clients = " . intval($f['portal_all_clients'] ?? 1); if ($ticket_template_id) { mysqli_query($mysqli, "UPDATE ticket_templates SET $set WHERE ticket_template_id = $ticket_template_id"); } else { mysqli_query($mysqli, "INSERT INTO ticket_templates SET $set"); $ticket_template_id = intval(mysqli_insert_id($mysqli)); } ticketTemplateSetTags($ticket_template_id, $f['tags']); if (isset($f['portal_clients'])) { ticketTemplateSetPortalClients($ticket_template_id, $f['portal_clients']); } return $ticket_template_id; } /** The clients a template's portal form is for, when it isn't for them all - ids. */ function ticketTemplatePortalClientIds($ticket_template_id) { global $mysqli; $ids = []; $sql = mysqli_query($mysqli, "SELECT client_id FROM ticket_template_portal_clients WHERE ticket_template_id = " . intval($ticket_template_id) . " ORDER BY client_id"); while ($row = mysqli_fetch_row($sql)) { $ids[] = intval($row[0]); } return $ids; } /** Set the clients a template's portal form is for - the ones that exist. */ function ticketTemplateSetPortalClients($ticket_template_id, array $client_ids) { global $mysqli; $ticket_template_id = intval($ticket_template_id); mysqli_query($mysqli, "DELETE FROM ticket_template_portal_clients WHERE ticket_template_id = $ticket_template_id"); $client_ids = array_values(array_filter(array_map('intval', $client_ids))); if ($client_ids) { mysqli_query($mysqli, "INSERT IGNORE INTO ticket_template_portal_clients (ticket_template_id, client_id) SELECT $ticket_template_id, client_id FROM clients WHERE client_id IN (" . implode(',', $client_ids) . ")"); } } /* Set a template's tags from a Tags field - ticket tags, as tagSetForRecord() takes them. The ids it has now. */ function ticketTemplateSetTags($ticket_template_id, array $values) { global $mysqli; $ticket_template_id = intval($ticket_template_id); $had = ticketTemplateTagIds($ticket_template_id); $keep = tagKeepIds('ticket', tagIdsFromValues('ticket', $values), $had); $remove = array_diff($had, $keep); if ($remove) { mysqli_query($mysqli, "DELETE FROM ticket_template_tags WHERE ticket_template_id = $ticket_template_id AND tag_id IN (" . implode(',', $remove) . ")"); } foreach (array_diff($keep, $had) as $tag_id) { mysqli_query($mysqli, "INSERT IGNORE INTO ticket_template_tags SET ticket_template_id = $ticket_template_id, tag_id = " . intval($tag_id)); } return $keep; } /** A copy of a template - "Copy of" its name, its tasks, tags and fill-ins - not archived. The new one's id, or 0. */ function ticketTemplateCopy($ticket_template_id) { global $mysqli; $row = ticketTemplateRow($ticket_template_id); if (!$row) { return 0; } $new_id = ticketTemplateSave(0, [ 'name' => mb_substr('Copy of ' . $row['ticket_template_name'], 0, 200), 'description' => (string) $row['ticket_template_description'], 'subject' => (string) $row['ticket_template_subject'], 'details' => (string) $row['ticket_template_details'], 'category_id' => intval($row['ticket_template_category_id']), 'priority' => $row['ticket_template_priority'], 'billable' => $row['ticket_template_billable'] === null ? null : intval($row['ticket_template_billable']), 'assigned_to' => intval($row['ticket_template_assigned_to']), 'due_hours' => intval($row['ticket_template_due_hours']), 'tags' => [], ]); foreach (ticketTemplateTagIds($ticket_template_id) as $tag_id) { mysqli_query($mysqli, "INSERT IGNORE INTO ticket_template_tags SET ticket_template_id = $new_id, tag_id = $tag_id"); } foreach (ticketTemplateTasks($ticket_template_id) as $order => $task) { mysqli_query($mysqli, "INSERT INTO task_templates SET task_template_name = '" . escapeSql($task['name']) . "', task_template_order = " . ($order + 1) . ", task_template_completion_estimate = " . intval($task['estimate']) . ", task_template_ticket_template_id = $new_id, task_template_when_fill_in = " . ($task['when'] ? "'" . escapeSql($task['when']['fill_in']) . "'" : 'NULL') . ", task_template_when_value = " . ($task['when'] ? "'" . escapeSql($task['when']['value']) . "'" : 'NULL')); } mysqli_query($mysqli, "INSERT INTO ticket_template_fill_ins (fill_in_ticket_template_id, fill_in_name, fill_in_type, fill_in_options, fill_in_required, fill_in_client, fill_in_help, fill_in_order) SELECT $new_id, fill_in_name, fill_in_type, fill_in_options, fill_in_required, fill_in_client, fill_in_help, fill_in_order FROM ticket_template_fill_ins WHERE fill_in_ticket_template_id = " . intval($ticket_template_id)); // Offered in the portal as it was - but not twice under the same title, so the copy starts off it mysqli_query($mysqli, "UPDATE ticket_templates t JOIN ticket_templates o ON o.ticket_template_id = " . intval($ticket_template_id) . " SET t.ticket_template_portal_title = o.ticket_template_portal_title, t.ticket_template_portal_description = o.ticket_template_portal_description, t.ticket_template_portal_icon = o.ticket_template_portal_icon, t.ticket_template_portal_who = o.ticket_template_portal_who, t.ticket_template_portal_all_clients = o.ticket_template_portal_all_clients, t.ticket_template_portal = 0 WHERE t.ticket_template_id = $new_id"); mysqli_query($mysqli, "INSERT IGNORE INTO ticket_template_portal_clients (ticket_template_id, client_id) SELECT $new_id, client_id FROM ticket_template_portal_clients WHERE ticket_template_id = " . intval($ticket_template_id)); return $new_id; } /** How templates are used - [id => ['count' => tickets raised, 'open' => still open, 'last' => when the last was raised]]. */ function ticketTemplateUsage(array $ticket_template_ids) { global $mysqli; $ids = array_values(array_filter(array_map('intval', $ticket_template_ids))); $usage = []; if (!$ids) { return $usage; } $sql = mysqli_query($mysqli, "SELECT ticket_template_id, COUNT(*) AS used, SUM(ticket_closed_at IS NULL AND ticket_resolved_at IS NULL) AS still_open, MAX(ticket_created_at) AS last_used FROM tickets WHERE ticket_template_id IN (" . implode(',', $ids) . ") AND ticket_archived_at IS NULL GROUP BY ticket_template_id"); while ($row = mysqli_fetch_assoc($sql)) { $usage[intval($row['ticket_template_id'])] = ['count' => intval($row['used']), 'open' => intval($row['still_open']), 'last' => $row['last_used']]; } return $usage; } /* * How a template sets up its tickets, as little badges - its priority, * category, billing, agent and due time. HTML; '' when it sets none of them. */ function ticketTemplateDefaultsHtml(array $row) { $chips = []; if (!empty($row['ticket_template_priority'])) { $chips[] = ticketPriorityHtml($row['ticket_template_priority'], 'detail'); } if (intval($row['ticket_template_category_id']) && !empty($row['category_name'])) { $chips[] = '' . categoryChip(['category_name' => $row['category_name'], 'category_type' => 'Ticket', 'category_color' => $row['category_color'] ?? '', 'category_icon' => $row['category_icon'] ?? '']) . ''; } if ($row['ticket_template_billable'] !== null) { $chips[] = '' . (intval($row['ticket_template_billable']) ? 'Billable' : 'Not billable') . ''; } if (intval($row['ticket_template_assigned_to']) && !empty($row['assignee_name'])) { $chips[] = '' . escapeHtml($row['assignee_name']) . ''; } if (intval($row['ticket_template_due_hours'])) { $chips[] = 'Due in ' . ticketTemplateDueText($row['ticket_template_due_hours']) . ''; } return $chips ? '' . implode('', $chips) . '' : ''; } /* * What picking a template puts into a ticket form - the data attributes of * its