Feature: Add Dynamic Task editing in add ticket and add recurring ticket, recurring tickets now have their own task table as well

This commit is contained in:
johnnyq
2026-07-29 17:48:41 -04:00
parent 74a5359b80
commit e74307eaea
14 changed files with 787 additions and 504 deletions

View File

@@ -0,0 +1,53 @@
<?php
/*
* ITFlow - Editable task rows
*
* Shared by the ticket add modal and the recurring ticket add/edit modals. Rows
* are added and removed in the browser by agent/js/ticket_tasks_modal.js and
* submit as parallel tasks[] / task_estimates[] arrays, read back by
* parseSubmittedTasks().
*
* Set $existing_tasks before including to pre-fill rows - a list of
* ['name' => string, 'estimate' => int]. Defaults to none.
*
* The tasks_submitted marker distinguishes "the user cleared every row" from
* "this form has no task section", which an empty tasks[] cannot express because
* a form with no inputs of that name posts nothing at all.
*/
$existing_tasks = $existing_tasks ?? [];
?>
<input type="hidden" name="tasks_submitted" value="1">
<div class="form-group">
<label>Tasks</label>
<div class="form-row mb-1 text-muted small">
<div class="col-7">Task</div>
<div class="col-3">Estimate (mins)</div>
<div class="col-2"></div>
</div>
<div id="ticketTasksContainer">
<?php foreach ($existing_tasks as $existing_task) { ?>
<div class="form-row mb-2 ticket-task-row">
<div class="col-7">
<input type="text" class="form-control" name="tasks[]" placeholder="Task name" maxlength="255" value="<?= escapeHtml($existing_task['name']) ?>">
</div>
<div class="col-3">
<input type="number" class="form-control" name="task_estimates[]" placeholder="Mins" min="0" value="<?= intval($existing_task['estimate']) ?: '' ?>">
</div>
<div class="col-2">
<button type="button" class="btn btn-secondary btn-block ticket-task-remove" title="Remove task"><i class="fa fa-fw fa-trash"></i></button>
</div>
</div>
<?php } ?>
</div>
<button type="button" class="btn btn-secondary" id="ticketTaskAdd"><i class="fas fa-plus mr-2"></i>Add Task</button>
<small class="form-text text-muted">Leave the estimate blank if you don't track one. Blank task names are ignored.</small>
</div>

View File

@@ -0,0 +1,73 @@
<?php
/*
* ITFlow - Ticket template picker
*
* Shared by the ticket add modal and the recurring ticket add/edit modals. Each
* option carries the template's subject, details and task list as data
* attributes, which agent/js/ticket_tasks_modal.js applies to the form when the
* template is picked.
*
* Set $selected_ticket_template_id before including to pre-select a template (an
* archived one stays listed while it is the one selected, so an existing link
* remains visible). Defaults to none.
*/
$selected_ticket_template_id = intval($selected_ticket_template_id ?? 0);
// Every template's tasks in one pass, rather than a query per option
$ticket_template_tasks = [];
$sql_ticket_template_tasks = mysqli_query(
$mysqli,
"SELECT task_template_ticket_template_id, task_template_name, task_template_completion_estimate
FROM task_templates
ORDER BY task_template_order ASC, task_template_id ASC"
);
while ($row = mysqli_fetch_assoc($sql_ticket_template_tasks)) {
$ticket_template_tasks[intval($row['task_template_ticket_template_id'])][] = [
'name' => $row['task_template_name'],
'estimate' => intval($row['task_template_completion_estimate'])
];
}
?>
<div class="form-group">
<label>Template</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
</div>
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id">
<option value="0">- No Template -</option>
<?php
$sql_ticket_templates = mysqli_query(
$mysqli,
"SELECT ticket_template_id, ticket_template_name, ticket_template_subject, ticket_template_details
FROM ticket_templates
WHERE ticket_template_archived_at IS NULL OR ticket_template_id = $selected_ticket_template_id
ORDER BY ticket_template_name ASC"
);
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
$ticket_template_id_select = intval($row['ticket_template_id']);
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
$ticket_template_task_list = $ticket_template_tasks[$ticket_template_id_select] ?? [];
$task_count = count($ticket_template_task_list);
?>
<option value="<?= $ticket_template_id_select ?>"
data-subject="<?= $ticket_template_subject_select ?>"
data-details="<?= $ticket_template_details_select ?>"
data-tasks="<?= escapeHtml(json_encode($ticket_template_task_list)) ?>"
<?php if ($selected_ticket_template_id == $ticket_template_id_select) { echo "selected"; } ?>>
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
</option>
<?php } ?>
</select>
</div>
<small class="form-text text-muted">Picking a template fills in the subject, details and tasks below. You can edit them afterwards.</small>
</div>

View File

@@ -0,0 +1,148 @@
// Editable task rows for the ticket and recurring ticket add/edit modals, and the
// ticket template picker that pre-fills them.
//
// Rows submit as parallel tasks[] and task_estimates[] arrays, aligned by their
// order in the form, so removing a row removes both of its inputs together.
//
// Wrapped in an IIFE because a modal can be opened, closed and opened again in one
// page load, which re-runs this file - top-level const/let would throw on the
// second run. Delegated handlers are namespaced and unbound first for the same
// reason, otherwise "Add Task" would add one row per time the modal was opened.
(function () {
// Builds one task row. Values are assigned as properties rather than built into
// markup, so a task name containing quotes or angle brackets needs no escaping.
function buildTaskRow(taskName, taskEstimate) {
const row = document.createElement("div");
row.className = "form-row mb-2 ticket-task-row";
const nameColumn = document.createElement("div");
nameColumn.className = "col-7";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.className = "form-control";
nameInput.name = "tasks[]";
nameInput.placeholder = "Task name";
nameInput.maxLength = 255;
nameInput.value = taskName || '';
nameColumn.appendChild(nameInput);
const estimateColumn = document.createElement("div");
estimateColumn.className = "col-3";
const estimateInput = document.createElement("input");
estimateInput.type = "number";
estimateInput.className = "form-control";
estimateInput.name = "task_estimates[]";
estimateInput.placeholder = "Mins";
estimateInput.min = 0;
estimateInput.value = taskEstimate || '';
estimateColumn.appendChild(estimateInput);
const removeColumn = document.createElement("div");
removeColumn.className = "col-2";
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "btn btn-secondary btn-block ticket-task-remove";
removeButton.title = "Remove task";
removeButton.innerHTML = '<i class="fa fa-fw fa-trash"></i>';
removeColumn.appendChild(removeButton);
row.appendChild(nameColumn);
row.appendChild(estimateColumn);
row.appendChild(removeColumn);
return row;
}
function addTaskRow(taskName, taskEstimate) {
const container = document.getElementById("ticketTasksContainer");
if (!container) {
return;
}
container.appendChild(buildTaskRow(taskName, taskEstimate));
}
// Replaces the whole list - used when a template is picked
function setTaskRows(tasks) {
const container = document.getElementById("ticketTasksContainer");
if (!container) {
return;
}
container.innerHTML = '';
(tasks || []).forEach(task => {
addTaskRow(task.name, task.estimate);
});
}
// jQuery parses a data-tasks attribute holding JSON into an array on its own,
// but hand it a string and it stays a string - so handle both
function readTemplateTasks($option) {
const tasks = $option.data('tasks');
if (!tasks) {
return [];
}
if (typeof tasks === 'string') {
try {
return JSON.parse(tasks);
} catch (error) {
return [];
}
}
return tasks;
}
$(document).off('click.ticketTasks').on('click.ticketTasks', '#ticketTaskAdd', function () {
addTaskRow('', '');
});
$(document).off('click.ticketTaskRemove').on('click.ticketTaskRemove', '.ticket-task-remove', function () {
$(this).closest('.ticket-task-row').remove();
});
// Ticket template picker - fills in the subject, details and task rows
$(document).off('change.ticketTemplate').on('change.ticketTemplate', '#ticket_template_select', function () {
const $option = $(this).find(':selected');
// Selecting "- No Template -" only unlinks the template - it must not wipe
// whatever the user has already written or added
if (!parseInt($option.val(), 10)) {
return;
}
const templateSubject = $option.data('subject') || '';
const templateDetails = $option.data('details') || '';
$('#subjectInput').val(templateSubject);
if (window.tinymce) {
const editor = tinymce.get('detailsInput');
if (editor) {
editor.setContent(templateDetails);
} else {
$('#detailsInput').val(templateDetails);
}
} else {
$('#detailsInput').val(templateDetails);
}
setTaskRows(readTemplateTasks($option));
});
})();

View File

@@ -1,4 +1,4 @@
// Used to populate dynamic content in recurring_ticket_add_modal and ticket_add_modal_v2 based on selected client
// Used to populate dynamic content in the ticket and recurring ticket add modals based on selected client
// Not every modal that loads this script has every dropdown, and a modal opened
// from a contact page has no client selector at all - the client arrives as a
@@ -8,362 +8,370 @@
// Client selected listener
// We seem to have to use jQuery to listen for events, as the client input is a select2 component?
const clientSelectDropdown = document.getElementById("changeClientSelect"); // Define client selector
// Wrapped in an IIFE because a modal can be opened, closed and opened again in one
// page load, which re-runs this file - a top-level const would throw on the second
// run and take the whole script with it.
if (clientSelectDropdown) {
(function () {
// If the client selector is disabled, we must be on a client-specific page instead. Trigger the lists to update.
if (clientSelectDropdown.disabled) {
const clientSelectDropdown = document.getElementById("changeClientSelect"); // Define client selector
let client_id = $(clientSelectDropdown).find(':selected').val();
if (clientSelectDropdown) {
// If the client selector is disabled, we must be on a client-specific page instead. Trigger the lists to update.
if (clientSelectDropdown.disabled) {
let client_id = $(clientSelectDropdown).find(':selected').val();
populateLists(client_id);
}
// Listener for client selection. Populate select lists when a client is selected
$(clientSelectDropdown).on('select2:select', function (e) {
let client_id = $(this).find(':selected').val();
// Update the dependent dropdown lists
populateLists(client_id);
});
} else {
// No client selector - the modal was opened from a contact page, where the
// client is fixed and arrives as a hidden field instead
const clientIdHiddenField = document.getElementById("clientIdHidden");
if (clientIdHiddenField && clientIdHiddenField.value) {
populateLists(clientIdHiddenField.value);
}
populateLists(client_id);
}
// Listener for client selection. Populate select lists when a client is selected
$(clientSelectDropdown).on('select2:select', function (e) {
let client_id = $(this).find(':selected').val();
// Populates dropdowns with dynamic content based on the client ID
// Called when the client select dropdown is used or if the client select is disabled
function populateLists(client_id) {
// Update the dependent dropdown lists
populateLists(client_id);
populateContactsDropdown(client_id);
});
populateAssetsDropdowns(client_id);
} else {
populateLocationsDropdown(client_id);
// No client selector - the modal was opened from a contact page, where the
// client is fixed and arrives as a hidden field instead
const clientIdHiddenField = document.getElementById("clientIdHidden");
populateVendorsDropdown(client_id);
if (clientIdHiddenField && clientIdHiddenField.value) {
populateLists(clientIdHiddenField.value);
populateProjectsDropdown(client_id);
}
}
// Empties a dropdown and adds its placeholder, returning the element - or null if
// this modal doesn't have it. Pass null as the label for a multi-select, which has
// no placeholder option of its own.
function resetDropdown(id, placeholderLabel, placeholderValue) {
// Populates dropdowns with dynamic content based on the client ID
// Called when the client select dropdown is used or if the client select is disabled
function populateLists(client_id) {
const dropdown = document.getElementById(id);
populateContactsDropdown(client_id);
if (!dropdown) {
return null;
}
populateAssetsDropdowns(client_id);
// innerHTML rather than removing options one by one, which leaves empty optgroups behind
dropdown.innerHTML = '';
populateLocationsDropdown(client_id);
// A multi-select keeps showing its old selections until select2 is told the value changed
$(dropdown).val(null).trigger('change.select2');
populateVendorsDropdown(client_id);
if (placeholderLabel !== null) {
dropdown[dropdown.length] = new Option(placeholderLabel, placeholderValue);
}
populateProjectsDropdown(client_id);
}
// Empties a dropdown and adds its placeholder, returning the element - or null if
// this modal doesn't have it. Pass null as the label for a multi-select, which has
// no placeholder option of its own.
function resetDropdown(id, placeholderLabel, placeholderValue) {
const dropdown = document.getElementById(id);
if (!dropdown) {
return null;
return dropdown;
}
// innerHTML rather than removing options one by one, which leaves empty optgroups behind
dropdown.innerHTML = '';
// A multi-select keeps showing its old selections until select2 is told the value changed
$(dropdown).val(null).trigger('change.select2');
if (placeholderLabel !== null) {
dropdown[dropdown.length] = new Option(placeholderLabel, placeholderValue);
}
return dropdown;
}
// Redraws a select2 component after its options have been replaced
function refreshDropdown(dropdown) {
if (dropdown) {
$(dropdown).trigger('change.select2');
}
}
// Re-applies the value the modal was opened with (e.g. ticket_add.php?project_id=4),
// which can only be selected once the options it refers to exist
function applyPreselection(dropdown) {
if (!hasPreselection(dropdown)) {
return;
}
dropdown.value = dropdown.dataset.selected;
}
// True when the modal was opened with a specific value for this dropdown.
// '0' is the "none selected" value every one of these dropdowns uses, and is
// truthy as a string - so it has to be excluded explicitly.
function hasPreselection(dropdown) {
return Boolean(dropdown && dropdown.dataset.selected && dropdown.dataset.selected !== '0');
}
// Adds an optgroup to a dropdown and returns it, so options can be appended into it
function appendOptionGroup(dropdown, label) {
if (!dropdown) {
return null;
}
const group = document.createElement("optgroup");
group.label = label;
dropdown.appendChild(group);
return group;
}
// Adds an option to an optgroup
function appendGroupedOption(group, label, value) {
if (!group) {
return;
}
group.appendChild(new Option(label, value));
}
// Builds the asset label as "Name - Make Model - (Contact)", matching how assets read elsewhere
function buildAssetLabel(asset) {
let label = asset.asset_name;
if (asset.asset_make) {
label = label + " - " + asset.asset_make;
if (asset.asset_model) {
label = label + " " + asset.asset_model;
// Redraws a select2 component after its options have been replaced
function refreshDropdown(dropdown) {
if (dropdown) {
$(dropdown).trigger('change.select2');
}
}
if (asset.contact_name) {
label = label + " - (" + asset.contact_name + ")";
// Re-applies the value the modal was opened with (e.g. ticket_add.php?project_id=4),
// which can only be selected once the options it refers to exist
function applyPreselection(dropdown) {
if (!hasPreselection(dropdown)) {
return;
}
dropdown.value = dropdown.dataset.selected;
}
return label;
}
// Populate client contacts - one request feeds both the contact picker and the
// watchers list, as both are built from the same set of people
function populateContactsDropdown(client_id) {
if (!document.getElementById("contactSelect") && !document.getElementById("watchersSelect")) {
return;
// True when the modal was opened with a specific value for this dropdown.
// '0' is the "none selected" value every one of these dropdowns uses, and is
// truthy as a string - so it has to be excluded explicitly.
function hasPreselection(dropdown) {
return Boolean(dropdown && dropdown.dataset.selected && dropdown.dataset.selected !== '0');
}
// Send a GET request to ajax.php as ajax.php?get_client_contacts=true&client_id=NUM
jQuery.get(
"ajax.php",
{get_client_contacts: 'true', client_id: client_id},
function(data) {
// Adds an optgroup to a dropdown and returns it, so options can be appended into it
function appendOptionGroup(dropdown, label) {
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
if (!dropdown) {
return null;
}
// Access the data for contacts (multiple)
const contacts = response.contacts || [];
const group = document.createElement("optgroup");
group.label = label;
dropdown.appendChild(group);
// Contacts dropdown
const contactSelectDropdown = resetDropdown("contactSelect", '- No One -', '0');
return group;
}
// Watchers is a tags field - any address can be typed in, these are just the handy ones
const watchersDropdown = resetDropdown("watchersSelect", null, null);
// Adds an option to an optgroup
function appendGroupedOption(group, label, value) {
// Populate dropdown
contacts.forEach(contact => {
var appendText = "";
if (contact.contact_title) {
appendText = " - " + contact.contact_title;
}
if (contact.contact_primary == "1") {
appendText = appendText + " (Primary)";
} else if (contact.contact_technical == "1") {
appendText = appendText + " (Technical)";
}
if (!group) {
return;
}
if (contactSelectDropdown) {
contactSelectDropdown[contactSelectDropdown.length] = new Option(contact.contact_name + appendText, contact.contact_id);
}
group.appendChild(new Option(label, value));
}
if (watchersDropdown && contact.contact_email) {
watchersDropdown[watchersDropdown.length] = new Option(contact.contact_email, contact.contact_email);
}
});
// Builds the asset label as "Name - Make Model - (Contact)", matching how assets read elsewhere
function buildAssetLabel(asset) {
// Default to the client's primary contact unless the modal was opened for a
// specific one. Contacts arrive primary-first.
if (contactSelectDropdown && !hasPreselection(contactSelectDropdown)) {
const primaryContact = contacts.find(contact => contact.contact_primary == "1");
let label = asset.asset_name;
if (primaryContact) {
contactSelectDropdown.value = primaryContact.contact_id;
}
if (asset.asset_make) {
label = label + " - " + asset.asset_make;
if (asset.asset_model) {
label = label + " " + asset.asset_model;
}
applyPreselection(contactSelectDropdown);
refreshDropdown(contactSelectDropdown);
refreshDropdown(watchersDropdown);
}
);
}
// Populate client assets - feeds both the single asset picker and the additional assets
// multi-select from one request, as both need the same list
function populateAssetsDropdowns(client_id) {
if (asset.contact_name) {
label = label + " - (" + asset.contact_name + ")";
}
if (!document.getElementById("assetSelect") && !document.getElementById("additionalAssetsSelect")) {
return;
return label;
}
jQuery.get(
"ajax.php",
{get_client_assets: 'true', client_id: client_id},
function(data) {
// Populate client contacts - one request feeds both the contact picker and the
// watchers list, as both are built from the same set of people
function populateContactsDropdown(client_id) {
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
if (!document.getElementById("contactSelect") && !document.getElementById("watchersSelect")) {
return;
}
// Access the data for assets (multiple)
const assets = response.assets || [];
// Send a GET request to ajax.php as ajax.php?get_client_contacts=true&client_id=NUM
jQuery.get(
"ajax.php",
{get_client_contacts: 'true', client_id: client_id},
function(data) {
const assetSelectDropdown = resetDropdown("assetSelect", '- None -', '0');
const additionalAssetsDropdown = resetDropdown("additionalAssetsSelect", null, null);
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Assets arrive ordered by type, so a change of type starts a new group
let currentType = null;
let assetGroup = null;
let additionalAssetGroup = null;
// Access the data for contacts (multiple)
const contacts = response.contacts || [];
assets.forEach(asset => {
const assetType = asset.asset_type || 'Uncategorized';
// Contacts dropdown
const contactSelectDropdown = resetDropdown("contactSelect", '- No One -', '0');
if (assetType !== currentType) {
currentType = assetType;
assetGroup = appendOptionGroup(assetSelectDropdown, assetType);
additionalAssetGroup = appendOptionGroup(additionalAssetsDropdown, assetType);
// Watchers is a tags field - any address can be typed in, these are just the handy ones
const watchersDropdown = resetDropdown("watchersSelect", null, null);
// Populate dropdown
contacts.forEach(contact => {
var appendText = "";
if (contact.contact_title) {
appendText = " - " + contact.contact_title;
}
if (contact.contact_primary == "1") {
appendText = appendText + " (Primary)";
} else if (contact.contact_technical == "1") {
appendText = appendText + " (Technical)";
}
if (contactSelectDropdown) {
contactSelectDropdown[contactSelectDropdown.length] = new Option(contact.contact_name + appendText, contact.contact_id);
}
if (watchersDropdown && contact.contact_email) {
watchersDropdown[watchersDropdown.length] = new Option(contact.contact_email, contact.contact_email);
}
});
// Default to the client's primary contact unless the modal was opened for a
// specific one. Contacts arrive primary-first.
if (contactSelectDropdown && !hasPreselection(contactSelectDropdown)) {
const primaryContact = contacts.find(contact => contact.contact_primary == "1");
if (primaryContact) {
contactSelectDropdown.value = primaryContact.contact_id;
}
}
const assetLabel = buildAssetLabel(asset);
applyPreselection(contactSelectDropdown);
appendGroupedOption(assetGroup, assetLabel, asset.asset_id);
appendGroupedOption(additionalAssetGroup, assetLabel, asset.asset_id);
});
refreshDropdown(contactSelectDropdown);
refreshDropdown(watchersDropdown);
applyPreselection(assetSelectDropdown);
refreshDropdown(assetSelectDropdown);
refreshDropdown(additionalAssetsDropdown);
}
);
}
// Populate client locations
function populateLocationsDropdown(client_id) {
if (!document.getElementById("locationSelect")) {
return;
}
);
}
jQuery.get(
"ajax.php",
{get_client_locations: 'true', client_id: client_id},
function(data) {
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Access the data for locations (multiple)
const locations = response.locations || [];
// Locations dropdown
const locationSelectDropdown = resetDropdown("locationSelect", '- Location -', '0');
// Populate dropdown
locations.forEach(location => {
locationSelectDropdown[locationSelectDropdown.length] = new Option(location.location_name, location.location_id);
});
applyPreselection(locationSelectDropdown);
refreshDropdown(locationSelectDropdown);
// Populate client assets - feeds both the single asset picker and the additional assets
// multi-select from one request, as both need the same list
function populateAssetsDropdowns(client_id) {
if (!document.getElementById("assetSelect") && !document.getElementById("additionalAssetsSelect")) {
return;
}
);
}
// Populate client vendors
function populateVendorsDropdown(client_id) {
jQuery.get(
"ajax.php",
{get_client_assets: 'true', client_id: client_id},
function(data) {
if (!document.getElementById("vendorSelect")) {
return;
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Access the data for assets (multiple)
const assets = response.assets || [];
const assetSelectDropdown = resetDropdown("assetSelect", '- None -', '0');
const additionalAssetsDropdown = resetDropdown("additionalAssetsSelect", null, null);
// Assets arrive ordered by type, so a change of type starts a new group
let currentType = null;
let assetGroup = null;
let additionalAssetGroup = null;
assets.forEach(asset => {
const assetType = asset.asset_type || 'Uncategorized';
if (assetType !== currentType) {
currentType = assetType;
assetGroup = appendOptionGroup(assetSelectDropdown, assetType);
additionalAssetGroup = appendOptionGroup(additionalAssetsDropdown, assetType);
}
const assetLabel = buildAssetLabel(asset);
appendGroupedOption(assetGroup, assetLabel, asset.asset_id);
appendGroupedOption(additionalAssetGroup, assetLabel, asset.asset_id);
});
applyPreselection(assetSelectDropdown);
refreshDropdown(assetSelectDropdown);
refreshDropdown(additionalAssetsDropdown);
}
);
}
jQuery.get(
"ajax.php",
{get_client_vendors: 'true', client_id: client_id},
function(data) {
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Access the data for vendors (multiple)
const vendors = response.vendors || [];
// Vendors dropdown
const vendorSelectDropdown = resetDropdown("vendorSelect", '- Vendor -', '0');
// Populate dropdown
vendors.forEach(vendor => {
vendorSelectDropdown[vendorSelectDropdown.length] = new Option(vendor.vendor_name, vendor.vendor_id);
});
applyPreselection(vendorSelectDropdown);
refreshDropdown(vendorSelectDropdown);
// Populate client locations
function populateLocationsDropdown(client_id) {
if (!document.getElementById("locationSelect")) {
return;
}
);
}
// Populate client projects
function populateProjectsDropdown(client_id) {
jQuery.get(
"ajax.php",
{get_client_locations: 'true', client_id: client_id},
function(data) {
if (!document.getElementById("projectSelect")) {
return;
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Access the data for locations (multiple)
const locations = response.locations || [];
// Locations dropdown
const locationSelectDropdown = resetDropdown("locationSelect", '- Location -', '0');
// Populate dropdown
locations.forEach(location => {
locationSelectDropdown[locationSelectDropdown.length] = new Option(location.location_name, location.location_id);
});
applyPreselection(locationSelectDropdown);
refreshDropdown(locationSelectDropdown);
}
);
}
jQuery.get(
"ajax.php",
{get_client_projects: 'true', client_id: client_id},
function(data) {
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Access the data for projects (multiple)
const projects = response.projects || [];
// Projects dropdown
const projectSelectDropdown = resetDropdown("projectSelect", '- Select Project -', '0');
// Populate dropdown
projects.forEach(project => {
projectSelectDropdown[projectSelectDropdown.length] = new Option(project.project_name, project.project_id);
});
applyPreselection(projectSelectDropdown);
refreshDropdown(projectSelectDropdown);
// Populate client vendors
function populateVendorsDropdown(client_id) {
if (!document.getElementById("vendorSelect")) {
return;
}
);
}
jQuery.get(
"ajax.php",
{get_client_vendors: 'true', client_id: client_id},
function(data) {
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Access the data for vendors (multiple)
const vendors = response.vendors || [];
// Vendors dropdown
const vendorSelectDropdown = resetDropdown("vendorSelect", '- Vendor -', '0');
// Populate dropdown
vendors.forEach(vendor => {
vendorSelectDropdown[vendorSelectDropdown.length] = new Option(vendor.vendor_name, vendor.vendor_id);
});
applyPreselection(vendorSelectDropdown);
refreshDropdown(vendorSelectDropdown);
}
);
}
// Populate client projects
function populateProjectsDropdown(client_id) {
if (!document.getElementById("projectSelect")) {
return;
}
jQuery.get(
"ajax.php",
{get_client_projects: 'true', client_id: client_id},
function(data) {
// If we get a response from ajax.php, parse it as JSON
const response = JSON.parse(data);
// Access the data for projects (multiple)
const projects = response.projects || [];
// Projects dropdown
const projectSelectDropdown = resetDropdown("projectSelect", '- Select Project -', '0');
// Populate dropdown
projects.forEach(project => {
projectSelectDropdown[projectSelectDropdown.length] = new Option(project.project_name, project.project_id);
});
applyPreselection(projectSelectDropdown);
refreshDropdown(projectSelectDropdown);
}
);
}
})();

View File

@@ -28,6 +28,9 @@ ob_start();
<li class="nav-item">
<a class="nav-link active" data-toggle="pill" href="#pills-add-details"><i class="fa fa-fw fa-life-ring mr-2"></i>Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-add-tasks"><i class="fa fa-fw fa-tasks mr-2"></i>Tasks</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-add-schedule"><i class="fa fa-fw fa-building mr-2"></i>Schedule</a>
</li>
@@ -83,46 +86,7 @@ ob_start();
<?php } ?>
<div class="form-group">
<label>Template</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
</div>
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id">
<option value="0">- No Template -</option>
<?php
$sql_ticket_templates = mysqli_query($mysqli, "
SELECT tt.ticket_template_id,
tt.ticket_template_name,
tt.ticket_template_subject,
tt.ticket_template_details,
COUNT(ttt.task_template_id) as task_count
FROM ticket_templates tt
LEFT JOIN task_templates ttt
ON tt.ticket_template_id = ttt.task_template_ticket_template_id
WHERE tt.ticket_template_archived_at IS NULL
GROUP BY tt.ticket_template_id
ORDER BY tt.ticket_template_name ASC
");
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
$ticket_template_id_select = intval($row['ticket_template_id']);
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
$task_count = intval($row['task_count']);
?>
<option value="<?= $ticket_template_id_select ?>"
data-subject="<?= $ticket_template_subject_select ?>"
data-details="<?= $ticket_template_details_select ?>">
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
</option>
<?php } ?>
</select>
</div>
<small class="form-text text-muted">The template's tasks are added to every ticket this schedule raises.</small>
</div>
<?php require_once '../../includes/inc_ticket_template_select.php'; ?>
<div class="form-group">
<label>Subject <strong class="text-danger">*</strong></label>
@@ -224,6 +188,12 @@ ob_start();
</div>
<div class="tab-pane fade" id="pills-add-tasks">
<?php require_once '../../includes/inc_ticket_tasks_section.php'; ?>
</div>
<div class="tab-pane fade" id="pills-add-schedule">
<div class="form-group">
@@ -300,40 +270,14 @@ ob_start();
</div>
</form>
<!-- Ticket Templates -->
<script>
$(document).on('change', '#ticket_template_select', function () {
const $opt = $(this).find(':selected');
// Selecting "- No Template -" only unlinks the template - it must not wipe
// whatever subject/details the user has already written
if (!parseInt($opt.val(), 10)) {
return;
}
const templateSubject = $opt.data('subject') || '';
const templateDetails = $opt.data('details') || '';
$('#subjectInput').val(templateSubject);
if (window.tinymce) {
const editor = tinymce.get('detailsInput');
if (editor) {
editor.setContent(templateDetails);
} else {
$('#detailsInput').val(templateDetails);
}
} else {
$('#detailsInput').val(templateDetails);
}
});
</script>
<!-- Recurring Ticket Client/Contact JS -->
<link rel="stylesheet" href="/libs/jquery-ui/jquery-ui.min.css">
<script src="/libs/jquery-ui/jquery-ui.min.js"></script>
<script src="/agent/js/tickets_add_modal.js"></script>
<script src="/agent/js/ticket_tasks_modal.js"></script>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -22,6 +22,22 @@ $recurring_ticket_category = intval($row['recurring_ticket_category']);
$recurring_ticket_billable = intval($row['recurring_ticket_billable']);
$recurring_ticket_ticket_template_id = intval($row['recurring_ticket_ticket_template_id']);
// Tasks already on this schedule, pre-filling the editable rows
$existing_tasks = array();
$sql_recurring_ticket_tasks = mysqli_query(
$mysqli,
"SELECT recurring_ticket_task_name, recurring_ticket_task_completion_estimate
FROM recurring_ticket_tasks
WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id
ORDER BY recurring_ticket_task_order ASC, recurring_ticket_task_id ASC"
);
while ($row = mysqli_fetch_assoc($sql_recurring_ticket_tasks)) {
$existing_tasks[] = [
'name' => $row['recurring_ticket_task_name'],
'estimate' => intval($row['recurring_ticket_task_completion_estimate'])
];
}
// Additional Assets Selected
$additional_assets_array = array();
$sql_additional_assets = mysqli_query($mysqli, "SELECT asset_id FROM recurring_ticket_assets WHERE recurring_ticket_id = $recurring_ticket_id");
@@ -55,6 +71,9 @@ ob_start();
<li class="nav-item">
<a class="nav-link active" data-toggle="pill" href="#pills-edit-details"><i class="fa fa-fw fa-life-ring mr-2"></i>Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-edit-tasks"><i class="fa fa-fw fa-tasks mr-2"></i>Tasks</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-edit-contacts"><i class="fa fa-fw fa-users mr-2"></i>Contact</a>
</li>
@@ -70,47 +89,10 @@ ob_start();
<div class="tab-pane fade show active" id="pills-edit-details">
<div class="form-group">
<label>Template</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
</div>
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id">
<option value="0">- No Template -</option>
<?php
$sql_ticket_templates = mysqli_query($mysqli, "
SELECT tt.ticket_template_id,
tt.ticket_template_name,
tt.ticket_template_subject,
tt.ticket_template_details,
COUNT(ttt.task_template_id) as task_count
FROM ticket_templates tt
LEFT JOIN task_templates ttt
ON tt.ticket_template_id = ttt.task_template_ticket_template_id
WHERE (tt.ticket_template_archived_at IS NULL OR tt.ticket_template_id = $recurring_ticket_ticket_template_id)
GROUP BY tt.ticket_template_id
ORDER BY tt.ticket_template_name ASC
");
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
$ticket_template_id_select = intval($row['ticket_template_id']);
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
$task_count = intval($row['task_count']);
?>
<option value="<?= $ticket_template_id_select ?>"
data-subject="<?= $ticket_template_subject_select ?>"
data-details="<?= $ticket_template_details_select ?>"
<?php if ($recurring_ticket_ticket_template_id == $ticket_template_id_select) { echo "selected"; } ?>>
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
</option>
<?php } ?>
</select>
</div>
<small class="form-text text-muted">The template's tasks are added to every ticket this schedule raises. Changing it rewrites the subject and details below.</small>
</div>
<?php
$selected_ticket_template_id = $recurring_ticket_ticket_template_id;
require_once '../../includes/inc_ticket_template_select.php';
?>
<div class="form-group">
<label>Subject <strong class="text-danger">*</strong></label>
@@ -211,6 +193,12 @@ ob_start();
</div>
<div class="tab-pane fade" id="pills-edit-tasks">
<?php require_once '../../includes/inc_ticket_tasks_section.php'; ?>
</div>
<div class="tab-pane fade" id="pills-edit-contacts">
<div class="form-group">
@@ -349,34 +337,8 @@ ob_start();
</div>
</form>
<!-- Ticket Templates -->
<script>
$(document).on('change', '#ticket_template_select', function () {
const $opt = $(this).find(':selected');
// Selecting "- No Template -" only unlinks the template - it must not wipe
// whatever subject/details the user has already written
if (!parseInt($opt.val(), 10)) {
return;
}
const templateSubject = $opt.data('subject') || '';
const templateDetails = $opt.data('details') || '';
$('#subjectInput').val(templateSubject);
if (window.tinymce) {
const editor = tinymce.get('detailsInput');
if (editor) {
editor.setContent(templateDetails);
} else {
$('#detailsInput').val(templateDetails);
}
} else {
$('#detailsInput').val(templateDetails);
}
});
</script>
<script src="/agent/js/ticket_tasks_modal.js"></script>
<?php

View File

@@ -35,6 +35,9 @@ ob_start();
<li class="nav-item">
<a class="nav-link active" data-toggle="pill" href="#pills-add-details"><i class="fa fa-fw fa-life-ring mr-2"></i>Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-add-tasks"><i class="fa fa-fw fa-tasks mr-2"></i>Tasks</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-add-relationships"><i class="fa fa-fw fa-desktop mr-2"></i>Assignment</a>
</li>
@@ -86,45 +89,7 @@ ob_start();
</div>
</div>
<div class="form-group">
<label>Template</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
</div>
<select class="form-control select2" id="ticket_template_select" name="ticket_template_id" required>
<option value="0">- Choose a Template -</option>
<?php
$sql_ticket_templates = mysqli_query($mysqli, "
SELECT tt.ticket_template_id,
tt.ticket_template_name,
tt.ticket_template_subject,
tt.ticket_template_details,
COUNT(ttt.task_template_id) as task_count
FROM ticket_templates tt
LEFT JOIN task_templates ttt
ON tt.ticket_template_id = ttt.task_template_ticket_template_id
WHERE tt.ticket_template_archived_at IS NULL
GROUP BY tt.ticket_template_id
ORDER BY tt.ticket_template_name ASC
");
while ($row = mysqli_fetch_assoc($sql_ticket_templates)) {
$ticket_template_id_select = intval($row['ticket_template_id']);
$ticket_template_name_select = escapeHtml($row['ticket_template_name']);
$ticket_template_subject_select = escapeHtml($row['ticket_template_subject']);
$ticket_template_details_select = escapeHtml($row['ticket_template_details']);
$task_count = intval($row['task_count']);
?>
<option value="<?= $ticket_template_id_select ?>"
data-subject="<?= $ticket_template_subject_select ?>"
data-details="<?= $ticket_template_details_select ?>">
<?= $ticket_template_name_select ?> (<?= $task_count ?> tasks)
</option>
<?php } ?>
</select>
</div>
</div>
<?php require_once '../../includes/inc_ticket_template_select.php'; ?>
<div class="form-group">
<label>Subject <strong class="text-danger">*</strong></label>
@@ -240,6 +205,12 @@ ob_start();
</div>
<div class="tab-pane fade" id="pills-add-tasks">
<?php require_once '../../includes/inc_ticket_tasks_section.php'; ?>
</div>
<div class="tab-pane fade" id="pills-add-relationships">
<div class="form-group">
@@ -339,33 +310,14 @@ ob_start();
</form>
<!-- Ticket Templates -->
<script>
$(document).on('change', '#ticket_template_select', function () {
const $opt = $(this).find(':selected');
const templateSubject = $opt.data('subject') || '';
const templateDetails = $opt.data('details') || '';
$('#subjectInput').val(templateSubject);
if (window.tinymce) {
const editor = tinymce.get('detailsInput');
if (editor) {
editor.setContent(templateDetails);
} else {
$('#detailsInput').val(templateDetails);
}
} else {
$('#detailsInput').val(templateDetails);
}
});
</script>
<!-- Ticket Client/Contact JS -->
<link rel="stylesheet" href="/libs/jquery-ui/jquery-ui.min.css">
<script src="/libs/jquery-ui/jquery-ui.min.js"></script>
<script src="/agent/js/tickets_add_modal.js"></script>
<script src="/agent/js/ticket_tasks_modal.js"></script>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -32,6 +32,11 @@ if (isset($_POST['add_recurring_ticket'])) {
}
}
// Add Tasks - stamped onto every ticket this schedule raises
foreach (parseSubmittedTasks() as $task) {
mysqli_query($mysqli, "INSERT INTO recurring_ticket_tasks SET recurring_ticket_task_name = '{$task['name']}', recurring_ticket_task_order = {$task['order']}, recurring_ticket_task_completion_estimate = {$task['estimate']}, recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id");
}
logAudit("Recurring Ticket", "Create", "$session_name created recurring ticket for $subject - $frequency", $client_id, $recurring_ticket_id);
flashAlert("Recurring ticket <strong>$subject - $frequency</strong> created");
@@ -66,6 +71,15 @@ if (isset($_POST['edit_recurring_ticket'])) {
}
}
// Replace Tasks with whatever the modal submitted
if (isset($_POST['tasks_submitted'])) {
mysqli_query($mysqli, "DELETE FROM recurring_ticket_tasks WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id");
foreach (parseSubmittedTasks() as $task) {
mysqli_query($mysqli, "INSERT INTO recurring_ticket_tasks SET recurring_ticket_task_name = '{$task['name']}', recurring_ticket_task_order = {$task['order']}, recurring_ticket_task_completion_estimate = {$task['estimate']}, recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id");
}
}
logAudit("Recurring Ticket", "Edit", "$session_name edited recurring ticket $subject", $client_id, $recurring_ticket_id);
flashAlert("Recurring ticket <strong>$subject - $frequency</strong> updated");
@@ -102,7 +116,6 @@ if (isset($_POST['bulk_force_recurring_tickets'])) {
$client_id = intval($row['recurring_ticket_client_id']);
$asset_id = intval($row['recurring_ticket_asset_id']);
$category = intval($row['recurring_ticket_category']);
$ticket_template_id = intval($row['recurring_ticket_ticket_template_id']);
$url_key = randomString(32);
enforceClientAccess();
@@ -140,8 +153,8 @@ if (isset($_POST['bulk_force_recurring_tickets'])) {
FROM recurring_ticket_assets
WHERE recurring_ticket_id = $recurring_ticket_id");
// Copy Tasks from the linked ticket template, if one is set
addTasksFromTicketTemplate($id, $ticket_template_id);
// Copy Tasks from the schedule's own task list
addTasksFromRecurringTicket($id, $recurring_ticket_id);
// Notifications
@@ -247,7 +260,6 @@ if (isset($_GET['force_recurring_ticket'])) {
$client_id = intval($row['recurring_ticket_client_id']);
$asset_id = intval($row['recurring_ticket_asset_id']);
$category = intval($row['recurring_ticket_category']);
$ticket_template_id = intval($row['recurring_ticket_ticket_template_id']);
$url_key = randomString(32);
enforceClientAccess();
@@ -285,8 +297,8 @@ if (isset($_GET['force_recurring_ticket'])) {
FROM recurring_ticket_assets
WHERE recurring_ticket_id = $recurring_ticket_id");
// Copy Tasks from the linked ticket template, if one is set
addTasksFromTicketTemplate($id, $ticket_template_id);
// Copy Tasks from the schedule's own task list
addTasksFromRecurringTicket($id, $recurring_ticket_id);
// Notifications

View File

@@ -79,8 +79,15 @@ if (isset($_POST['add_ticket'])) {
$ticket_id = mysqli_insert_id($mysqli);
applyTicketSla($ticket_id);
// Add Tasks from Template if Template was selected
addTasksFromTicketTemplate($ticket_id, $ticket_template_id);
// Tasks come from the editable rows in the modal, which the template pre-fills.
// Fall back to copying the template directly for a form without that section.
if (isset($_POST['tasks_submitted'])) {
foreach (parseSubmittedTasks() as $task) {
mysqli_query($mysqli, "INSERT INTO tasks SET task_name = '{$task['name']}', task_order = {$task['order']}, task_completion_estimate = {$task['estimate']}, task_ticket_id = $ticket_id");
}
} else {
addTasksFromTicketTemplate($ticket_id, $ticket_template_id);
}
// Add Watchers
if (isset($_POST['watchers'])) {

View File

@@ -55,7 +55,7 @@ if (isset($_GET['billable']) && $_GET['billable'] == 1) {
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS *,
(SELECT COUNT(task_template_id) FROM task_templates WHERE task_template_ticket_template_id = recurring_ticket_ticket_template_id) AS template_task_count
(SELECT COUNT(recurring_ticket_task_id) FROM recurring_ticket_tasks WHERE recurring_ticket_task_recurring_ticket_id = recurring_ticket_id) AS recurring_ticket_task_count
FROM recurring_tickets
LEFT JOIN clients ON recurring_ticket_client_id = client_id
LEFT JOIN categories ON category_id = recurring_ticket_category
@@ -299,7 +299,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$recurring_ticket_client_name = escapeHtml($row['client_name']);
$assigned_to = escapeHtml($row['user_name']) ?: '-';
$recurring_ticket_template_name = escapeHtml($row['ticket_template_name']);
$recurring_ticket_template_task_count = intval($row['template_task_count']);
$recurring_ticket_task_count = intval($row['recurring_ticket_task_count']);
?>
<tr>
@@ -315,12 +315,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
data-modal-url="modals/recurring_ticket/recurring_ticket_edit.php?id=<?= $recurring_ticket_id ?>">
<?= $recurring_ticket_subject ?>
</a>
<?php if ($recurring_ticket_template_name) { ?>
<?php if ($recurring_ticket_task_count) { ?>
<span class="badge badge-secondary"
data-toggle="tooltip"
data-placement="top"
title="Template: <?= $recurring_ticket_template_name ?> - adds <?= $recurring_ticket_template_task_count ?> task<?php if ($recurring_ticket_template_task_count != 1) { echo "s"; } ?> to each ticket raised">
<i class="fa fa-fw fa-cube"></i><?= $recurring_ticket_template_task_count ?>
title="Adds <?= $recurring_ticket_task_count ?> task<?php if ($recurring_ticket_task_count != 1) { echo "s"; } ?> to each ticket raised<?php if ($recurring_ticket_template_name) { ?>, from template: <?= $recurring_ticket_template_name ?><?php } ?>">
<i class="fa fa-fw fa-tasks"></i><?= $recurring_ticket_task_count ?>
</span>
<?php } ?>
</td>