Add IsIncoming inventory flag and catalog-to-incoming powder flow in item wizard
- InventoryItem.IsIncoming: marks powder ordered but not yet received; enables QR code printing on work orders while the shipment is in transit - InventoryController.CreateIncomingFromCatalog: POST endpoint creates a 0-balance inventory record from a PowderCatalogItem and returns it in wizard-compatible shape - item-wizard.js: custom coat tab now searches the platform powder catalog as a fallback; catalog results show an 'Add as Incoming Order' option; createIncomingFromCatalog POSTs to server and selects the new item without a page refresh - QuoteItemCoatDto: CatalogItemId + AddAsIncoming fields so the wizard can signal server-side incoming-item creation during quote save - Inventory Create/Edit/Index views: IsIncoming badge and field - IInventoryAiLookupService: minor interface update - Migration: AddInventoryIsIncoming Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -708,7 +708,6 @@ public class InventoryController : Controller
|
||||
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||
|
||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||
if (result.Success) await ApplyTdsCureFallbackAsync(result, colorName);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
@@ -801,17 +800,15 @@ public class InventoryController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If cure temperature or cure time is still missing after the primary lookup but a TDS URL
|
||||
/// was returned, fetches that page and asks Claude to extract only the cure schedule.
|
||||
/// Mutates <paramref name="result"/> in place; silently no-ops on failure so callers
|
||||
/// can always return the result even if the TDS fetch does not help.
|
||||
/// When cure specs are still missing after a primary AI lookup (LookupAsync or ScanLabelAsync),
|
||||
/// fetches the TDS URL that Claude returned and asks it to extract only the cure schedule.
|
||||
/// Not used by AiAugmentFromUrl — that path uses LookupByUrlAsync which has TDS fallback built in.
|
||||
/// </summary>
|
||||
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
|
||||
{
|
||||
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
|
||||
&& !string.IsNullOrEmpty(result.TdsUrl))
|
||||
{
|
||||
_logger.LogInformation("Cure specs missing after lookup; trying TDS at {Url}", result.TdsUrl);
|
||||
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
|
||||
if (tds.Success)
|
||||
{
|
||||
@@ -1118,6 +1115,109 @@ public class InventoryController : Controller
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 0-balance inventory item from a PowderCatalogItem record and marks it IsIncoming=true.
|
||||
/// Called by the item wizard when a staff member needs to quote a powder that has been ordered
|
||||
/// but not yet received — the inventory record enables QR code printing on the work order.
|
||||
/// Returns the new item's data in the same shape as the inventoryPowdersData list so the wizard
|
||||
/// can add it to powderData and select it immediately without a page refresh.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CreateIncomingFromCatalog(int catalogItemId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
||||
if (catalogItem == null)
|
||||
return Json(new { success = false, error = "Catalog item not found." });
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Find the default coating category to assign
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (coatingCategory == null)
|
||||
return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });
|
||||
|
||||
// Generate a unique SKU following the same pattern as GenerateSku: {CODE}-{YYMM}-{####}
|
||||
var code = coatingCategory.CategoryCode.Length >= 4
|
||||
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
||||
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
|
||||
var yearMonth = DateTime.Now.ToString("yyMM");
|
||||
var prefix = $"{code}-{yearMonth}-";
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
||||
|
||||
var item = new InventoryItem
|
||||
{
|
||||
SKU = sku,
|
||||
Name = ToTitleCase($"{catalogItem.VendorName} {catalogItem.ColorName}"),
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber= catalogItem.Sku,
|
||||
Finish = catalogItem.Finish,
|
||||
ColorFamilies = catalogItem.ColorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
CoverageSqFtPerLb = catalogItem.CoverageSqFtPerLb ?? 30m,
|
||||
TransferEfficiency = GetEffectiveTransferEfficiency(catalogItem.TransferEfficiency),
|
||||
CureTemperatureF = catalogItem.CureTemperatureF,
|
||||
CureTimeMinutes = catalogItem.CureTimeMinutes,
|
||||
SpecificGravity = catalogItem.SpecificGravity,
|
||||
SpecPageUrl = catalogItem.ProductUrl,
|
||||
ImageUrl = catalogItem.ImageUrl,
|
||||
SdsUrl = catalogItem.SdsUrl,
|
||||
TdsUrl = catalogItem.TdsUrl,
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
InventoryCategoryId = coatingCategory.Id,
|
||||
Category = coatingCategory.DisplayName,
|
||||
IsActive = true,
|
||||
IsIncoming = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created incoming inventory item {ItemId} ({ItemName}) from catalog item {CatalogId} for company {CompanyId}",
|
||||
item.Id, item.Name, catalogItemId, companyId);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
value = item.Id.ToString(),
|
||||
text = $"[INCOMING] {coatingCategory.DisplayName} - {item.Manufacturer ?? "Generic"} - {item.ColorName ?? item.Name} - {item.ManufacturerPartNumber ?? "N/A"} ({item.UnitCost:C4}/unit)",
|
||||
coverage = item.CoverageSqFtPerLb ?? 30m,
|
||||
efficiency = item.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure= item.UnitOfMeasure,
|
||||
categoryName = coatingCategory.DisplayName,
|
||||
costPerLb = item.UnitCost,
|
||||
colorName = item.ColorName ?? item.Name,
|
||||
colorCode = "",
|
||||
isIncoming = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create incoming inventory item from catalog {CatalogItemId}", catalogItemId);
|
||||
return Json(new { success = false, error = "Failed to create inventory item. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||
{
|
||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<!-- Notes & Status -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
|
||||
@@ -397,6 +397,17 @@
|
||||
<textarea asp-for="Notes" class="form-control" rows="3"></textarea>
|
||||
<span asp-validation-for="Notes" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input asp-for="IsIncoming" class="form-check-input" id="IsIncoming" />
|
||||
<label class="form-check-label fw-semibold" for="IsIncoming">
|
||||
<i class="bi bi-truck me-1 text-warning"></i>Incoming / On Order
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">
|
||||
Check this when the powder has been ordered but not yet received. It will appear with an "Incoming" badge in the inventory list and can be selected on quotes so staff can print QR codes while the powder is in transit. Pricing will charge for the full ordered quantity.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -398,6 +398,17 @@
|
||||
<textarea asp-for="Notes" class="form-control" rows="3"></textarea>
|
||||
<span asp-validation-for="Notes" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input asp-for="IsIncoming" class="form-check-input" id="IsIncoming" />
|
||||
<label class="form-check-label fw-semibold" for="IsIncoming">
|
||||
<i class="bi bi-truck me-1 text-warning"></i>Incoming / On Order
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">
|
||||
Uncheck once the powder has been received to mark it as regular in-stock inventory.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -275,7 +275,13 @@
|
||||
<span class="fw-semibold">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (item.IsActive)
|
||||
@if (item.IsIncoming)
|
||||
{
|
||||
<span class="badge bg-warning bg-opacity-25 text-warning-emphasis">
|
||||
<i class="bi bi-truck me-1"></i>Incoming
|
||||
</span>
|
||||
}
|
||||
else if (item.IsActive)
|
||||
{
|
||||
<span class="badge bg-success bg-opacity-10 text-success">
|
||||
<i class="bi bi-check-circle me-1"></i>Active
|
||||
|
||||
@@ -84,7 +84,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
|
||||
}
|
||||
|
||||
// Close any open powder combobox dropdown when clicking outside it
|
||||
// Close any open powder combobox or catalog lookup dropdown when clicking outside it
|
||||
document.addEventListener('click', e => {
|
||||
document.querySelectorAll('[id^="coat_powder_wrapper_"]').forEach(wrapper => {
|
||||
if (!wrapper.contains(e.target)) {
|
||||
@@ -92,6 +92,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
powderComboClose(parseInt(idx));
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('[id^="coat_catalog_results_"]').forEach(dd => {
|
||||
const idx = dd.id.replace('coat_catalog_results_', '');
|
||||
const wrapper = document.getElementById(`coat_catalog_search_wrapper_${idx}`);
|
||||
if (!wrapper?.contains(e.target) && !dd.contains(e.target))
|
||||
dd.style.display = 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1640,9 +1646,46 @@ function buildCoatRowHtml(i, coat) {
|
||||
<input type="number" class="form-control" id="coat_costPerLb_${i}" min="0" step="0.01" placeholder="auto" value="${coat.powderCostPerLb || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Shown only when an incoming (on-order) inventory powder is selected -->
|
||||
<div class="col-12" id="coat_incoming_section_${i}" style="display:${coat.isIncoming ? 'block' : 'none'}">
|
||||
<div class="alert alert-warning py-2 mb-0">
|
||||
<div class="fw-semibold"><i class="bi bi-truck me-1"></i>Incoming / On Order — powder not yet in stock</div>
|
||||
<div class="small mt-1 mb-2">Pricing will charge for the full quantity ordered, not just calculated usage.</div>
|
||||
<label class="form-label form-label-sm fw-semibold mb-1"><i class="bi bi-cart me-1"></i>Qty to Order (lbs)</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="input-group input-group-sm" style="max-width:200px">
|
||||
<input type="number" class="form-control" id="coat_incoming_orderQty_${i}" min="0" step="0.01"
|
||||
placeholder="Lbs to order" value="${coat.isIncoming && coat.powderToOrder ? coat.powderToOrder : ''}">
|
||||
<span class="input-group-text">lbs</span>
|
||||
</div>
|
||||
<span class="text-muted small">Calculated from area: <strong id="coat_incoming_calcQty_${i}">—</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Custom powder -->
|
||||
<div id="coat_custom_section_${i}" class="row g-2" style="display:${coat.powderType === 'custom' ? 'flex' : 'none'}">
|
||||
<!-- Catalog lookup row -->
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-grow-1" style="max-width:360px;" id="coat_catalog_search_wrapper_${i}">
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search text-muted" style="font-size:.8rem;"></i></span>
|
||||
<input type="text" class="form-control form-control-sm" id="coat_catalog_q_${i}"
|
||||
placeholder="Lookup from catalog (color name or SKU)…"
|
||||
oninput="customPowderCatalogInput(${i})"
|
||||
onkeydown="if(event.key==='Escape'){customPowderCatalogClose(${i})}"
|
||||
autocomplete="off">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="customPowderCatalogClose(${i})" title="Clear lookup">
|
||||
<i class="bi bi-x" style="font-size:.8rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-muted small fst-italic" style="font-size:.75rem;">or fill in manually below</span>
|
||||
</div>
|
||||
<div id="coat_catalog_results_${i}"
|
||||
class="powder-combo-dropdown"
|
||||
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label form-label-sm">Color Name</label>
|
||||
<input type="text" class="form-control form-control-sm" id="coat_colorName_${i}" value="${escHtml(coat.colorName || '')}" placeholder="e.g., Gloss Black">
|
||||
@@ -1679,6 +1722,16 @@ function buildCoatRowHtml(i, coat) {
|
||||
<input type="number" class="form-control" id="coat_custom_costPerLb_${i}" min="0" step="0.01" placeholder="0.00" value="${coat.powderCostPerLb || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<!-- "Add to inventory as incoming" — shown after a catalog selection -->
|
||||
<div class="col-12" id="coat_custom_incoming_opt_${i}" style="display:${coat.catalogItemId ? 'block' : 'none'}">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="coat_custom_addIncoming_${i}" ${coat.addAsIncoming ? 'checked' : ''}>
|
||||
<label class="form-check-label small fw-semibold" for="coat_custom_addIncoming_${i}">
|
||||
<i class="bi bi-truck text-warning me-1"></i>Add to inventory as Incoming Order (enables QR codes on work orders)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="coat_custom_catalogItemId_${i}" value="${coat.catalogItemId || ''}">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning py-2 mb-0">
|
||||
<label class="form-label form-label-sm fw-semibold mb-1"><i class="bi bi-cart me-1"></i>Qty to Order (lbs) — this powder must be purchased before the job</label>
|
||||
@@ -1738,6 +1791,15 @@ function restoreCoatRow(i, coat) {
|
||||
const el = document.getElementById(`coat_custom_orderQty_${i}`);
|
||||
if (el) el.value = coat.powderToOrder;
|
||||
}
|
||||
// Restore incoming state for stock coats backed by an incoming inventory item
|
||||
if (coat.powderType !== 'custom' && coat.isIncoming) {
|
||||
const section = document.getElementById(`coat_incoming_section_${i}`);
|
||||
if (section) section.style.display = 'block';
|
||||
if (coat.powderToOrder != null) {
|
||||
const el = document.getElementById(`coat_incoming_orderQty_${i}`);
|
||||
if (el) el.value = coat.powderToOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeCoatRow(i) {
|
||||
@@ -1769,9 +1831,11 @@ function powderComboInput(i) {
|
||||
const q = document.getElementById(`coat_powder_search_${i}`)?.value?.toLowerCase() || '';
|
||||
powderComboRender(i, q);
|
||||
powderComboShow(i);
|
||||
// Clear the hidden value when the user edits the text (forces a fresh pick)
|
||||
// Clear the hidden value and incoming section when the user edits the text (forces a fresh pick)
|
||||
const hidden = document.getElementById(`coat_inventoryItemId_${i}`);
|
||||
if (hidden) hidden.value = '';
|
||||
const incomingSection = document.getElementById(`coat_incoming_section_${i}`);
|
||||
if (incomingSection) incomingSection.style.display = 'none';
|
||||
}
|
||||
|
||||
function powderComboOpen(i) {
|
||||
@@ -1798,19 +1862,30 @@ function powderComboRender(i, query) {
|
||||
? powderData.filter(p => p.text.toLowerCase().includes(query))
|
||||
: powderData;
|
||||
if (filtered.length === 0) {
|
||||
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No powders match your search</div>';
|
||||
const qEnc = encodeURIComponent(query || '');
|
||||
dd.innerHTML = `<div class="px-3 py-2 text-muted small">No inventory match.</div>
|
||||
${query && query.length >= 2 ? `<div class="px-2 pb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-warning w-100"
|
||||
onmousedown="event.preventDefault(); powderCatalogSearch(${i}, '${query.replace(/'/g, "\\'")}')">
|
||||
<i class="bi bi-search me-1"></i>Search Catalog & Add as Incoming Order
|
||||
</button>
|
||||
</div>` : ''}`;
|
||||
return;
|
||||
}
|
||||
dd.innerHTML = filtered.map(p =>
|
||||
`<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
||||
dd.innerHTML = filtered.map(p => {
|
||||
const badge = p.isIncoming
|
||||
? '<span class="badge bg-warning text-dark ms-1" style="font-size:.7rem;vertical-align:middle;">Incoming</span>'
|
||||
: '';
|
||||
const displayText = p.isIncoming ? p.text.replace(/^\[INCOMING\]\s*/, '') : p.text;
|
||||
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
||||
data-val="${escHtml(String(p.value))}"
|
||||
data-txt="${escHtml(p.text)}"
|
||||
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
|
||||
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
|
||||
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
|
||||
${escHtml(p.text)}
|
||||
</div>`
|
||||
).join('');
|
||||
${escHtml(displayText)}${badge}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function powderComboShow(i) {
|
||||
@@ -1869,6 +1944,166 @@ function powderComboKey(event, i) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Custom coat catalog lookup ───────────────────────────────────────────────
|
||||
|
||||
let customCatalogDebounce = null;
|
||||
|
||||
function customPowderCatalogInput(i) {
|
||||
clearTimeout(customCatalogDebounce);
|
||||
const q = document.getElementById(`coat_catalog_q_${i}`)?.value?.trim() || '';
|
||||
if (q.length < 2) {
|
||||
// Hide dropdown only — do NOT clear the input (that would erase the user's typing)
|
||||
const dd = document.getElementById(`coat_catalog_results_${i}`);
|
||||
if (dd) dd.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
customCatalogDebounce = setTimeout(() => customPowderCatalogSearch(i, q), 300);
|
||||
}
|
||||
|
||||
function customPowderCatalogSearch(i, query) {
|
||||
const dd = document.getElementById(`coat_catalog_results_${i}`);
|
||||
if (!dd) return;
|
||||
const anchor = document.getElementById(`coat_catalog_q_${i}`);
|
||||
dd.innerHTML = `<div class="px-3 py-2 text-muted small"><i class="bi bi-hourglass-split me-1"></i>Searching…</div>`;
|
||||
// Position relative to the search input wrapper
|
||||
const rect = anchor?.closest('.input-group')?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
dd.style.position = 'fixed';
|
||||
dd.style.top = (rect.bottom + 2) + 'px';
|
||||
dd.style.left = rect.left + 'px';
|
||||
dd.style.width = rect.width + 'px';
|
||||
}
|
||||
dd.style.display = 'block';
|
||||
|
||||
fetch(`/Inventory/CatalogLookup?q=${encodeURIComponent(query)}`)
|
||||
.then(r => r.json())
|
||||
.then(results => {
|
||||
if (!results || results.length === 0) {
|
||||
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No catalog matches. Enter details manually below.</div>';
|
||||
return;
|
||||
}
|
||||
dd.innerHTML = results.map(r => {
|
||||
const disc = r.isDiscontinued ? '<span class="badge bg-secondary ms-1" style="font-size:.7rem;">Discontinued</span>' : '';
|
||||
const price = r.unitPrice ? `<span class="text-muted small ms-1">$${parseFloat(r.unitPrice).toFixed(2)}/lb</span>` : '';
|
||||
return `<div class="powder-opt" style="padding:.4rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
||||
onmousedown="event.preventDefault(); applyCustomCatalogResult(${i}, ${JSON.stringify(r).replace(/"/g, '"')})"
|
||||
onmouseenter="this.style.background='#f0f4ff'"
|
||||
onmouseleave="this.style.background=''">
|
||||
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)}
|
||||
<span class="text-muted small ms-1">${escHtml(r.sku || '')}</span>
|
||||
${price}${disc}
|
||||
</div>`;
|
||||
}).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
dd.innerHTML = '<div class="px-3 py-2 text-danger small">Search failed. Enter details manually.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function customPowderCatalogClose(i) {
|
||||
const dd = document.getElementById(`coat_catalog_results_${i}`);
|
||||
if (dd) dd.style.display = 'none';
|
||||
const qEl = document.getElementById(`coat_catalog_q_${i}`);
|
||||
if (qEl) qEl.value = '';
|
||||
}
|
||||
|
||||
function applyCustomCatalogResult(i, r) {
|
||||
customPowderCatalogClose(i);
|
||||
// Fill in the custom fields from the catalog result
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el && val != null) el.value = val; };
|
||||
set(`coat_colorName_${i}`, r.colorName);
|
||||
set(`coat_colorCode_${i}`, r.sku || '');
|
||||
set(`coat_finish_${i}`, r.finish || '');
|
||||
if (r.coverageSqFtPerLb) set(`coat_custom_coverage_${i}`, r.coverageSqFtPerLb);
|
||||
if (r.transferEfficiency) set(`coat_custom_efficiency_${i}`, r.transferEfficiency);
|
||||
if (r.unitPrice) set(`coat_custom_costPerLb_${i}`, parseFloat(r.unitPrice).toFixed(2));
|
||||
// Store catalog item ID and show "Add to inventory as Incoming" checkbox (default: checked)
|
||||
set(`coat_custom_catalogItemId_${i}`, r.id);
|
||||
const incomingOpt = document.getElementById(`coat_custom_incoming_opt_${i}`);
|
||||
if (incomingOpt) incomingOpt.style.display = 'block';
|
||||
const addIncomingCheck = document.getElementById(`coat_custom_addIncoming_${i}`);
|
||||
if (addIncomingCheck) addIncomingCheck.checked = true;
|
||||
// Try to match catalog vendor name to a local supplier
|
||||
const vendorLower = (r.vendorName || '').toLowerCase();
|
||||
if (vendorLower) {
|
||||
const supplierMatch = supplierData.find(s => {
|
||||
const sLower = s.text.toLowerCase();
|
||||
return sLower.includes(vendorLower) || vendorLower.includes(sLower);
|
||||
});
|
||||
if (supplierMatch) {
|
||||
const supplierSel = document.getElementById(`coat_supplierId_${i}`);
|
||||
if (supplierSel) supplierSel.value = supplierMatch.value;
|
||||
}
|
||||
}
|
||||
updatePowderNeeded(i);
|
||||
}
|
||||
|
||||
// ─── Stock-side catalog search (fallback when no inventory match) ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// Searches the platform powder catalog for items matching the query string and renders
|
||||
/// them in the dropdown as "Add as Incoming Order" options. If the user clicks one,
|
||||
/// <see cref="createIncomingFromCatalog"/> POSTs to the server to create a 0-balance
|
||||
/// inventory item with IsIncoming=true and then selects it for the current coat.
|
||||
/// </summary>
|
||||
function powderCatalogSearch(i, query) {
|
||||
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
|
||||
if (!dd) return;
|
||||
dd.innerHTML = `<div class="px-3 py-2 text-muted small"><i class="bi bi-hourglass-split me-1"></i>Searching catalog…</div>`;
|
||||
powderComboShow(i);
|
||||
fetch(`/Inventory/CatalogLookup?q=${encodeURIComponent(query)}`)
|
||||
.then(r => r.json())
|
||||
.then(results => {
|
||||
if (!results || results.length === 0) {
|
||||
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No catalog matches found. Try a different search term.</div>';
|
||||
return;
|
||||
}
|
||||
dd.innerHTML = `<div class="px-3 py-1 text-muted small fw-semibold border-bottom" style="font-size:.75rem;">Catalog Results — click to add as Incoming Order</div>` +
|
||||
results.map(r => {
|
||||
const label = r.isDiscontinued
|
||||
? `<span class="badge bg-secondary ms-1" style="font-size:.7rem;">Discontinued</span>`
|
||||
: '';
|
||||
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
||||
onmousedown="event.preventDefault(); createIncomingFromCatalog(${i}, ${r.id})"
|
||||
onmouseenter="this.style.background='#fff8e1'"
|
||||
onmouseleave="this.style.background=''">
|
||||
<i class="bi bi-truck text-warning me-1"></i>
|
||||
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)} ${escHtml(r.sku || '')}
|
||||
<span class="text-muted small ms-1">$${parseFloat(r.unitPrice || 0).toFixed(2)}/lb</span>${label}
|
||||
</div>`;
|
||||
}).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
dd.innerHTML = '<div class="px-3 py-2 text-danger small">Catalog search failed. Please try again.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function createIncomingFromCatalog(i, catalogItemId) {
|
||||
powderComboClose(i);
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
const searchEl = document.getElementById(`coat_powder_search_${i}`);
|
||||
if (searchEl) searchEl.value = 'Adding to inventory…';
|
||||
|
||||
const body = new URLSearchParams({ catalogItemId, __RequestVerificationToken: token || '' });
|
||||
fetch('/Inventory/CreateIncomingFromCatalog', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
if (searchEl) searchEl.value = '';
|
||||
alert(data.error || 'Failed to create inventory item.');
|
||||
return;
|
||||
}
|
||||
// Add the new item to powderData so it can be found by onPowderSelected
|
||||
powderData.push(data);
|
||||
// Select it as the current coat's powder
|
||||
powderComboSelect(i, data.value, data.text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (searchEl) searchEl.value = '';
|
||||
alert('Failed to create inventory item. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
function onPowderSelected(i) {
|
||||
const sel = document.getElementById(`coat_inventoryItemId_${i}`);
|
||||
if (!sel || !sel.value) return;
|
||||
@@ -1880,6 +2115,11 @@ function onPowderSelected(i) {
|
||||
if (covEl) covEl.value = powder.coverage;
|
||||
if (effEl) effEl.value = powder.efficiency;
|
||||
if (costEl && powder.costPerLb) costEl.value = parseFloat(powder.costPerLb).toFixed(2);
|
||||
|
||||
// Show the incoming-order-qty section when the selected powder is incoming
|
||||
const incomingSection = document.getElementById(`coat_incoming_section_${i}`);
|
||||
if (incomingSection) incomingSection.style.display = powder.isIncoming ? 'block' : 'none';
|
||||
|
||||
updatePowderNeeded(i);
|
||||
}
|
||||
|
||||
@@ -1899,9 +2139,14 @@ function updatePowderNeeded(i) {
|
||||
const valEl = document.getElementById(`coat_powderNeededVal_${i}`);
|
||||
if (valEl) valEl.textContent = lbs.toFixed(2) + ' lbs';
|
||||
|
||||
// Update the suggested qty label next to the custom order qty input
|
||||
// Update the suggested qty labels for custom and incoming order qty inputs
|
||||
const calcQtyEl = document.getElementById(`coat_custom_calcQty_${i}`);
|
||||
if (calcQtyEl) calcQtyEl.textContent = lbs.toFixed(2) + ' lbs';
|
||||
const incomingCalcEl = document.getElementById(`coat_incoming_calcQty_${i}`);
|
||||
if (incomingCalcEl) incomingCalcEl.textContent = lbs.toFixed(2) + ' lbs';
|
||||
// Pre-fill incoming order qty if empty
|
||||
const incomingQtyEl = document.getElementById(`coat_incoming_orderQty_${i}`);
|
||||
if (incomingQtyEl && !incomingQtyEl.value) incomingQtyEl.value = lbs.toFixed(2);
|
||||
}
|
||||
|
||||
function updateAllPowderNeeded() {
|
||||
@@ -2238,15 +2483,20 @@ function collectStep3() {
|
||||
if (!isCustom) {
|
||||
const invId = document.getElementById(`coat_inventoryItemId_${i}`)?.value;
|
||||
coat.inventoryItemId = invId ? parseInt(invId) : null;
|
||||
// Resolve color name from powderData for display purposes
|
||||
// Resolve color name and incoming flag from powderData for display purposes
|
||||
let isIncomingCoat = false;
|
||||
if (coat.inventoryItemId) {
|
||||
const powder = powderData.find(p => p.value === String(invId));
|
||||
if (powder) coat.colorName = powder.colorName || null;
|
||||
if (powder) {
|
||||
coat.colorName = powder.colorName || null;
|
||||
isIncomingCoat = powder.isIncoming || false;
|
||||
}
|
||||
}
|
||||
coat.coverageSqFtPerLb = parseFloat(document.getElementById(`coat_coverage_${i}`)?.value) || 30;
|
||||
coat.transferEfficiency = parseFloat(document.getElementById(`coat_efficiency_${i}`)?.value) || 65;
|
||||
const costEl = document.getElementById(`coat_costPerLb_${i}`)?.value;
|
||||
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null;
|
||||
coat.isIncoming = isIncomingCoat;
|
||||
} else {
|
||||
coat.colorName = document.getElementById(`coat_colorName_${i}`)?.value?.trim() || null;
|
||||
coat.colorCode = document.getElementById(`coat_colorCode_${i}`)?.value?.trim() || null;
|
||||
@@ -2257,12 +2507,19 @@ function collectStep3() {
|
||||
coat.transferEfficiency = parseFloat(document.getElementById(`coat_custom_efficiency_${i}`)?.value) || 65;
|
||||
const costEl = document.getElementById(`coat_custom_costPerLb_${i}`)?.value;
|
||||
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null;
|
||||
// Catalog lookup result fields
|
||||
const catId = document.getElementById(`coat_custom_catalogItemId_${i}`)?.value;
|
||||
coat.catalogItemId = catId ? parseInt(catId) : null;
|
||||
coat.addAsIncoming = document.getElementById(`coat_custom_addIncoming_${i}`)?.checked || false;
|
||||
}
|
||||
|
||||
// Powder to order: custom coats read from the user-entered field; stock coats auto-calculate
|
||||
// Powder to order: custom/incoming coats read from the user-entered field; in-stock auto-calculates
|
||||
if (isCustom) {
|
||||
const orderQtyVal = document.getElementById(`coat_custom_orderQty_${i}`)?.value;
|
||||
coat.powderToOrder = orderQtyVal ? parseFloat(orderQtyVal) : null;
|
||||
} else if (coat.isIncoming) {
|
||||
const orderQtyVal = document.getElementById(`coat_incoming_orderQty_${i}`)?.value;
|
||||
coat.powderToOrder = orderQtyVal ? parseFloat(orderQtyVal) : null;
|
||||
} else {
|
||||
const sqft = parseFloat(wz.data.surfaceAreaSqFt) || 0;
|
||||
const qty = parseInt(wz.data.quantity) || 1;
|
||||
@@ -2295,7 +2552,10 @@ function preFillStep2() {
|
||||
|
||||
if (wz.itemType === 'product' && d.catalogItemId) {
|
||||
const listItem = document.querySelector(`#catalogListbox [data-value="${d.catalogItemId}"]`);
|
||||
if (listItem) pickCatalogItem(listItem);
|
||||
if (listItem) {
|
||||
pickCatalogItem(listItem);
|
||||
listItem.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
if (wz.itemType === 'calculated') {
|
||||
@@ -2534,6 +2794,8 @@ function writeHiddenFields() {
|
||||
if (coat.powderToOrder) fields.push(h(cp + '.PowderToOrder', coat.powderToOrder));
|
||||
if (coat.notes) fields.push(h(cp + '.Notes', coat.notes));
|
||||
fields.push(h(cp + '.NoExtraLayerCharge', coat.noExtraLayerCharge ? 'true' : 'false'));
|
||||
if (coat.catalogItemId) fields.push(h(cp + '.CatalogItemId', coat.catalogItemId));
|
||||
if (coat.addAsIncoming) fields.push(h(cp + '.AddAsIncoming', 'true'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user