Compare commits
3 Commits
cf36e41139
...
5e3b0b9ddf
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e3b0b9ddf | |||
| 3aeec4ffb2 | |||
| 28b7b9f86b |
@@ -933,7 +933,9 @@ Rules:
|
||||
/// </summary>
|
||||
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 unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null;
|
||||
var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null;
|
||||
|
||||
@@ -834,11 +834,51 @@ 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
|
||||
{
|
||||
success = true,
|
||||
manufacturer = manufacturer,
|
||||
manufacturerPartNumber= sku,
|
||||
manufacturerPartNumber = sku,
|
||||
colorName = colorName,
|
||||
description = aiResult.Description,
|
||||
finish = catalogMatch?.Finish ?? aiResult.Finish,
|
||||
@@ -848,7 +888,7 @@ public class InventoryController : Controller
|
||||
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
||||
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
||||
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
||||
unitPrice = catalogMatch?.UnitPrice ?? 0m,
|
||||
unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m,
|
||||
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
||||
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
||||
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
|
||||
@@ -856,10 +896,73 @@ public class InventoryController : Controller
|
||||
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">
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
/**
|
||||
* In-browser powder label scanner for the Inventory Create/Edit forms.
|
||||
*
|
||||
* QR scanning priority:
|
||||
* 1. Native BarcodeDetector API (Chrome/Edge/Android) — uses OS-level decoding,
|
||||
* all supported formats, canvas-snapshot approach for reliability.
|
||||
* 2. jsQR fallback for Safari/Firefox.
|
||||
* QR scanning strategy (parallel for maximum compatibility):
|
||||
* 1. BarcodeDetector (Chrome/Edge/Android) starts immediately — canvas snapshot approach.
|
||||
* 2. jsQR starts in parallel after JSQR_DELAY_MS so both libraries run simultaneously.
|
||||
* 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:
|
||||
* - A localStorage flag ('scannerCameraGranted') is set the first time getUserMedia
|
||||
* succeeds. On subsequent page loads the flag triggers a proactive getUserMedia call
|
||||
* that succeeds silently when Chrome has the site at "Allow", bypassing any quirk
|
||||
* where navigator.permissions.query still returns 'prompt' for localhost.
|
||||
* - 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.
|
||||
* Camera permission:
|
||||
* Pre-warm only fires when navigator.permissions.query returns 'granted' so we never
|
||||
* show a browser prompt on page load — if Chrome has the site at "Ask", the prompt
|
||||
* appears only when the user explicitly clicks Scan Label (once per page session after
|
||||
* that, because the stream stays alive between modal opens).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const SCAN_URL = '/Inventory/ScanLabel';
|
||||
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 scanBtn = document.getElementById('scan-label-btn');
|
||||
@@ -28,7 +27,9 @@
|
||||
if (!scanBtn) return;
|
||||
|
||||
let stream = null;
|
||||
let rafId = null;
|
||||
let rafId = null; // BarcodeDetector rAF
|
||||
let rafId2 = null; // jsQR rAF (parallel fallback)
|
||||
let jsqrTimer = null; // timer that starts jsQR loop
|
||||
let qrFound = false;
|
||||
let shutterTimer = null;
|
||||
let idleTimer = null;
|
||||
@@ -45,14 +46,36 @@
|
||||
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 if we've ever gotten camera permission on this site
|
||||
// Pre-warm camera if browser has already granted permission (no prompt risk)
|
||||
preWarmCamera();
|
||||
|
||||
// ── Open / close ──────────────────────────────────────────────────────
|
||||
@@ -96,7 +119,9 @@
|
||||
|
||||
function stopQrLoop() {
|
||||
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() {
|
||||
@@ -107,51 +132,54 @@
|
||||
}
|
||||
|
||||
async function startStream() {
|
||||
const s = await navigator.mediaDevices.getUserMedia({
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
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() {
|
||||
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 {
|
||||
if (!navigator.permissions) return;
|
||||
const perm = await navigator.permissions.query({ name: 'camera' });
|
||||
if (perm.state !== 'granted') return;
|
||||
stream = await startStream();
|
||||
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch { /* permission denied or getUserMedia unavailable — ignore */ }
|
||||
}
|
||||
|
||||
// ── QR scanning: BarcodeDetector (canvas) → jsQR fallback ────────────
|
||||
// ── QR scanning: BarcodeDetector + jsQR in parallel ──────────────────
|
||||
|
||||
function startQrLoop() {
|
||||
if (typeof BarcodeDetector !== 'undefined') {
|
||||
const hasBD = typeof BarcodeDetector !== 'undefined';
|
||||
|
||||
if (hasBD) {
|
||||
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() {
|
||||
let detector;
|
||||
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();
|
||||
detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] });
|
||||
} catch {
|
||||
loadJsQR().then(startJsQrLoop);
|
||||
return;
|
||||
return; // BarcodeDetector unavailable — jsQR timer will handle it
|
||||
}
|
||||
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
@@ -160,7 +188,6 @@
|
||||
if (!stream || qrFound) return;
|
||||
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) {
|
||||
try {
|
||||
// Snapshot to canvas — more reliable than detecting from the live video element
|
||||
canvasEl.width = videoEl.videoWidth;
|
||||
canvasEl.height = videoEl.videoHeight;
|
||||
ctx.drawImage(videoEl, 0, 0);
|
||||
@@ -170,7 +197,7 @@
|
||||
handleQrResult(codes[0].rawValue);
|
||||
return;
|
||||
}
|
||||
} catch { /* frame not ready or detect failed — try next frame */ }
|
||||
} catch { /* frame not ready — try next */ }
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
@@ -178,29 +205,31 @@
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// jsQR loop — separate canvas context to avoid interfering with BarcodeDetector
|
||||
function startJsQrLoop() {
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
const canvas2 = document.createElement('canvas');
|
||||
const ctx2 = canvas2.getContext('2d');
|
||||
|
||||
function tick() {
|
||||
if (!stream || qrFound) return;
|
||||
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
|
||||
canvasEl.width = videoEl.videoWidth;
|
||||
canvasEl.height = videoEl.videoHeight;
|
||||
ctx.drawImage(videoEl, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height);
|
||||
canvas2.width = videoEl.videoWidth;
|
||||
canvas2.height = videoEl.videoHeight;
|
||||
ctx2.drawImage(videoEl, 0, 0);
|
||||
const imageData = ctx2.getImageData(0, 0, canvas2.width, canvas2.height);
|
||||
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'attemptBoth'
|
||||
});
|
||||
if (code && code.data) {
|
||||
if (code && code.data && !qrFound) {
|
||||
qrFound = true;
|
||||
handleQrResult(code.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
rafId2 = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
rafId2 = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function loadJsQR() {
|
||||
@@ -294,7 +323,15 @@
|
||||
}
|
||||
|
||||
bsModal.hide();
|
||||
|
||||
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();
|
||||
@@ -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 ───────────────────────────
|
||||
|
||||
function fillFromScan(data) {
|
||||
function fillFromScan(data, skipDuplicatePrompt = false) {
|
||||
const filled = [];
|
||||
|
||||
function setIf(id, value, label) {
|
||||
@@ -397,7 +505,18 @@
|
||||
? ' <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}`);
|
||||
} else {
|
||||
showFormStatus('warning', `Label scanned but no empty fields to fill.${catalogNote}`);
|
||||
|
||||
Reference in New Issue
Block a user