Compare commits

...

3 Commits

Author SHA1 Message Date
spouliot 5e3b0b9ddf 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>
2026-05-03 19:59:43 -04:00
spouliot 3aeec4ffb2 Warn on label scan when product already exists in tenant inventory
After resolving manufacturer + SKU from the scan, ScanLabel now queries the
tenant's InventoryItems: first by ManufacturerPartNumber exact match (most
precise), then by ColorName + Manufacturer fuzzy match as fallback.

If a match is found, the response includes existingInventoryId and
existingInventoryName. The JS fillFromScan() shows a warning banner with a
direct link to the existing item instead of the normal success message. Form
fields are still pre-filled so the user can proceed to add a new entry (e.g.
a different lot or bag size) if that was the intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:49:13 -04:00
spouliot 28b7b9f86b Fix QR detection (parallel loops), price extraction, and camera pre-warm
QR scanning:
- Run BarcodeDetector and jsQR in parallel — jsQR starts after JSQR_DELAY_MS
  (1.5 s) so both decode simultaneously. BarcodeDetector silently returns empty
  arrays for some QR variants; running jsQR in parallel via a separate rAF loop
  (rafId2) and its own off-screen canvas catches those cases. First decoder to
  find anything calls handleQrResult and sets qrFound = true; the other stops.

Price extraction (two bugs):
- ScanLabel: unitPrice was catalogMatch?.UnitPrice ?? 0m, ignoring aiResult
  .UnitCostPerLb entirely when no catalog match — changed to fall through to AI result
- AppendOffer: only read JSON-LD "price" field; Shopify AggregateOffer uses
  "lowPrice" instead — now checked as fallback so Prismatic Powders prices are found

Camera pre-warm:
- Reverted localStorage approach (caused getUserMedia to fire on every page load,
  showing Chrome's "Ask" prompt immediately before user clicked anything)
- Restored Permissions API gate: preWarmCamera only calls getUserMedia when
  navigator.permissions.query returns 'granted', never risks a page-load prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:45:22 -04:00
4 changed files with 350 additions and 77 deletions
@@ -933,7 +933,9 @@ Rules:
/// </summary> /// </summary>
private static void AppendOffer(JsonElement offer, StringBuilder sb) private static void AppendOffer(JsonElement offer, StringBuilder sb)
{ {
var price = offer.TryGetProperty("price", out var p) ? p.ToString() : null; // Accept "price" (Offer) or "lowPrice" (AggregateOffer — used by Shopify and others)
var price = offer.TryGetProperty("price", out var p) ? p.ToString() :
offer.TryGetProperty("lowPrice", out var lp) ? lp.ToString() : null;
var currency = offer.TryGetProperty("priceCurrency", out var c) ? c.GetString() : "USD"; var currency = offer.TryGetProperty("priceCurrency", out var c) ? c.GetString() : "USD";
var unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null; var unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null;
var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null; var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null;
@@ -834,32 +834,135 @@ 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 prompt to add stock inline.
int? existingInventoryId = null;
string? existingInventoryName = null;
decimal? existingQuantityOnHand = null;
string? existingUnitOfMeasure = null;
InventoryItem? existingHit = null;
if (!string.IsNullOrEmpty(sku))
{
var skuLower = sku.ToLower();
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
i.ManufacturerPartNumber != null &&
i.ManufacturerPartNumber.ToLower() == skuLower);
existingHit = byPart.FirstOrDefault();
}
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);
existingHit = byName.FirstOrDefault(i =>
string.IsNullOrEmpty(mfrLower) ||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
}
if (existingHit != null)
{
existingInventoryId = existingHit.Id;
existingInventoryName = existingHit.Name;
existingQuantityOnHand = existingHit.QuantityOnHand;
existingUnitOfMeasure = existingHit.UnitOfMeasure;
}
return Json(new return Json(new
{ {
success = true, success = true,
manufacturer = manufacturer, manufacturer = manufacturer,
manufacturerPartNumber= sku, manufacturerPartNumber = sku,
colorName = colorName, colorName = colorName,
description = aiResult.Description, description = aiResult.Description,
finish = catalogMatch?.Finish ?? aiResult.Finish, finish = catalogMatch?.Finish ?? aiResult.Finish,
cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF, cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF,
cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes, cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes,
colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies, colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies,
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat, requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb, coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency, transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
unitPrice = catalogMatch?.UnitPrice ?? 0m, unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m,
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl, imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl, productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl, sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
tdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl, tdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl,
vendorName = manufacturer, vendorName = manufacturer,
wasInCatalog = wasInCatalog, wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog, addedToCatalog = addedToCatalog,
reasoning = aiResult.Reasoning, 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> /// <summary>
/// Searches the platform-level PowderCatalogItems table by SKU or color name and returns /// 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 /// 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 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-dialog modal-dialog-centered" style="max-width:480px;">
<div class="modal-content"> <div class="modal-content">
@@ -1,25 +1,24 @@
/** /**
* In-browser powder label scanner for the Inventory Create/Edit forms. * In-browser powder label scanner for the Inventory Create/Edit forms.
* *
* QR scanning priority: * QR scanning strategy (parallel for maximum compatibility):
* 1. Native BarcodeDetector API (Chrome/Edge/Android) — uses OS-level decoding, * 1. BarcodeDetector (Chrome/Edge/Android) starts immediately — canvas snapshot approach.
* all supported formats, canvas-snapshot approach for reliability. * 2. jsQR starts in parallel after JSQR_DELAY_MS so both libraries run simultaneously.
* 2. jsQR fallback for Safari/Firefox. * First one to decode anything wins. Running both covers cases where BarcodeDetector
* silently returns empty arrays for certain QR variants (e.g. Prismatic Powders).
* *
* Camera permission strategy: * Camera permission:
* - A localStorage flag ('scannerCameraGranted') is set the first time getUserMedia * Pre-warm only fires when navigator.permissions.query returns 'granted' so we never
* succeeds. On subsequent page loads the flag triggers a proactive getUserMedia call * show a browser prompt on page load — if Chrome has the site at "Ask", the prompt
* that succeeds silently when Chrome has the site at "Allow", bypassing any quirk * appears only when the user explicitly clicks Scan Label (once per page session after
* where navigator.permissions.query still returns 'prompt' for localhost. * that, because the stream stays alive between modal opens).
* - The MediaStream is kept alive between modal opens (only closed after 2 min idle
* or page unload) so re-opening the scanner within a session never re-prompts.
*/ */
(function () { (function () {
'use strict'; 'use strict';
const SCAN_URL = '/Inventory/ScanLabel'; const SCAN_URL = '/Inventory/ScanLabel';
const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
const STORAGE_KEY = 'scannerCameraGranted'; const JSQR_DELAY_MS = 1500; // start jsQR fallback this long after BarcodeDetector
const IDLE_RELEASE_MS = 2 * 60 * 1000; const IDLE_RELEASE_MS = 2 * 60 * 1000;
const scanBtn = document.getElementById('scan-label-btn'); const scanBtn = document.getElementById('scan-label-btn');
@@ -27,11 +26,13 @@
if (!scanBtn) return; if (!scanBtn) return;
let stream = null; let stream = null;
let rafId = null; let rafId = null; // BarcodeDetector rAF
let qrFound = false; let rafId2 = null; // jsQR rAF (parallel fallback)
let shutterTimer = null; let jsqrTimer = null; // timer that starts jsQR loop
let idleTimer = null; let qrFound = false;
let shutterTimer = null;
let idleTimer = null;
// ── Modal elements ──────────────────────────────────────────────────── // ── Modal elements ────────────────────────────────────────────────────
@@ -45,14 +46,36 @@
const processingEl = document.getElementById('scan-processing'); const processingEl = document.getElementById('scan-processing');
const processingMsgEl= document.getElementById('scan-processing-msg'); 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; if (!modalEl || !videoEl || !canvasEl) return;
scanBtn.addEventListener('click', openScanner); scanBtn.addEventListener('click', openScanner);
modalEl.addEventListener('hide.bs.modal', onModalClose); modalEl.addEventListener('hide.bs.modal', onModalClose);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame); 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); window.addEventListener('beforeunload', releaseCamera);
// Pre-warm if we've ever gotten camera permission on this site // Pre-warm camera if browser has already granted permission (no prompt risk)
preWarmCamera(); preWarmCamera();
// ── Open / close ────────────────────────────────────────────────────── // ── Open / close ──────────────────────────────────────────────────────
@@ -95,8 +118,10 @@
} }
function stopQrLoop() { function stopQrLoop() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; } if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; } if (rafId2) { cancelAnimationFrame(rafId2); rafId2 = null; }
if (jsqrTimer) { clearTimeout(jsqrTimer); jsqrTimer = null; }
if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; }
} }
function releaseCamera() { function releaseCamera() {
@@ -107,51 +132,54 @@
} }
async function startStream() { async function startStream() {
const s = await navigator.mediaDevices.getUserMedia({ return navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
}); });
localStorage.setItem(STORAGE_KEY, '1'); // remember that user has granted before
return s;
} }
// ── Camera pre-warm ─────────────────────────────────────────────────── // ── Camera pre-warm (no-prompt-on-page-load guarantee) ────────────────
// On page load, if we've seen a grant before, call getUserMedia now.
// When Chrome has the site at "Allow" this succeeds silently — no prompt.
// If Chrome reverted to "Ask" the user sees the prompt on load (once) rather
// than when they tap Scan Label. If permission was revoked, we clear the flag.
async function preWarmCamera() { async function preWarmCamera() {
if (!localStorage.getItem(STORAGE_KEY)) return; // Only attempt if the Permissions API confirms the user has already granted access.
// Skipping when state is 'prompt' ensures we never show a browser dialog on page load.
try { try {
if (!navigator.permissions) return;
const perm = await navigator.permissions.query({ name: 'camera' });
if (perm.state !== 'granted') return;
stream = await startStream(); stream = await startStream();
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
} catch { } catch { /* permission denied or getUserMedia unavailable — ignore */ }
localStorage.removeItem(STORAGE_KEY);
}
} }
// ── QR scanning: BarcodeDetector (canvas) → jsQR fallback ──────────── // ── QR scanning: BarcodeDetector + jsQR in parallel ──────────────────
function startQrLoop() { function startQrLoop() {
if (typeof BarcodeDetector !== 'undefined') { const hasBD = typeof BarcodeDetector !== 'undefined';
if (hasBD) {
startBarcodeDetectorLoop(); startBarcodeDetectorLoop();
} else {
loadJsQR().then(startJsQrLoop).catch(() => {
if (shutterWrap) shutterWrap.classList.remove('d-none');
});
} }
// Always start jsQR after a short delay — runs in parallel with BarcodeDetector
// (or immediately if BarcodeDetector isn't available). This ensures Prismatic-style
// QR codes that BarcodeDetector silently misses still get decoded by jsQR.
jsqrTimer = setTimeout(() => {
if (!qrFound) {
loadJsQR().then(() => { if (!qrFound) startJsQrLoop(); }).catch(() => {
if (!hasBD && shutterWrap) shutterWrap.classList.remove('d-none');
});
}
}, hasBD ? JSQR_DELAY_MS : 0);
} }
// BarcodeDetector loop — canvas snapshot for reliability
async function startBarcodeDetectorLoop() { async function startBarcodeDetectorLoop() {
let detector; let detector;
try { try {
// Use every format the browser supports — we don't hardcode qr_code only
// in case the label uses data_matrix or another variant.
const supported = await BarcodeDetector.getSupportedFormats(); const supported = await BarcodeDetector.getSupportedFormats();
detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] }); detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] });
} catch { } catch {
loadJsQR().then(startJsQrLoop); return; // BarcodeDetector unavailable — jsQR timer will handle it
return;
} }
const ctx = canvasEl.getContext('2d'); const ctx = canvasEl.getContext('2d');
@@ -160,7 +188,6 @@
if (!stream || qrFound) return; if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) { if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) {
try { try {
// Snapshot to canvas — more reliable than detecting from the live video element
canvasEl.width = videoEl.videoWidth; canvasEl.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight; canvasEl.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0); ctx.drawImage(videoEl, 0, 0);
@@ -170,7 +197,7 @@
handleQrResult(codes[0].rawValue); handleQrResult(codes[0].rawValue);
return; return;
} }
} catch { /* frame not ready or detect failed — try next frame */ } } catch { /* frame not ready — try next */ }
} }
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
} }
@@ -178,29 +205,31 @@
rafId = requestAnimationFrame(tick); rafId = requestAnimationFrame(tick);
} }
// jsQR loop — separate canvas context to avoid interfering with BarcodeDetector
function startJsQrLoop() { function startJsQrLoop() {
const ctx = canvasEl.getContext('2d'); const canvas2 = document.createElement('canvas');
const ctx2 = canvas2.getContext('2d');
function tick() { function tick() {
if (!stream || qrFound) return; if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
canvasEl.width = videoEl.videoWidth; canvas2.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight; canvas2.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0); ctx2.drawImage(videoEl, 0, 0);
const imageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height); const imageData = ctx2.getImageData(0, 0, canvas2.width, canvas2.height);
const code = window.jsQR(imageData.data, imageData.width, imageData.height, { const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth' inversionAttempts: 'attemptBoth'
}); });
if (code && code.data) { if (code && code.data && !qrFound) {
qrFound = true; qrFound = true;
handleQrResult(code.data); handleQrResult(code.data);
return; return;
} }
} }
rafId = requestAnimationFrame(tick); rafId2 = requestAnimationFrame(tick);
} }
rafId = requestAnimationFrame(tick); rafId2 = requestAnimationFrame(tick);
} }
function loadJsQR() { function loadJsQR() {
@@ -294,7 +323,15 @@
} }
bsModal.hide(); 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) { } catch (err) {
hideProcessing(); hideProcessing();
@@ -302,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 ─────────────────────────── // ── Fill the inventory form from scan result ───────────────────────────
function fillFromScan(data) { function fillFromScan(data, skipDuplicatePrompt = false) {
const filled = []; const filled = [];
function setIf(id, value, label) { function setIf(id, value, label) {
@@ -397,7 +505,18 @@
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>' ? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
: ''; : '';
if (filled.length > 0) { 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>` +
`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}`); showFormStatus('success', `Filled from label scan: ${filled.join(', ')}.${catalogNote}`);
} else { } else {
showFormStatus('warning', `Label scanned but no empty fields to fill.${catalogNote}`); showFormStatus('warning', `Label scanned but no empty fields to fill.${catalogNote}`);