diff --git a/agent/ajax.php b/agent/ajax.php index 8ee9ea03..021a38ed 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -324,6 +324,9 @@ if (isset($_GET['get_client_contacts'])) { ORDER BY contact_primary DESC, contact_technical DESC, contact_important DESC, contact_name" ); + // Always return the key, so a client with none gives [] rather than null + $response['contacts'] = []; + while ($row = mysqli_fetch_assoc($contact_sql)) { $response['contacts'][] = $row; } @@ -343,14 +346,17 @@ if (isset($_GET['get_client_assets'])) { $asset_sql = mysqli_query( $mysqli, - "SELECT asset_id, asset_name, contact_name FROM assets + "SELECT asset_id, asset_name, asset_type, asset_make, asset_model, contact_name FROM assets LEFT JOIN clients on asset_client_id = client_id LEFT JOIN contacts ON contact_id = asset_contact_id WHERE assets.asset_archived_at IS NULL AND asset_client_id = $client_id $access_permission_query - ORDER BY asset_favorite DESC, asset_name" + ORDER BY asset_type ASC, asset_favorite DESC, asset_name" ); + // Always return the key, so a client with no assets gives [] rather than null + $response['assets'] = []; + while ($row = mysqli_fetch_assoc($asset_sql)) { $response['assets'][] = $row; } @@ -377,6 +383,9 @@ if (isset($_GET['get_client_locations'])) { ORDER BY location_primary DESC, location_name ASC" ); + // Always return the key, so a client with none gives [] rather than null + $response['locations'] = []; + while ($row = mysqli_fetch_assoc($locations_sql)) { $response['locations'][] = $row; } @@ -403,6 +412,9 @@ if (isset($_GET['get_client_vendors'])) { ORDER BY vendor_name ASC" ); + // Always return the key, so a client with none gives [] rather than null + $response['vendors'] = []; + while ($row = mysqli_fetch_assoc($vendors_sql)) { $response['vendors'][] = $row; } @@ -410,6 +422,35 @@ if (isset($_GET['get_client_vendors'])) { echo json_encode($response); } +/* + * Returns open projects for a specified client + */ +if (isset($_GET['get_client_projects'])) { + enforceUserPermission('module_client'); + + $client_id = intval($_GET['client_id']); + + enforceClientAccess(); + + $projects_sql = mysqli_query( + $mysqli, + "SELECT project_id, project_name FROM projects + LEFT JOIN clients on project_client_id = client_id + WHERE projects.project_archived_at IS NULL AND projects.project_completed_at IS NULL AND project_client_id = $client_id + $access_permission_query + ORDER BY project_name ASC" + ); + + // Always return the key, so a client with none gives [] rather than null + $response['projects'] = []; + + while ($row = mysqli_fetch_assoc($projects_sql)) { + $response['projects'][] = $row; + } + + echo json_encode($response); +} + /* * NEW TOTP getter for client login/passwords page * When provided with a login ID, checks permissions and returns the 6-digit code diff --git a/agent/js/tickets_add_modal.js b/agent/js/tickets_add_modal.js index 154e29f3..4008d9c9 100644 --- a/agent/js/tickets_add_modal.js +++ b/agent/js/tickets_add_modal.js @@ -1,42 +1,156 @@ // Used to populate dynamic content in recurring_ticket_add_modal and ticket_add_modal_v2 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 +// hidden field. Everything below therefore checks that an element exists before +// touching it, rather than assuming the full set is present. + // 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 -// // If the client selector is disabled, we must be on a client-specific page instead. Trigger the lists to update. -if (clientSelectDropdown.disabled) { +if (clientSelectDropdown) { - let client_id = $(clientSelectDropdown).find(':selected').val(); + // 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(); - - // Update the contacts dropdown list - populateLists(client_id); - -}); - // Populates dropdowns with dynamic content based on the client ID -// Called the client select dropdown is used or if the client select is disabled +// Called when the client select dropdown is used or if the client select is disabled function populateLists(client_id) { populateContactsDropdown(client_id); - populateAssetsDropdown(client_id); + populateAssetsDropdowns(client_id); populateLocationsDropdown(client_id); populateVendorsDropdown(client_id); + + 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; + } + + // 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_v2.php?project_id=4), +// which can only be selected once the options it refers to exist +function applyPreselection(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 + if (!dropdown || !dropdown.dataset.selected || dropdown.dataset.selected === '0') { + return; + } + + dropdown.value = dropdown.dataset.selected; +} + +// 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; + } + } + + if (asset.contact_name) { + label = label + " - (" + asset.contact_name + ")"; + } + + return label; } // Populate client contacts function populateContactsDropdown(client_id) { + + if (!document.getElementById("contactSelect")) { + return; + } + // Send a GET request to ajax.php as ajax.php?get_client_contacts=true&client_id=NUM jQuery.get( "ajax.php", @@ -47,17 +161,10 @@ function populateContactsDropdown(client_id) { const response = JSON.parse(data); // Access the data for contacts (multiple) - const contacts = response.contacts; + const contacts = response.contacts || []; // Contacts dropdown - const contactSelectDropdown = document.getElementById("contactSelect"); - - // Clear dropdown - let i, L = contactSelectDropdown.options.length - 1; - for (i = L; i >= 0; i--) { - contactSelectDropdown.remove(i); - } - contactSelectDropdown[contactSelectDropdown.length] = new Option('- Contact -', '0'); + const contactSelectDropdown = resetDropdown("contactSelect", '- Contact -', '0'); // Populate dropdown contacts.forEach(contact => { @@ -70,12 +177,22 @@ function populateContactsDropdown(client_id) { contactSelectDropdown[contactSelectDropdown.length] = new Option(contact.contact_name + appendText, contact.contact_id); }); + applyPreselection(contactSelectDropdown); + + refreshDropdown(contactSelectDropdown); + } ); } -// Populate client assets -function populateAssetsDropdown(client_id) { +// 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; + } + jQuery.get( "ajax.php", {get_client_assets: 'true', client_id: client_id}, @@ -85,34 +202,47 @@ function populateAssetsDropdown(client_id) { const response = JSON.parse(data); // Access the data for assets (multiple) - const assets = response.assets; + const assets = response.assets || []; - // Assets dropdown - const assetSelectDropdown = document.getElementById("assetSelect"); + const assetSelectDropdown = resetDropdown("assetSelect", '- None -', '0'); + const additionalAssetsDropdown = resetDropdown("additionalAssetsSelect", null, null); - // Clear dropdown - let i, L = assetSelectDropdown.options.length - 1; - for (i = L; i >= 0; i--) { - assetSelectDropdown.remove(i); - } - assetSelectDropdown[assetSelectDropdown.length] = new Option('- Asset -', '0'); + // Assets arrive ordered by type, so a change of type starts a new group + let currentType = null; + let assetGroup = null; + let additionalAssetGroup = null; - // Populate dropdown with asset name (and contact, if set) assets.forEach(asset => { - let displayText = asset.asset_name; - if (asset.contact_name !== null) { - displayText = asset.asset_name + " - " + asset.contact_name; + const assetType = asset.asset_type || 'Uncategorized'; + + if (assetType !== currentType) { + currentType = assetType; + assetGroup = appendOptionGroup(assetSelectDropdown, assetType); + additionalAssetGroup = appendOptionGroup(additionalAssetsDropdown, assetType); } - assetSelectDropdown[assetSelectDropdown.length] = new Option(displayText, asset.asset_id); + const assetLabel = buildAssetLabel(asset); + + appendGroupedOption(assetGroup, assetLabel, asset.asset_id); + appendGroupedOption(additionalAssetGroup, assetLabel, asset.asset_id); }); + 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}, @@ -122,29 +252,31 @@ function populateLocationsDropdown(client_id) { const response = JSON.parse(data); // Access the data for locations (multiple) - const locations = response.locations; + const locations = response.locations || []; // Locations dropdown - const locationSelectDropdown = document.getElementById("locationSelect"); - - // Clear dropdown - let i, L = locationSelectDropdown.options.length - 1; - for (i = L; i >= 0; i--) { - locationSelectDropdown.remove(i); - } - locationSelectDropdown[locationSelectDropdown.length] = new Option('- Location -', '0'); + 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 vendors function populateVendorsDropdown(client_id) { + + if (!document.getElementById("vendorSelect")) { + return; + } + jQuery.get( "ajax.php", {get_client_vendors: 'true', client_id: client_id}, @@ -153,24 +285,55 @@ function populateVendorsDropdown(client_id) { // If we get a response from ajax.php, parse it as JSON const response = JSON.parse(data); - // Access the data for locations (multiple) - const vendors = response.vendors; + // Access the data for vendors (multiple) + const vendors = response.vendors || []; - // Locations dropdown - const vendorSelectDropdown = document.getElementById("vendorSelect"); - - // Clear dropdown - let i, L = vendorSelectDropdown.options.length - 1; - for (i = L; i >= 0; i--) { - vendorSelectDropdown.remove(i); - } - vendorSelectDropdown[vendorSelectDropdown.length] = new Option('- Vendor -', '0'); + // 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 cd772de1..42f19777 100644 --- a/agent/modals/recurring_ticket/recurring_ticket_add.php +++ b/agent/modals/recurring_ticket/recurring_ticket_add.php @@ -17,8 +17,8 @@ ob_start();
- - + + @@ -41,7 +41,6 @@ ob_start();
-
@@ -264,113 +263,30 @@ ob_start();
- - -
- -
-
- -
- +
+ +
+
+
+
+
-
- -
-
- -
- +
+ +
+
+
+
+
- + + Select a client on the Details tab to load its assets.
diff --git a/agent/modals/ticket/ticket_add_v2.php b/agent/modals/ticket/ticket_add_v2.php index e5c53a66..bca0db0c 100644 --- a/agent/modals/ticket/ticket_add_v2.php +++ b/agent/modals/ticket/ticket_add_v2.php @@ -23,10 +23,7 @@ ob_start(); - - - - + @@ -234,7 +231,17 @@ ob_start();
- To-do: project, etc. + +
+ +
+
+ +
+ +
+
@@ -247,6 +254,17 @@ ob_start();
+
+ +
+
+ +
+ +
+
+