diff --git a/admin/database_updates/2.5.6.php b/admin/database_updates/2.5.6.php new file mode 100644 index 00000000..c73a7fff --- /dev/null +++ b/admin/database_updates/2.5.6.php @@ -0,0 +1,34 @@ + 0"); diff --git a/agent/includes/inc_ticket_tasks_section.php b/agent/includes/inc_ticket_tasks_section.php new file mode 100644 index 00000000..0174b3b6 --- /dev/null +++ b/agent/includes/inc_ticket_tasks_section.php @@ -0,0 +1,53 @@ + 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 ?? []; + +?> + + + +
+ + +
+
Task
+
Estimate (mins)
+
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + Leave the estimate blank if you don't track one. Blank task names are ignored. +
diff --git a/agent/includes/inc_ticket_template_select.php b/agent/includes/inc_ticket_template_select.php new file mode 100644 index 00000000..312df26e --- /dev/null +++ b/agent/includes/inc_ticket_template_select.php @@ -0,0 +1,73 @@ + $row['task_template_name'], + 'estimate' => intval($row['task_template_completion_estimate']) + ]; +} + +?> + +
+ +
+
+ +
+ +
+ Picking a template fills in the subject, details and tasks below. You can edit them afterwards. +
diff --git a/agent/js/ticket_tasks_modal.js b/agent/js/ticket_tasks_modal.js new file mode 100644 index 00000000..59ef202b --- /dev/null +++ b/agent/js/ticket_tasks_modal.js @@ -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 = ''; + 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)); + }); + +})(); diff --git a/agent/js/tickets_add_modal.js b/agent/js/tickets_add_modal.js index 3ac04623..9cbcd9db 100644 --- a/agent/js/tickets_add_modal.js +++ b/agent/js/tickets_add_modal.js @@ -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); + + } + ); + } + +})(); diff --git a/agent/modals/recurring_ticket/recurring_ticket_add.php b/agent/modals/recurring_ticket/recurring_ticket_add.php index 42f19777..56f2c9b7 100644 --- a/agent/modals/recurring_ticket/recurring_ticket_add.php +++ b/agent/modals/recurring_ticket/recurring_ticket_add.php @@ -28,6 +28,9 @@ ob_start(); + @@ -83,46 +86,7 @@ ob_start(); -
- -
-
- -
- -
- The template's tasks are added to every ticket this schedule raises. -
+
@@ -224,6 +188,12 @@ ob_start();
+
+ + + +
+
@@ -300,40 +270,14 @@ ob_start();
- - + + $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(); + @@ -70,47 +89,10 @@ ob_start();
-
- -
-
- -
- -
- The template's tasks are added to every ticket this schedule raises. Changing it rewrites the subject and details below. -
+
@@ -211,6 +193,12 @@ ob_start();
+
+ + + +
+
@@ -349,34 +337,8 @@ ob_start();
- - + Details + @@ -86,45 +89,7 @@ ob_start();
-
- -
-
- -
- -
-
+
@@ -240,6 +205,12 @@ ob_start();
+
+ + + +
+
@@ -339,33 +310,14 @@ ob_start(); - - + + $subject - $frequency 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 $subject - $frequency 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 diff --git a/agent/post/ticket.php b/agent/post/ticket.php index aa991a32..76c0a3fd 100644 --- a/agent/post/ticket.php +++ b/agent/post/ticket.php @@ -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'])) { diff --git a/agent/recurring_tickets.php b/agent/recurring_tickets.php index 97d33479..8f1696e0 100644 --- a/agent/recurring_tickets.php +++ b/agent/recurring_tickets.php @@ -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']); ?> @@ -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="> - + to each ticket raised"> - + title="Adds task to each ticket raised, from template: "> + diff --git a/cron/cron.php b/cron/cron.php index aca110c9..a71f018e 100644 --- a/cron/cron.php +++ b/cron/cron.php @@ -317,7 +317,6 @@ if (mysqli_num_rows($sql_recurring_tickets) > 0) { $contact_id = intval($row['recurring_ticket_contact_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); $ticket_status = 1; // Default @@ -353,8 +352,8 @@ if (mysqli_num_rows($sql_recurring_tickets) > 0) { 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); // Logging logAudit("Ticket", "Create", "Cron created recurring scheduled $frequency ticket - $subject", $client_id, $id); diff --git a/db.sql b/db.sql index bfe5d03b..4a1c22ab 100644 --- a/db.sql +++ b/db.sql @@ -1913,6 +1913,25 @@ CREATE TABLE `recurring_ticket_assets` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `recurring_ticket_tasks` +-- + +DROP TABLE IF EXISTS `recurring_ticket_tasks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `recurring_ticket_tasks` ( + `recurring_ticket_task_id` int(11) NOT NULL AUTO_INCREMENT, + `recurring_ticket_task_name` varchar(255) NOT NULL, + `recurring_ticket_task_order` int(11) NOT NULL DEFAULT 0, + `recurring_ticket_task_completion_estimate` int(11) NOT NULL DEFAULT 0, + `recurring_ticket_task_recurring_ticket_id` int(11) NOT NULL, + PRIMARY KEY (`recurring_ticket_task_id`), + KEY `recurring_ticket_task_recurring_ticket_id` (`recurring_ticket_task_recurring_ticket_id`), + CONSTRAINT `recurring_ticket_tasks_ibfk_1` FOREIGN KEY (`recurring_ticket_task_recurring_ticket_id`) REFERENCES `recurring_tickets` (`recurring_ticket_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `recurring_tickets` -- @@ -3067,4 +3086,4 @@ CREATE TABLE `vendors` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-07-29 16:11:10 +-- Dump completed on 2026-07-29 17:47:46 diff --git a/functions/app.php b/functions/app.php index 11a6a66a..55311571 100644 --- a/functions/app.php +++ b/functions/app.php @@ -113,6 +113,78 @@ function addTasksFromTicketTemplate($ticket_id, $ticket_template_id) { } +/** + * Copies a recurring ticket's task list onto a ticket it has just raised. + * + * Recurring tickets own their tasks (see recurring_ticket_tasks) rather than + * reading the linked ticket template at run time, so that a schedule's task + * list can be edited without touching the template or any other schedule. + * + * @param int $ticket_id The ticket to attach the tasks to. + * @param int $recurring_ticket_id The schedule to copy tasks from. 0 = no-op. + * + * @return void + */ +function addTasksFromRecurringTicket($ticket_id, $recurring_ticket_id) { + + global $mysqli; + + $ticket_id = intval($ticket_id); + $recurring_ticket_id = intval($recurring_ticket_id); + + if (!$ticket_id || !$recurring_ticket_id) { + return; + } + + mysqli_query($mysqli, "INSERT INTO tasks (task_name, task_order, task_completion_estimate, task_ticket_id) + SELECT recurring_ticket_task_name, recurring_ticket_task_order, recurring_ticket_task_completion_estimate, $ticket_id + FROM recurring_ticket_tasks + WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id + ORDER BY recurring_ticket_task_order ASC"); + +} + +/** + * Reads the editable task rows posted by the ticket and recurring ticket modals. + * + * The rows submit as parallel tasks[] and task_estimates[] arrays, aligned by + * their order in the form. Rows left blank are dropped, and the order is taken + * from the surviving rows rather than the raw array index. + * + * Names come back already escaped for SQL, as every caller inserts them. + * + * @return array List of ['name' => string, 'order' => int, 'estimate' => int] + */ +function parseSubmittedTasks() { + + $tasks = []; + + if (empty($_POST['tasks']) || !is_array($_POST['tasks'])) { + return $tasks; + } + + $estimates = $_POST['task_estimates'] ?? []; + $task_order = 0; + + foreach ($_POST['tasks'] as $index => $task_name) { + $task_name = trim($task_name); + + if ($task_name === '') { + continue; + } + + $tasks[] = [ + 'name' => escapeSql($task_name), + 'order' => $task_order, + 'estimate' => intval($estimates[$index] ?? 0) + ]; + + $task_order++; + } + + return $tasks; +} + /** * Retrieves a specified field's value from a table based on the record's id. * It validates the table and field names, automatically determines the primary key (or uses the first column as fallback),