From e7015d1595857dd6d0e519bb9dbf97bc9f664b3c Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 3 Sep 2026 18:35:46 -0400 Subject: [PATCH] Fix undefined item name when selecting from product list in quote or recurring invoice --- agent/invoice.php | 88 ++----------------------------------- agent/quote.php | 72 +++--------------------------- agent/recurring_invoice.php | 73 +++--------------------------- functions/app.php | 43 ++++++++++++++++++ js/product_autocomplete.js | 76 ++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 218 deletions(-) create mode 100644 js/product_autocomplete.js diff --git a/agent/invoice.php b/agent/invoice.php index 4bf4d071b..da87319da 100644 --- a/agent/invoice.php +++ b/agent/invoice.php @@ -180,32 +180,7 @@ if (isset($_GET['invoice_id'])) { $invoice_badge_color = getInvoiceBadgeColor($invoice_status); //Product autocomplete - $products_sql = mysqli_query($mysqli, " - SELECT - IF(product_code IS NULL OR product_code = '', product_name, CONCAT(product_code, ' - ', product_name)) AS label, - product_name, - product_code, - product_type AS type, - product_description AS description, - product_price AS price, - product_tax_id AS tax, - tax_percent, - product_id AS prod_id, - COALESCE(SUM(product_stock.stock_qty), 0) AS available_stock - FROM products - LEFT JOIN product_stock ON product_id = stock_product_id - LEFT JOIN taxes ON product_tax_id = tax_id - WHERE product_archived_at IS NULL - GROUP BY product_id - ORDER BY product_name ASC - "); - - if (mysqli_num_rows($products_sql) > 0) { - while ($row = mysqli_fetch_assoc($products_sql)) { - $products[] = $row; - } - $json_products = json_encode($products); - } + $json_products = getProductsForAutocomplete($mysqli); // Saved Payment Methods $sql_saved_payment_methods = mysqli_query($mysqli, " @@ -815,67 +790,12 @@ require_once "../includes/footer.php"; ?> - + + diff --git a/agent/quote.php b/agent/quote.php index 6b008a83a..4aff7960a 100644 --- a/agent/quote.php +++ b/agent/quote.php @@ -128,14 +128,7 @@ if (isset($_GET['quote_id'])) { } //Product autocomplete - $products_sql = mysqli_query($mysqli, "SELECT product_name AS label, product_description AS description, product_price AS price, product_tax_id AS tax FROM products WHERE product_archived_at IS NULL"); - - if (mysqli_num_rows($products_sql) > 0) { - while ($row = mysqli_fetch_assoc($products_sql)) { - $products[] = $row; - } - $json_products = json_encode($products); - } + $json_products = getProductsForAutocomplete($mysqli); // Quote File Attachments $sql_quote_files = mysqli_query( @@ -403,6 +396,7 @@ if (isset($_GET['quote_id'])) {
+ - - + + diff --git a/agent/recurring_invoice.php b/agent/recurring_invoice.php index 2db896fbb..5d3232541 100644 --- a/agent/recurring_invoice.php +++ b/agent/recurring_invoice.php @@ -117,14 +117,7 @@ if (isset($_GET['recurring_invoice_id'])) { $sql_history = mysqli_query($mysqli, "SELECT history_created_at, history_description, history_status FROM history WHERE history_recurring_invoice_id = $recurring_invoice_id ORDER BY history_id DESC"); //Product autocomplete - $products_sql = mysqli_query($mysqli, "SELECT product_name AS label, product_description AS description, product_price AS price, product_tax_id AS tax FROM products WHERE product_archived_at IS NULL"); - - if (mysqli_num_rows($products_sql) > 0) { - while ($row = mysqli_fetch_assoc($products_sql)) { - $products[] = $row; - } - $json_products = json_encode($products); - } + $json_products = getProductsForAutocomplete($mysqli); enforceClientAccess(); @@ -340,6 +333,7 @@ if (isset($_GET['recurring_invoice_id'])) { + - - + + diff --git a/functions/app.php b/functions/app.php index 651d291b6..36dba407e 100644 --- a/functions/app.php +++ b/functions/app.php @@ -871,3 +871,46 @@ function getSentMethods() { 'Other' ]; } + + +/* + * Products for the line-item autocomplete on invoices, quotes and recurring + * invoices. + * + * All three pages share js/product_autocomplete.js, so they must all be handed + * the same shape. They used to carry a SELECT each and they drifted: quote and + * recurring invoice only selected label/description/price/tax, so the shared + * onSelect wrote item.product_name - undefined - into the item name field. + * + * Returns a JSON string ready to emit into the page. + */ +function getProductsForAutocomplete($mysqli): string +{ + $products = []; + + $sql = mysqli_query($mysqli, " + SELECT + IF(product_code IS NULL OR product_code = '', product_name, CONCAT(product_code, ' - ', product_name)) AS label, + product_name, + product_code, + product_type AS type, + product_description AS description, + product_price AS price, + product_tax_id AS tax, + tax_percent, + product_id AS prod_id, + COALESCE(SUM(product_stock.stock_qty), 0) AS available_stock + FROM products + LEFT JOIN product_stock ON product_id = stock_product_id + LEFT JOIN taxes ON product_tax_id = tax_id + WHERE product_archived_at IS NULL + GROUP BY product_id + ORDER BY product_name ASC + "); + + while ($row = mysqli_fetch_assoc($sql)) { + $products[] = $row; + } + + return json_encode($products) ?: '[]'; +} diff --git a/js/product_autocomplete.js b/js/product_autocomplete.js new file mode 100644 index 000000000..b054a2dde --- /dev/null +++ b/js/product_autocomplete.js @@ -0,0 +1,76 @@ +/* + * Product autocomplete for the add-item row on invoices, quotes and recurring + * invoices. + * + * The three pages used to carry a copy of this each, and they drifted - the + * quote and recurring invoice copies were fed a four column product query, so + * item.product_name was undefined and selecting a product wrote the literal + * string "undefined" into the item name. + * + * Call with the array emitted by getProductsForAutocomplete(): + * + * initProductAutocomplete(); + */ +function initProductAutocomplete(availableProducts) { + + var nameInput = document.getElementById('name'); + if (!nameInput) { + return; + } + + // Quote and recurring invoice do not store the product link yet, so treat + // the hidden input as optional rather than throwing on every keystroke. + var productIdInput = document.getElementById('product_id'); + + itflowAutocomplete(nameInput, { + minLength: 1, + source: availableProducts || [], + match: function (item, term) { + return String(item.label || '').toLowerCase().indexOf(term) !== -1 + || String(item.product_name || '').toLowerCase().indexOf(term) !== -1 + || String(item.product_code || '').toLowerCase().indexOf(term) !== -1; + }, + render: function (item) { + var esc = itflowEscapeHtml; + var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : ""; + var showStock = (typeText.toLowerCase() !== "service"); + var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax"; + var priceText = (item.price != null && item.price !== "") ? String(item.price) : ""; + var stockText = (item.available_stock ?? 0); + + return "
" + + "
" + + "
" + esc(item.label) + + (typeText ? " (" + esc(typeText) + ")" : "") + + "
" + + "
" + esc(item.description) + "
" + + "
" + + "Tax: " + esc(taxText) + "" + + (showStock ? "Stock: " + esc(stockText) + "" : "") + + "
" + + "
" + + "
" + + "
" + esc(priceText) + "
" + + "
" + + "
"; + }, + onSelect: function (item) { + nameInput.value = item.product_name; + document.getElementById('desc').value = item.description; + document.getElementById('qty').value = 1; + document.getElementById('price').value = item.price; + setTomSelectValue(document.getElementById('tax'), item.tax); + if (productIdInput) { + productIdInput.value = item.prod_id; + } + } + }); + + // Typing over the name by hand breaks the link to the product + if (productIdInput) { + nameInput.addEventListener('input', function () { + productIdInput.value = 0; + }); + } + +}