Inline add-stock prompt when label scan finds existing inventory item
When a scanned label matches an item already in the tenant's inventory, the scanner now opens an inline modal asking the user to add stock to the existing item rather than navigating away or creating a duplicate. - InventoryController.AddStock: new POST endpoint that creates a Purchase transaction, updates QuantityOnHand, and optionally updates UnitCost / LastPurchasePrice when a new cost is provided. Returns new balance as JSON. - InventoryController.ScanLabel: extends the duplicate-detection response to include existingQuantityOnHand and existingUnitOfMeasure so the modal can display current stock level. - _LabelScanModal.cshtml: adds #addStockModal with quantity (+ UOM label), optional unit cost (pre-filled from scan), optional notes, Add Stock CTA, and an escape hatch to create a new entry instead. - inventory-label-scan.js: when scan returns existingInventoryId the JS opens addStockModal instead of a warning banner. Submitting POSTs to /Inventory/AddStock and shows the updated balance in a success bar with a link to the item. The 'new entry instead' path hides the modal and pre-fills the create form with a softer duplicate warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -836,9 +836,13 @@ public class InventoryController : Controller
|
||||
|
||||
// Check if this product already exists in the tenant's inventory.
|
||||
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
|
||||
// Returns the first active match so the UI can warn the user before they create a duplicate.
|
||||
int? existingInventoryId = null;
|
||||
string? existingInventoryName = null;
|
||||
// Returns the first active match so the UI can prompt to add stock inline.
|
||||
int? existingInventoryId = null;
|
||||
string? existingInventoryName = null;
|
||||
decimal? existingQuantityOnHand = null;
|
||||
string? existingUnitOfMeasure = null;
|
||||
|
||||
InventoryItem? existingHit = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(sku))
|
||||
{
|
||||
@@ -846,52 +850,119 @@ public class InventoryController : Controller
|
||||
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||
i.ManufacturerPartNumber != null &&
|
||||
i.ManufacturerPartNumber.ToLower() == skuLower);
|
||||
var hit = byPart.FirstOrDefault();
|
||||
if (hit != null) { existingInventoryId = hit.Id; existingInventoryName = hit.Name; }
|
||||
existingHit = byPart.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (existingInventoryId == null && !string.IsNullOrEmpty(colorName))
|
||||
if (existingHit == null && !string.IsNullOrEmpty(colorName))
|
||||
{
|
||||
var nameLower = colorName.ToLower();
|
||||
var mfrLower = manufacturer?.ToLower() ?? "";
|
||||
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
|
||||
i.Name.ToLower() == nameLower);
|
||||
var hit = byName.FirstOrDefault(i =>
|
||||
existingHit = byName.FirstOrDefault(i =>
|
||||
string.IsNullOrEmpty(mfrLower) ||
|
||||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
|
||||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
|
||||
if (hit != null) { existingInventoryId = hit.Id; existingInventoryName = hit.Name; }
|
||||
}
|
||||
|
||||
if (existingHit != null)
|
||||
{
|
||||
existingInventoryId = existingHit.Id;
|
||||
existingInventoryName = existingHit.Name;
|
||||
existingQuantityOnHand = existingHit.QuantityOnHand;
|
||||
existingUnitOfMeasure = existingHit.UnitOfMeasure;
|
||||
}
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
manufacturer = manufacturer,
|
||||
manufacturerPartNumber= sku,
|
||||
colorName = colorName,
|
||||
description = aiResult.Description,
|
||||
finish = catalogMatch?.Finish ?? aiResult.Finish,
|
||||
cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF,
|
||||
cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes,
|
||||
colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies,
|
||||
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
||||
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
||||
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
||||
unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m,
|
||||
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
||||
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
||||
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
|
||||
tdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl,
|
||||
vendorName = manufacturer,
|
||||
wasInCatalog = wasInCatalog,
|
||||
addedToCatalog = addedToCatalog,
|
||||
existingInventoryId = existingInventoryId,
|
||||
existingInventoryName = existingInventoryName,
|
||||
reasoning = aiResult.Reasoning,
|
||||
success = true,
|
||||
manufacturer = manufacturer,
|
||||
manufacturerPartNumber = sku,
|
||||
colorName = colorName,
|
||||
description = aiResult.Description,
|
||||
finish = catalogMatch?.Finish ?? aiResult.Finish,
|
||||
cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF,
|
||||
cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes,
|
||||
colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies,
|
||||
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
||||
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
||||
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
||||
unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m,
|
||||
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
||||
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
||||
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
|
||||
tdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl,
|
||||
vendorName = manufacturer,
|
||||
wasInCatalog = wasInCatalog,
|
||||
addedToCatalog = addedToCatalog,
|
||||
existingInventoryId = existingInventoryId,
|
||||
existingInventoryName = existingInventoryName,
|
||||
existingQuantityOnHand = existingQuantityOnHand,
|
||||
existingUnitOfMeasure = existingUnitOfMeasure,
|
||||
reasoning = aiResult.Reasoning,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds stock to an existing inventory item from the label scanner inline prompt.
|
||||
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddStock(int inventoryItemId, decimal quantity, decimal? unitCost, string? notes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (quantity <= 0)
|
||||
return Json(new { success = false, errorMessage = "Quantity must be greater than zero." });
|
||||
|
||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
||||
if (item == null) return Json(new { success = false, errorMessage = "Item not found." });
|
||||
|
||||
var cost = (unitCost.HasValue && unitCost.Value > 0) ? unitCost.Value : item.UnitCost;
|
||||
|
||||
item.QuantityOnHand += quantity;
|
||||
item.LastPurchaseDate = DateTime.UtcNow;
|
||||
if (unitCost.HasValue && unitCost.Value > 0)
|
||||
{
|
||||
item.LastPurchasePrice = unitCost.Value;
|
||||
item.UnitCost = unitCost.Value;
|
||||
}
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||
|
||||
var txn = new InventoryTransaction
|
||||
{
|
||||
InventoryItemId = item.Id,
|
||||
TransactionType = InventoryTransactionType.Purchase,
|
||||
Quantity = quantity,
|
||||
UnitCost = cost,
|
||||
TotalCost = quantity * cost,
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
BalanceAfter = item.QuantityOnHand,
|
||||
Notes = !string.IsNullOrWhiteSpace(notes) ? notes.Trim() : "Added via label scan",
|
||||
};
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Label scan added {Qty} {UOM} to inventory item {Id} ({Name})",
|
||||
quantity, item.UnitOfMeasure, item.Id, item.Name);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
newQuantityOnHand = item.QuantityOnHand,
|
||||
unitOfMeasure = item.UnitOfMeasure,
|
||||
itemName = item.Name,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error adding stock via label scan to inventory item {ItemId}", inventoryItemId);
|
||||
return Json(new { success = false, errorMessage = "An error occurred. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches the platform-level PowderCatalogItems table by SKU or color name and returns
|
||||
/// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back
|
||||
|
||||
@@ -1,3 +1,52 @@
|
||||
<!-- Add-stock modal: shown when label scan matches an existing inventory item -->
|
||||
<div class="modal fade" id="addStockModal" tabindex="-1" aria-labelledby="addStockModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" style="max-width:420px;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="addStockModalLabel">
|
||||
<i class="bi bi-box-seam me-2 text-success"></i>Already in Inventory
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pb-2">
|
||||
<p class="mb-1">
|
||||
<strong id="add-stock-item-name" class="text-body"></strong> is already in your inventory.
|
||||
</p>
|
||||
<p class="text-muted small mb-3">
|
||||
Current stock: <strong id="add-stock-current-qty"></strong>
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Quantity to Add <span class="text-danger">*</span></label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" id="add-stock-qty" class="form-control" min="0.01" step="0.01" placeholder="e.g. 5">
|
||||
<span class="input-group-text" id="add-stock-uom-label">lbs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Unit Cost <span class="text-muted fw-normal">(optional — updates item cost)</span></label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" id="add-stock-cost" class="form-control" min="0" step="0.01" placeholder="Leave blank to keep current">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold small">Notes <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" id="add-stock-notes" class="form-control form-control-sm" placeholder="e.g. New bag received">
|
||||
</div>
|
||||
<div id="add-stock-status" class="d-none small mt-2"></div>
|
||||
</div>
|
||||
<div class="modal-footer flex-column align-items-stretch gap-2 py-2">
|
||||
<button id="add-stock-confirm-btn" type="button" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Stock
|
||||
</button>
|
||||
<button id="add-stock-new-btn" type="button" class="btn btn-link btn-sm text-muted">
|
||||
Add as a new entry instead (e.g. different lot)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="labelScanModal" tabindex="-1" aria-labelledby="labelScanModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" style="max-width:480px;">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -46,11 +46,33 @@
|
||||
const processingEl = document.getElementById('scan-processing');
|
||||
const processingMsgEl= document.getElementById('scan-processing-msg');
|
||||
|
||||
// Add-stock modal elements
|
||||
const addStockModalEl = document.getElementById('addStockModal');
|
||||
const bsAddStockModal = addStockModalEl ? new bootstrap.Modal(addStockModalEl) : null;
|
||||
const addStockItemName = document.getElementById('add-stock-item-name');
|
||||
const addStockCurrentQty= document.getElementById('add-stock-current-qty');
|
||||
const addStockUomLabel = document.getElementById('add-stock-uom-label');
|
||||
const addStockQtyInput = document.getElementById('add-stock-qty');
|
||||
const addStockCostInput = document.getElementById('add-stock-cost');
|
||||
const addStockNotesInput= document.getElementById('add-stock-notes');
|
||||
const addStockStatusEl = document.getElementById('add-stock-status');
|
||||
const addStockConfirmBtn= document.getElementById('add-stock-confirm-btn');
|
||||
|
||||
let _addStockItemId = null;
|
||||
let _lastScanData = null;
|
||||
|
||||
if (!modalEl || !videoEl || !canvasEl) return;
|
||||
|
||||
scanBtn.addEventListener('click', openScanner);
|
||||
modalEl.addEventListener('hide.bs.modal', onModalClose);
|
||||
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
|
||||
if (addStockConfirmBtn) addStockConfirmBtn.addEventListener('click', submitAddStock);
|
||||
// "Create new entry instead" hides the add-stock modal and pre-fills the create form
|
||||
const addStockNewBtn = document.getElementById('add-stock-new-btn');
|
||||
if (addStockNewBtn) addStockNewBtn.addEventListener('click', () => {
|
||||
bsAddStockModal?.hide();
|
||||
if (_lastScanData) fillFromScan(_lastScanData, /* skipDuplicatePrompt */ true);
|
||||
});
|
||||
window.addEventListener('beforeunload', releaseCamera);
|
||||
|
||||
// Pre-warm camera if browser has already granted permission (no prompt risk)
|
||||
@@ -301,7 +323,15 @@
|
||||
}
|
||||
|
||||
bsModal.hide();
|
||||
fillFromScan(data);
|
||||
|
||||
if (data.existingInventoryId) {
|
||||
// Product already in inventory — show inline add-stock prompt
|
||||
_lastScanData = data;
|
||||
_addStockItemId = data.existingInventoryId;
|
||||
openAddStockModal(data);
|
||||
} else {
|
||||
fillFromScan(data);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
hideProcessing();
|
||||
@@ -309,9 +339,80 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add-stock modal ───────────────────────────────────────────────────
|
||||
|
||||
function openAddStockModal(data) {
|
||||
if (!bsAddStockModal) { fillFromScan(data); return; }
|
||||
|
||||
const uom = data.existingUnitOfMeasure || 'lbs';
|
||||
if (addStockItemName) addStockItemName.textContent = data.existingInventoryName || data.colorName || 'This product';
|
||||
if (addStockCurrentQty) addStockCurrentQty.textContent = `${(data.existingQuantityOnHand ?? 0).toFixed(2)} ${uom}`;
|
||||
if (addStockUomLabel) addStockUomLabel.textContent = uom;
|
||||
if (addStockQtyInput) addStockQtyInput.value = '';
|
||||
if (addStockCostInput) addStockCostInput.value = data.unitPrice > 0 ? data.unitPrice : '';
|
||||
if (addStockNotesInput) addStockNotesInput.value = '';
|
||||
if (addStockStatusEl) { addStockStatusEl.className = 'd-none'; addStockStatusEl.textContent = ''; }
|
||||
if (addStockConfirmBtn) addStockConfirmBtn.disabled = false;
|
||||
|
||||
bsAddStockModal.show();
|
||||
}
|
||||
|
||||
async function submitAddStock() {
|
||||
const qty = parseFloat(addStockQtyInput?.value);
|
||||
if (!qty || qty <= 0) {
|
||||
showAddStockStatus('danger', 'Please enter a quantity greater than zero.');
|
||||
return;
|
||||
}
|
||||
|
||||
addStockConfirmBtn.disabled = true;
|
||||
addStockConfirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
inventoryItemId: _addStockItemId,
|
||||
quantity: qty,
|
||||
});
|
||||
const cost = parseFloat(addStockCostInput?.value);
|
||||
if (cost > 0) params.append('unitCost', cost);
|
||||
const notes = addStockNotesInput?.value?.trim();
|
||||
if (notes) params.append('notes', notes);
|
||||
|
||||
const resp = await fetch('/Inventory/AddStock?' + params.toString(), { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.success) {
|
||||
showAddStockStatus('danger', data.errorMessage || 'Failed to add stock.');
|
||||
addStockConfirmBtn.disabled = false;
|
||||
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — close modal and show confirmation on the form
|
||||
bsAddStockModal.hide();
|
||||
showFormStatus('success',
|
||||
`<i class="bi bi-check-circle-fill me-1"></i>` +
|
||||
`Added <strong>${qty.toFixed(2)} ${data.unitOfMeasure}</strong> to <strong>${data.itemName}</strong>. ` +
|
||||
`New stock: ${(data.newQuantityOnHand ?? 0).toFixed(2)} ${data.unitOfMeasure}. ` +
|
||||
`<a href="/Inventory/Details/${_addStockItemId}" class="alert-link">View item</a>`
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
showAddStockStatus('danger', 'Error: ' + err.message);
|
||||
addStockConfirmBtn.disabled = false;
|
||||
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
|
||||
}
|
||||
}
|
||||
|
||||
function showAddStockStatus(type, msg) {
|
||||
if (!addStockStatusEl) return;
|
||||
addStockStatusEl.className = `alert alert-${type} py-2 small`;
|
||||
addStockStatusEl.textContent = msg;
|
||||
}
|
||||
|
||||
// ── Fill the inventory form from scan result ───────────────────────────
|
||||
|
||||
function fillFromScan(data) {
|
||||
function fillFromScan(data, skipDuplicatePrompt = false) {
|
||||
const filled = [];
|
||||
|
||||
function setIf(id, value, label) {
|
||||
@@ -404,14 +505,16 @@
|
||||
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
|
||||
: '';
|
||||
|
||||
if (data.existingInventoryId) {
|
||||
if (data.existingInventoryId && !skipDuplicatePrompt) {
|
||||
// Duplicate handled by add-stock modal — don't show a banner here
|
||||
} else if (data.existingInventoryId && skipDuplicatePrompt) {
|
||||
const itemName = data.existingInventoryName || data.colorName || 'This product';
|
||||
const filledNote = filled.length > 0 ? ` Fields pre-filled from scan.` : '';
|
||||
showFormStatus('warning',
|
||||
`<i class="bi bi-exclamation-triangle-fill me-1"></i>` +
|
||||
`<strong>${itemName}</strong> is already in your inventory. ` +
|
||||
`<a href="/Inventory/Details/${data.existingInventoryId}" class="alert-link fw-semibold">View existing item</a>` +
|
||||
` — or continue below to add a new entry (e.g. a new lot or bag size).${filledNote}${catalogNote}`
|
||||
`Creating a new entry — <strong>${itemName}</strong> already exists. ` +
|
||||
`<a href="/Inventory/Details/${data.existingInventoryId}" class="alert-link">View existing item</a>` +
|
||||
`${filledNote}${catalogNote}`
|
||||
);
|
||||
} else if (filled.length > 0) {
|
||||
showFormStatus('success', `Filled from label scan: ${filled.join(', ')}.${catalogNote}`);
|
||||
|
||||
Reference in New Issue
Block a user