From 014b98943efe2e168feb6925fd1d1acbaf5a0665 Mon Sep 17 00:00:00 2001 From: Jane Wheatley Date: Wed, 3 Dec 2025 20:29:34 -0800 Subject: [PATCH 1/9] 5446 add audit_duplicates file with new modal for multi barcodes per item --- app/controllers/audits_controller.rb | 1 + app/javascript/application.js | 1 + app/javascript/utils/audit_duplicates.js | 107 ++++++++++++++++++ app/models/audit.rb | 4 +- app/views/audits/edit.html.erb | 23 ++++ app/views/audits/new.html.erb | 2 +- .../_duplicate_item_modal.html.erb | 21 ++++ 7 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 app/javascript/utils/audit_duplicates.js create mode 100644 app/views/barcode_items/_duplicate_item_modal.html.erb diff --git a/app/controllers/audits_controller.rb b/app/controllers/audits_controller.rb index e240a2682e..a0256f1ea4 100644 --- a/app/controllers/audits_controller.rb +++ b/app/controllers/audits_controller.rb @@ -51,6 +51,7 @@ def new def create @audit = current_organization.audits.new(audit_params) @audit.user = current_user + @audit.merge_duplicates = params[:merge_duplicates] == 'true' if @audit.save save_audit_status_and_redirect(params) else diff --git a/app/javascript/application.js b/app/javascript/application.js index b34d4b5ae5..9c78a3d8ae 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -27,6 +27,7 @@ import 'bootstrap' import 'controllers' import 'utils/barcode_items' +import 'utils/audit_duplicates' import 'utils/barcode_scan' import 'utils/distributions_and_transfers' import 'utils/donations' diff --git a/app/javascript/utils/audit_duplicates.js b/app/javascript/utils/audit_duplicates.js new file mode 100644 index 0000000000..4a984d07c1 --- /dev/null +++ b/app/javascript/utils/audit_duplicates.js @@ -0,0 +1,107 @@ +import $ from 'jquery'; + +$(() => { + $("button[name='save_progress']").on('click', function (e) { + + const form = $(this).closest('form'); + const itemCounts = {}; // Will look like: { "2": 3, "5": 1, "12": 2 } + const itemNames = {}; // Will look like: { "2": "Item A", "5": "Item B", "12": "Item C" } + + form.find('select[name$="[item_id]"]').each(function() { + const itemId = $(this).val(); + const itemText = $(this).find('option:selected').text(); + const barcodeValue = $(this).closest('.line_item_section').find('.__barcode_item_lookup').val(); + if (!itemId || itemText === "Choose an item") { + return; + } + itemCounts[itemId] = (itemCounts[itemId] || 0) + 1; + itemNames[itemId] = itemText; + }); + + // Check for duplicates + const duplicates = Object.keys(itemCounts) + .filter(itemId => itemCounts[itemId] > 1) + .map(itemId => itemNames[itemId]); + + if (duplicates.length > 0) { + // Show modal with duplicate items + showDuplicateModal(duplicates, form); + } else { + // No duplicates, proceed normally + form.trigger('submit'); + } + e.preventDefault(); + }); + + function showDuplicateModal(duplicateItems, form) { + const itemList = duplicateItems.join(', '); + const modalHtml = ` + + `; + + // Remove existing modal + $('#duplicateItemsModal').remove(); + + // Add and show modal + $('body').append(modalHtml); + $('#duplicateItemsModal').modal('show'); + + // Handle confirm button + $('#confirmMerge').on('click', function() { + $('#duplicateItemsModal').modal('hide'); + + // Change form action to force_update + const currentAction = form.attr('action'); + const currentUrl = window.location.pathname; + console.log('Current action:', currentAction); + console.log('Current URL:', currentUrl); + + // Try to get audit ID from URL first, then from action + let auditId; + const urlMatch = currentUrl.match(/\/audits\/(\d+)/); + const actionMatch = currentAction.match(/\/audits\/(\d+)/); + + if (urlMatch) { + auditId = urlMatch[1]; + } else if (actionMatch) { + auditId = actionMatch[1]; + } else { + console.log('New audit - adding merge_duplicates field'); + // For new audits, add a hidden field to allow duplicates + form.append(''); + form.trigger('submit'); + return; + } + + console.log('Extracted audit ID:', auditId); + const newAction = `/audits/${auditId}/force_update`; + console.log('New action:', newAction); + form.attr('action', newAction); + + // Submit form + form.off('submit'); + console.log('Submitting form to:', form.attr('action')); + form.trigger('submit'); + }); + } +}); \ No newline at end of file diff --git a/app/models/audit.rb b/app/models/audit.rb index b0b6a6099f..a35dc1628d 100644 --- a/app/models/audit.rb +++ b/app/models/audit.rb @@ -29,9 +29,11 @@ class Audit < ApplicationRecord enum :status, { in_progress: 0, confirmed: 1, finalized: 2 } validate :line_items_quantity_is_not_negative - validate :line_items_unique_by_item_id + validate :line_items_unique_by_item_id, unless: :merge_duplicates validate :user_is_organization_admin_of_the_organization + attr_accessor :merge_duplicates + def self.finalized_since?(itemizable, *location_ids) item_ids = itemizable.line_items.pluck(:item_id) where(status: "finalized") diff --git a/app/views/audits/edit.html.erb b/app/views/audits/edit.html.erb index 79619017c6..5ee04a3ed2 100644 --- a/app/views/audits/edit.html.erb +++ b/app/views/audits/edit.html.erb @@ -39,3 +39,26 @@ <%= render partial: "audits/form" %> + + + + + diff --git a/app/views/audits/new.html.erb b/app/views/audits/new.html.erb index e30ef1be67..4260f2a880 100644 --- a/app/views/audits/new.html.erb +++ b/app/views/audits/new.html.erb @@ -40,4 +40,4 @@ -<%= render partial: "audits/form" %> +<%= render partial: "audits/form" %> \ No newline at end of file diff --git a/app/views/barcode_items/_duplicate_item_modal.html.erb b/app/views/barcode_items/_duplicate_item_modal.html.erb new file mode 100644 index 0000000000..2701489aad --- /dev/null +++ b/app/views/barcode_items/_duplicate_item_modal.html.erb @@ -0,0 +1,21 @@ +# From 39fca5f7f371cb3384e2a2eeca5eff46b8ba4cb1 Mon Sep 17 00:00:00 2001 From: Jane Wheatley Date: Thu, 4 Dec 2025 11:26:25 -0800 Subject: [PATCH 2/9] 5446 merge duplicates logic --- app/javascript/utils/audit_duplicates.js | 83 +++++++++++++++--------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/app/javascript/utils/audit_duplicates.js b/app/javascript/utils/audit_duplicates.js index 4a984d07c1..7b42571847 100644 --- a/app/javascript/utils/audit_duplicates.js +++ b/app/javascript/utils/audit_duplicates.js @@ -41,7 +41,7 @@ $(() => { @@ -66,42 +66,67 @@ $(() => { $('body').append(modalHtml); $('#duplicateItemsModal').modal('show'); + // Handle close button + $('#duplicateItemsModal .close').on('click', function() { + $('#duplicateItemsModal').modal('hide'); + }); + + // Handle cancel button + $('#duplicateItemsModal .btn-secondary').on('click', function() { + $('#duplicateItemsModal').modal('hide'); + }); + // Handle confirm button $('#confirmMerge').on('click', function() { $('#duplicateItemsModal').modal('hide'); - // Change form action to force_update - const currentAction = form.attr('action'); - const currentUrl = window.location.pathname; - console.log('Current action:', currentAction); - console.log('Current URL:', currentUrl); + // Merge duplicate items before submitting + mergeDuplicateItems(form); - // Try to get audit ID from URL first, then from action - let auditId; - const urlMatch = currentUrl.match(/\/audits\/(\d+)/); - const actionMatch = currentAction.match(/\/audits\/(\d+)/); - - if (urlMatch) { - auditId = urlMatch[1]; - } else if (actionMatch) { - auditId = actionMatch[1]; + // Find and click the actual Save Progress button to preserve its behavior + const saveProgressBtn = form.find('button[name="save_progress"]'); + if (saveProgressBtn.length > 0) { + // Temporarily remove our event handler to avoid infinite loop + saveProgressBtn.off('click'); + saveProgressBtn.click(); } else { - console.log('New audit - adding merge_duplicates field'); - // For new audits, add a hidden field to allow duplicates - form.append(''); + // Fallback: add hidden input and submit + form.append(''); form.trigger('submit'); - return; } + }); + } + + function mergeDuplicateItems(form) { + const itemQuantities = {}; + const itemSections = []; + + // Collect all line items and their quantities + form.find('select[name$="[item_id]"]').each(function() { + const itemId = $(this).val(); + const section = $(this).closest('.line_item_section'); + const quantityInput = section.find('input[name*="[quantity]"]'); + const quantity = parseInt(quantityInput.val()) || 0; - console.log('Extracted audit ID:', auditId); - const newAction = `/audits/${auditId}/force_update`; - console.log('New action:', newAction); - form.attr('action', newAction); - - // Submit form - form.off('submit'); - console.log('Submitting form to:', form.attr('action')); - form.trigger('submit'); + if (itemId && itemId !== '') { + itemSections.push({ itemId, section, quantity }); + itemQuantities[itemId] = (itemQuantities[itemId] || 0) + quantity; + } + }); + + // Find duplicates and merge them + const processedItems = new Set(); + + itemSections.forEach(({ itemId, section, quantity }) => { + if (processedItems.has(itemId)) { + // This is a duplicate - remove it + section.remove(); + } else { + // This is the first occurrence - update quantity to merged total + const quantityInput = section.find('input[name*="[quantity]"]'); + quantityInput.val(itemQuantities[itemId]); + processedItems.add(itemId); + } }); } }); \ No newline at end of file From 48729ff27991bbfce0474872899f11eaf3615e1d Mon Sep 17 00:00:00 2001 From: Jane Wheatley Date: Sat, 6 Dec 2025 20:40:09 -0800 Subject: [PATCH 3/9] 5446 update modal and fix issue with fill color on audit page after save --- app/javascript/utils/audit_duplicates.js | 59 ++++++++++++++++++------ app/views/audits/show.html.erb | 2 +- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/app/javascript/utils/audit_duplicates.js b/app/javascript/utils/audit_duplicates.js index 7b42571847..1e5c23b2cd 100644 --- a/app/javascript/utils/audit_duplicates.js +++ b/app/javascript/utils/audit_duplicates.js @@ -6,38 +6,69 @@ $(() => { const form = $(this).closest('form'); const itemCounts = {}; // Will look like: { "2": 3, "5": 1, "12": 2 } const itemNames = {}; // Will look like: { "2": "Item A", "5": "Item B", "12": "Item C" } + const itemQuantities = {}; // Will look like: { "2": [{qty: 15, barcode: "123"}, {qty: 10, barcode: "456"}] } form.find('select[name$="[item_id]"]').each(function() { const itemId = $(this).val(); const itemText = $(this).find('option:selected').text(); - const barcodeValue = $(this).closest('.line_item_section').find('.__barcode_item_lookup').val(); - if (!itemId || itemText === "Choose an item") { + const section = $(this).closest('.line_item_section'); + const quantityInput = section.find('input[name*="[quantity]"]'); + const itemQuantity = parseInt(quantityInput.val()) || 0; + const barcodeValue = section.find('.__barcode_item_lookup').val() || ''; + if (!itemId || itemText === "Choose an item" || itemQuantity === 0) { return; } itemCounts[itemId] = (itemCounts[itemId] || 0) + 1; itemNames[itemId] = itemText; + if (!itemQuantities[itemId]) itemQuantities[itemId] = []; + itemQuantities[itemId].push({ qty: itemQuantity, barcode: barcodeValue }); + }); + + // Remove rows with zero quantity or no item selected + form.find('select[name$="[item_id]"]').each(function() { + const itemId = $(this).val(); + const itemText = $(this).find('option:selected').text(); + const section = $(this).closest('.line_item_section'); + const quantityInput = section.find('input[name*="[quantity]"]'); + const itemQuantity = parseInt(quantityInput.val()) || 0; + + if (!itemId || itemText === "Choose an item" || itemQuantity === 0) { + section.remove(); + } }); // Check for duplicates const duplicates = Object.keys(itemCounts) .filter(itemId => itemCounts[itemId] > 1) - .map(itemId => itemNames[itemId]); + .map(itemId => ({ name: itemNames[itemId], id: itemId })); if (duplicates.length > 0) { // Show modal with duplicate items - showDuplicateModal(duplicates, form); + showDuplicateModal(duplicates, itemQuantities, form); } else { // No duplicates, proceed normally form.trigger('submit'); } e.preventDefault(); }); - - function showDuplicateModal(duplicateItems, form) { - const itemList = duplicateItems.join(', '); + + function showDuplicateModal(duplicateItems, duplicateQuantities, form) { + const itemRows = duplicateItems.map(item => { + const entries = duplicateQuantities[item.id] || []; + const total = entries.reduce((sum, entry) => sum + entry.qty, 0); + const rows = entries.map((entry, i) => { + const barcodeLine = entry.barcode ? `
Barcode: ${entry.barcode}
` : ''; + if (i === 0) { + return `
${item.name} - Quantity: ${entry.qty}${barcodeLine}
`; + } else { + return `
⚠ Duplicate: ${item.name} - Quantity: ${entry.qty}${barcodeLine}
`; + } + }).join(''); + return `
${rows}
✓ Merged Result - Quantity: ${total}
`; + }).join(''); const modalHtml = ` - - diff --git a/app/views/audits/new.html.erb b/app/views/audits/new.html.erb index 4260f2a880..e30ef1be67 100644 --- a/app/views/audits/new.html.erb +++ b/app/views/audits/new.html.erb @@ -40,4 +40,4 @@ -<%= render partial: "audits/form" %> \ No newline at end of file +<%= render partial: "audits/form" %> From ac46edd0666657d8c2fa015a8352878246b77769 Mon Sep 17 00:00:00 2001 From: Jane Wheatley Date: Tue, 9 Dec 2025 21:18:48 -0800 Subject: [PATCH 6/9] 5446 refactor code and tweak modal --- app/assets/stylesheets/modal-dialog.scss | 51 +++++++++++++++++ app/controllers/audits_controller.rb | 1 - app/javascript/utils/audit_duplicates.js | 57 +++++++------------ app/models/audit.rb | 4 +- app/views/audits/edit.html.erb | 21 ------- app/views/audits/show.html.erb | 2 +- .../_duplicate_item_modal.html.erb | 21 ------- 7 files changed, 73 insertions(+), 84 deletions(-) delete mode 100644 app/views/barcode_items/_duplicate_item_modal.html.erb diff --git a/app/assets/stylesheets/modal-dialog.scss b/app/assets/stylesheets/modal-dialog.scss index 57f0daac8d..bbb2f3605e 100644 --- a/app/assets/stylesheets/modal-dialog.scss +++ b/app/assets/stylesheets/modal-dialog.scss @@ -31,3 +31,54 @@ width: 700px; } } + +/* DUPLICATE ITEMS MODAL */ +.duplicate-container { + margin-bottom: 20px; + padding: 10px; + background-color: #f9f9f9; + border: 1px solid #ddd; + border-radius: 5px; +} + +.duplicate-entry { + padding: 8px; + margin: 4px 0; + background-color: #fff3cd; + border-left: 3px solid #ffc107; +} + +.duplicate-barcode { + font-size: 0.85em; + color: #666; + margin-top: 2px; +} + +.duplicate-merged { + padding: 10px; + margin: 10px 0 0 0; + background-color: #d4edda; + border: 2px solid #28a745; + border-radius: 4px; + font-weight: bold; +} + +.duplicate-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.duplicate-modal-text { + margin: 0; + font-size: 0.9em; + margin-right: 20px; +} + +.duplicate-modal-buttons { + margin-left: auto; +} + +.duplicate-items-list { + margin-bottom: 20px; +} diff --git a/app/controllers/audits_controller.rb b/app/controllers/audits_controller.rb index a0256f1ea4..e240a2682e 100644 --- a/app/controllers/audits_controller.rb +++ b/app/controllers/audits_controller.rb @@ -51,7 +51,6 @@ def new def create @audit = current_organization.audits.new(audit_params) @audit.user = current_user - @audit.merge_duplicates = params[:merge_duplicates] == 'true' if @audit.save save_audit_status_and_redirect(params) else diff --git a/app/javascript/utils/audit_duplicates.js b/app/javascript/utils/audit_duplicates.js index 5d5af4738d..d42cb1774c 100644 --- a/app/javascript/utils/audit_duplicates.js +++ b/app/javascript/utils/audit_duplicates.js @@ -14,28 +14,18 @@ $(() => { const quantityInput = section.find('input[name*="[quantity]"]'); const itemQuantity = parseInt(quantityInput.val()) || 0; const barcodeValue = section.find('.__barcode_item_lookup').val() || ''; + if (!itemId || itemText === "Choose an item" || itemQuantity === 0) { + section.remove(); return; } + itemCounts[itemId] = (itemCounts[itemId] || 0) + 1; itemNames[itemId] = itemText; if (!itemQuantities[itemId]) itemQuantities[itemId] = []; itemQuantities[itemId].push({ qty: itemQuantity, barcode: barcodeValue }); }); - // Remove rows with zero quantity or no item selected - form.find('select[name$="[item_id]"]').each(function() { - const itemId = $(this).val(); - const itemText = $(this).find('option:selected').text(); - const section = $(this).closest('.line_item_section'); - const quantityInput = section.find('input[name*="[quantity]"]'); - const itemQuantity = parseInt(quantityInput.val()) || 0; - - if (!itemId || itemText === "Choose an item" || itemQuantity === 0) { - section.remove(); - } - }); - // Check for duplicates const duplicates = Object.keys(itemCounts) .filter(itemId => itemCounts[itemId] > 1) @@ -45,10 +35,8 @@ $(() => { // Show modal with duplicate items showDuplicateModal(duplicates, itemQuantities, form, buttonName); e.preventDefault(); - } else { - // No duplicates, let the form submit normally - // Don't prevent default - let the button's natural submit behavior work - } + } + // else, allow form submission to proceed } $("button[name='save_progress']").on('click', function (e) { @@ -64,33 +52,33 @@ $(() => { const entries = duplicateQuantities[item.id] || []; const total = entries.reduce((sum, entry) => sum + entry.qty, 0); const rows = entries.map((entry, i) => { - const barcodeLine = entry.barcode ? `
Barcode: ${entry.barcode}
` : ''; - if (i === 0) { - return `
${item.name} - Quantity: ${entry.qty}${barcodeLine}
`; - } else { - return `
⚠ Duplicate: ${item.name} - Quantity: ${entry.qty}${barcodeLine}
`; - } + const barcodeLine = entry.barcode ? `
Barcode: ${entry.barcode}
` : ''; + return `
❐ ${item.name} : ${entry.qty}${barcodeLine}
`; }).join(''); - return `
${rows}
✓ Merged Result - Quantity: ${total}
`; + return `
${rows}
→ Merged Result: ${item.name} : ${total}
`; }).join(''); const modalHtml = `