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:
2026-05-08 20:47:19 -04:00
parent f40d58ac2e
commit fc35fd123c
10 changed files with 10038 additions and 22 deletions
+275 -13
View File
@@ -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 &amp; 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, '&quot;')})"
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'));
});
});