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.
+
+ 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();