Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/item-wizard.js
T
spouliot 7239f55308 Fix tax-exempt customers always charged tax in quote preview
parseFloat('0') is falsy in JS, so '0 || pageMeta.taxPercent' was
falling through to the company default rate even when the TaxPercent
field was correctly set to 0 for a tax-exempt customer. Use an
explicit field presence check instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:05:07 -04:00

3480 lines
171 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* item-wizard.js
* Generic multi-step item wizard — shared by quotes and jobs.
* Configured via pageMeta (itemsFieldPrefix, pricingUrl, etc.)
*
* Step 1: Choose item type (Product / Custom / Flat-Rate / Labor)
* Step 2: Gather item details (fields vary by type)
* Step 3: Coating layers (Product & Custom only; skipped for Flat-Rate & Labor)
*
* Auto-calculates pricing after each item add/edit/remove.
*/
// ─── State ────────────────────────────────────────────────────────────────────
let quoteItems = []; // Array of item objects matching CreateQuoteItemDto shape
const wz = { // Wizard state
step: 1,
editIndex: -1, // -1 = new item; >= 0 = editing
itemType: null, // 'product' | 'calculated' | 'generic' | 'labor' | 'ai'
data: {}, // Collected field values
ai: { // AI-specific wizard state
phase: 'upload', // 'upload' | 'loading' | 'followup' | 'result'
tempIds: [], // Temp photo IDs from server
fileNames: [], // Display file names
result: null, // Last AiAnalyzeItemResult from server
conversationHistory: [],
accepted: false,
tags: [] // Editable tags (AI-generated + user additions)
}
};
// ─── Page metadata (from embedded JSON) ───────────────────────────────────────
let pageMeta = {};
let powderData = [];
let catalogData = [];
let merchandiseData = [];
let supplierData = [];
let prepServiceData = [];
let blastSetupData = [];
document.addEventListener('DOMContentLoaded', () => {
const metaEl = document.getElementById('quoteMetaData');
if (metaEl) pageMeta = JSON.parse(metaEl.textContent);
const powderEl = document.getElementById('inventoryPowdersData');
if (powderEl) powderData = JSON.parse(powderEl.textContent);
const catalogEl = document.getElementById('catalogItemsData');
if (catalogEl) catalogData = JSON.parse(catalogEl.textContent);
const merchEl = document.getElementById('merchandiseItemsData');
if (merchEl) merchandiseData = JSON.parse(merchEl.textContent);
const supplierEl = document.getElementById('vendorsData');
if (supplierEl) supplierData = JSON.parse(supplierEl.textContent);
const prepEl = document.getElementById('prepServicesData');
if (prepEl) prepServiceData = JSON.parse(prepEl.textContent);
const blastEl = document.getElementById('blastSetupsData');
if (blastEl) blastSetupData = JSON.parse(blastEl.textContent);
// Restore items from server round-trip (validation failure re-render)
const existingEl = document.getElementById('existingItemsData');
if (existingEl) {
try {
quoteItems = JSON.parse(existingEl.textContent);
renderAllCards();
writeHiddenFields();
window.scrollTo({ top: 0, behavior: 'instant' });
scheduleAutoPricing();
} catch (err) {
console.error('item-wizard: failed to restore items from server model:', err);
}
}
// Guarantee hidden fields are always written on form submission, even if the wizard
// was never interacted with (e.g. validation round-trip with pre-existing items).
const hfc = document.getElementById('hiddenFieldsContainer');
const ownerForm = hfc?.closest('form');
if (ownerForm) {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
}
// 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)) {
const idx = wrapper.id.replace('coat_powder_wrapper_', '');
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';
});
});
});
// ─── Wizard open / close ──────────────────────────────────────────────────────
function openWizard(editIndex = -1) {
wz.editIndex = editIndex;
wz.step = 1;
wz.data = {};
wz.ai = { phase: 'upload', tempIds: [], fileNames: [], previewUrls: [], result: null, conversationHistory: [], accepted: false, tags: [], followUpQuestion: null };
if (editIndex >= 0) {
const item = quoteItems[editIndex];
wz.itemType = item.isLaborItem ? 'labor'
: item.isSalesItem ? 'sales'
: item.isGenericItem ? 'generic'
: item.catalogItemId ? 'product'
: item.isAiItem ? 'ai'
: 'calculated';
// Pre-fill wizard data from existing item
wz.data = JSON.parse(JSON.stringify(item)); // deep copy
// Restore sales item catalog reference
if (wz.itemType === 'sales') {
wz.data.salesCatalogItemId = item.catalogItemId || null;
}
// Restore AI state if editing an AI item
if (wz.itemType === 'ai') {
wz.ai.tempIds = item.aiPhotoTempIds || [];
wz.ai.fileNames = item.aiPhotoFileNames || [];
wz.ai.previewUrls = item.aiPhotoPreviewUrls || [];
wz.ai.tags = item.aiTags ? item.aiTags.split(',').filter(Boolean) : [];
wz.ai.accepted = true;
wz.ai.phase = 'result';
// Reconstruct a synthetic result from saved values so the item can be saved
// without forcing another round-trip to the AI engine. confidence=null signals
// the card to render as "Saved Estimate" instead of "AI Estimate Ready".
wz.ai.result = {
description: item.description || '',
surfaceAreaSqFt: item.surfaceAreaSqFt || 0,
complexity: item.complexity || 'Moderate',
estimatedMinutes: item.estimatedMinutes || 30,
estimatedUnitPrice: item.manualUnitPrice || 0,
estimatedTotal: (item.manualUnitPrice || 0) * (item.quantity || 1),
confidence: null, // unknown on edit — renders "Saved Estimate" header
aiReasoning: null,
benchmark: null,
breakdown: null, // aiRecalcPrice returns early; user can override price manually
tags: item.aiTags ? item.aiTags.split(',').filter(Boolean) : [],
aiPredictionId: item.aiPredictionId || null
};
}
} else {
wz.itemType = null;
}
updateWizardTitle();
renderStep(1);
updateWizardButtons();
const modal = new bootstrap.Modal(document.getElementById('itemWizardModal'));
modal.show();
}
function closeWizard() {
bootstrap.Modal.getInstance(document.getElementById('itemWizardModal'))?.hide();
}
// ─── Step navigation ──────────────────────────────────────────────────────────
// Guard against Android ghost-clicks: after a touch tap the browser fires a
// synthetic click ~300ms later at the same screen coordinates. By then the
// Next button has been replaced by Save, so the ghost click saves the item
// before the user sees step 4. Disabling for 400ms covers the ghost-click window.
let _wizardNavBusy = false;
function _guardNav() {
if (_wizardNavBusy) return false;
_wizardNavBusy = true;
setTimeout(() => { _wizardNavBusy = false; }, 400);
return true;
}
function wizardNext() {
if (!_guardNav()) return;
if (!collectCurrentStep()) return; // validation failed
if (wz.step === 1) {
wz.step = 2;
} else if (wz.step === 2) {
// Generic / labor / sales: step 2 is the last step
if (wz.itemType === 'generic' || wz.itemType === 'labor' || wz.itemType === 'sales') {
wizardSave();
return;
}
// AI: step 2 must be accepted before proceeding
if (wz.itemType === 'ai') {
if (!wz.ai.accepted) {
document.getElementById('ai_acceptError')?.classList.remove('d-none');
return; // don't advance
}
}
wz.step = 3;
} else if (wz.step === 3) {
// Step 4 (prep services) for product, calculated, and ai items — always shown
// so users with no prep services configured see the empty-state prompt
wz.step = 4;
} else if (wz.step === 4) {
// Calculated and AI items offer a Save-to-Catalog step before finalising
if (wz.itemType === 'calculated' || wz.itemType === 'ai') {
wz.step = 5;
} else {
wizardSave();
return;
}
} else if (wz.step === 5) {
// Step 5 actions are handled by saveToCatalogFromWizard() / skipCatalogSave()
return;
}
renderStep(wz.step);
updateWizardButtons();
updateStepDots();
}
function wizardBack() {
if (!_guardNav()) return;
if (wz.step === 5) wz.step = 4;
else if (wz.step === 4) wz.step = 3;
else if (wz.step === 3) wz.step = 2;
else if (wz.step === 2) wz.step = 1;
renderStep(wz.step);
updateWizardButtons();
updateStepDots();
}
function wizardSave() {
if (!_guardNav()) return;
if (!collectCurrentStep()) return;
const item = buildItemFromWizard();
if (wz.editIndex >= 0) {
quoteItems[wz.editIndex] = item;
} else {
quoteItems.push(item);
}
writeHiddenFields();
renderAllCards();
scheduleAutoPricing();
closeWizard();
}
// ─── Step renderers ───────────────────────────────────────────────────────────
function renderStep(step) {
wz.step = step;
const body = document.getElementById('wizardBody');
if (step === 1) body.innerHTML = renderStep1Html();
else if (step === 2) body.innerHTML = renderStep2Html();
else if (step === 3) body.innerHTML = renderStep3Html();
else if (step === 4) body.innerHTML = renderStep4Html();
else if (step === 5) {
body.innerHTML = renderStep5Html();
loadCatalogCategoriesForWizard();
if (wz.itemType === 'calculated') prefillCatalogPriceFromCalc();
}
// Re-select the previously chosen type card
if (step === 1 && wz.itemType) {
document.querySelectorAll('.item-type-card').forEach(c => {
c.classList.toggle('selected', c.dataset.type === wz.itemType);
});
}
// Pre-fill step 2 fields from wz.data
if (step === 2) preFillStep2();
// Pre-fill step 3 coats
if (step === 3) renderCoatsList();
}
// Step 1: Type picker
function renderStep1Html() {
const types = [
{
type: 'product',
icon: 'bi-bag-check',
label: 'Product from Catalog',
desc: 'Pick a pre-defined product. Dimensions and default pricing are already set up.'
},
{
type: 'calculated',
icon: 'bi-rulers',
label: 'Custom / Measured Item',
desc: 'Enter exact dimensions and coating time. Price is fully calculated by the engine.'
},
{
type: 'generic',
icon: 'bi-tag',
label: 'Flat-Rate Charge',
desc: 'Setup fees, handling, touch-ups — any charge with a fixed unit price.'
},
{
type: 'labor',
icon: 'bi-person-gear',
label: 'Labor Only',
desc: 'Billable hours at the shop labor rate. No coating required.'
},
{
type: 'ai',
icon: 'bi-robot',
label: 'AI Photo Quote',
desc: 'Upload a photo — our AI will analyze the item and estimate surface area, complexity, and price.'
},
{
type: 'sales',
icon: 'bi-shop',
label: 'Retail / Merchandise',
desc: 'Off-the-shelf items — T-shirts, tumblers, apparel, or any product sold at a fixed price.'
}
].filter(t => t.type !== 'ai' || pageMeta.aiPhotoQuotesEnabled !== false);
return `<div class="row g-3">` +
types.map(t => `
<div class="col-sm-6">
<div class="item-type-card h-100${wz.itemType === t.type ? ' selected' : ''}"
data-type="${t.type}" onclick="selectItemType('${t.type}')">
<div class="item-type-icon"><i class="bi ${t.icon} text-primary"></i></div>
<div class="fw-semibold mb-1">${t.label}</div>
<small class="text-muted">${t.desc}</small>
</div>
</div>`
).join('') +
`</div>
<div id="typeError" class="text-danger mt-2 d-none">Please select an item type.</div>`;
}
function selectItemType(type) {
wz.itemType = type;
document.querySelectorAll('.item-type-card').forEach(c => {
c.classList.toggle('selected', c.dataset.type === type);
});
document.getElementById('typeError')?.classList.add('d-none');
updateStepDots();
// Brief pause so the selected state is visible before advancing
setTimeout(wizardNext, 180);
}
// Step 2: Item-type-specific fields
function renderStep2Html() {
if (wz.itemType === 'product') return renderProductFields();
if (wz.itemType === 'calculated') return renderCalculatedFields();
if (wz.itemType === 'generic') return renderGenericFields();
if (wz.itemType === 'labor') return renderLaborFields();
if (wz.itemType === 'ai') return renderAiPhotoFields();
if (wz.itemType === 'sales') return renderSalesFields();
return '<p class="text-danger">Unknown item type.</p>';
}
function renderProductFields() {
ensureCatalogPreviewEl();
const catalogItems = catalogData.map(c => {
const thumbHtml = c.thumbnailPath
? `<img src="/CatalogItems/Image?id=${c.value}&thumbnail=true" alt=""
style="width:36px;height:36px;object-fit:cover;border-radius:4px;flex-shrink:0;cursor:zoom-in;"
onmouseenter="showCatalogPreview(event,'/CatalogItems/Image?id=${c.value}&thumbnail=true')"
onmousemove="moveCatalogPreview(event)"
onmouseleave="hideCatalogPreview()" />`
: `<span style="width:36px;height:36px;background:#f0f0f0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;"><i class='bi bi-image text-muted' style='font-size:.85rem;'></i></span>`;
// Inner div carries the flex layout — the outer catalog-list-item div must stay a plain block element
// so filterCatalog() can set el.style.display='none' without Bootstrap d-flex !important overriding it.
return `<div class="catalog-list-item px-2 py-2" data-value="${c.value}" onclick="pickCatalogItem(this)"><div style="display:flex;align-items:center;gap:0.5rem;">${thumbHtml}<span>${escHtml(c.text)}</span></div></div>`;
}).join('');
return `
<div class="mb-3">
<label class="form-label fw-semibold">Search / Filter Products</label>
<input type="text" class="form-control mb-2" id="catalogSearch" placeholder="Type to filter…" oninput="filterCatalog()" autocomplete="off">
<label class="form-label fw-semibold">Product <span class="text-danger">*</span></label>
<input type="hidden" id="wz_catalogItemId">
<div class="border rounded" id="catalogListbox" style="max-height:220px;overflow-y:auto;">
${catalogItems || '<div class="px-3 py-2 text-muted small">No catalog items found.</div>'}
</div>
<div class="text-danger d-none mt-1" id="err_catalogItemId">Please select a product.</div>
</div>
<div class="row g-3">
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" value="1">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Price Override <small class="text-muted">($/unit, optional)</small></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_powderCostOverride" min="0" step="0.01" placeholder="Leave blank to use catalog price">
</div>
</div>
</div>`;
}
function filterCatalog() {
const q = document.getElementById('catalogSearch').value.toLowerCase();
const words = q.split(/\s+/).filter(Boolean);
const listbox = document.getElementById('catalogListbox');
if (!listbox) return;
listbox.querySelectorAll('.catalog-list-item').forEach(el => {
const text = el.textContent.toLowerCase();
const hide = words.length > 0 && !words.every(w => text.includes(w));
el.style.display = hide ? 'none' : '';
});
// Reset scroll so filtered-in items are always visible at the top.
listbox.scrollTop = 0;
// Force a layout recalculation — required on iOS Safari so elements that
// transition from display:none back to visible register as interactive.
// eslint-disable-next-line no-unused-expressions
listbox.offsetHeight;
}
function pickCatalogItem(el) {
document.querySelectorAll('#catalogListbox .catalog-list-item').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('wz_catalogItemId').value = el.dataset.value;
document.getElementById('err_catalogItemId')?.classList.add('d-none');
}
// ── Catalog thumbnail hover preview ──────────────────────────────────────────
function ensureCatalogPreviewEl() {
if (document.getElementById('catalogThumbPreview')) return;
const el = document.createElement('div');
el.id = 'catalogThumbPreview';
el.style.cssText = 'position:fixed;display:none;z-index:9999;pointer-events:none;' +
'border:1px solid #dee2e6;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.18);' +
'background:#fff;padding:4px;';
el.innerHTML = '<img id="catalogThumbPreviewImg" style="display:block;width:200px;height:200px;object-fit:contain;border-radius:4px;" />';
document.body.appendChild(el);
}
function showCatalogPreview(event, url) {
const preview = document.getElementById('catalogThumbPreview');
const img = document.getElementById('catalogThumbPreviewImg');
if (!preview || !img) return;
img.src = url;
_placeCatalogPreview(event, preview);
preview.style.display = 'block';
}
function moveCatalogPreview(event) {
const preview = document.getElementById('catalogThumbPreview');
if (preview && preview.style.display !== 'none') _placeCatalogPreview(event, preview);
}
function hideCatalogPreview() {
const preview = document.getElementById('catalogThumbPreview');
if (preview) preview.style.display = 'none';
}
function _placeCatalogPreview(event, preview) {
const pad = 16, pw = 216, ph = 216;
let x = event.clientX + pad;
let y = event.clientY - ph / 2;
if (x + pw > window.innerWidth) x = event.clientX - pw - pad;
if (y < 8) y = 8;
if (y + ph > window.innerHeight) y = window.innerHeight - ph - 8;
preview.style.left = x + 'px';
preview.style.top = y + 'px';
}
function renderCalculatedFields() {
const areaUnit = pageMeta.areaUnit || 'sq ft';
return `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="wz_description" placeholder="e.g., Steel bracket, aluminium frame…" maxlength="200">
<div class="text-danger d-none mt-1" id="err_description">Description is required.</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" value="1">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">${escHtml(areaUnit)} per item <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" class="form-control" id="wz_surfaceAreaSqFt" min="0.01" step="0.01" placeholder="0.00">
<button type="button" class="btn btn-outline-secondary" onclick="wzToggleCalc()" title="Area calculator">
<i class="bi bi-calculator"></i>
</button>
</div>
<div class="text-danger d-none mt-1" id="err_surfaceAreaSqFt">Surface area is required.</div>
<!-- Inline area calculator (avoids modal-on-modal) -->
<div id="wz_calcPanel" class="d-none mt-2 p-3 border rounded bg-light" style="font-size:.875rem;">
<div class="fw-semibold mb-2">Area Calculator</div>
<div class="mb-2">
<select class="form-select form-select-sm" id="wz_calcShape" onchange="wzCalcShape()">
<option value="rectangle">Rectangle / Flat panel</option>
<option value="cylinder">Cylinder</option>
<option value="circle">Circle / Disc</option>
</select>
</div>
<div id="wz_calcRect">
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label form-label-sm mb-1">Length (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcL" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
<div class="col-6">
<label class="form-label form-label-sm mb-1">Width (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcW" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
</div>
</div>
<div id="wz_calcCyl" class="d-none">
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label form-label-sm mb-1">Diameter (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcD" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
<div class="col-6">
<label class="form-label form-label-sm mb-1">Height (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcH" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
</div>
</div>
<div id="wz_calcCirc" class="d-none">
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label form-label-sm mb-1">Diameter (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcCD" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-1">
<span class="text-muted">Result: <strong id="wz_calcResult">0.000</strong> ${escHtml(areaUnit)}</span>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('wz_calcPanel').classList.add('d-none')">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="wzCalcApply()">Apply</button>
</div>
</div>
</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Part Complexity</label>
<select class="form-select" id="wz_complexity">
<option value="Simple">Simple</option>
<option value="Moderate">Moderate</option>
<option value="Complex">Complex</option>
<option value="Extreme">Extreme</option>
</select>
<small class="text-muted">Price multiplier for part intricacy</small>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Coating time <small class="text-muted">(min/item)</small> <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_estimatedMinutes" min="1" value="30">
</div>
</div>`;
}
function renderGenericFields() {
return `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="wz_description" placeholder="e.g., Setup charge, Touch-up fee, Handling…" maxlength="200">
<div class="text-danger d-none mt-1" id="err_description">Description is required.</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" value="1">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Unit Price <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_manualUnitPrice" min="0" step="0.01" placeholder="0.00">
</div>
<div class="text-danger d-none mt-1" id="err_manualUnitPrice">Unit price is required.</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="wz_notes" placeholder="Internal notes about this charge…" maxlength="500">
</div>
</div>`;
}
function renderLaborFields() {
return `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="wz_description" placeholder="e.g., Sandblasting prep, Custom masking, Assembly…" maxlength="200">
<div class="text-danger d-none mt-1" id="err_description">Description is required.</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Billable Hours <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="0.25" step="0.25" value="1">
<small class="text-muted">Minimum 0.25 hr (15 min)</small>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="wz_notes" placeholder="What does this labor cover?" maxlength="500">
</div>
</div>`;
}
function renderSalesFields() {
if (merchandiseData.length === 0) {
return `<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
No merchandise items are set up yet. Go to <strong>Catalog Items</strong> and mark items as merchandise to make them available here.
</div>`;
}
// Resolve display name for the currently selected item (when editing)
const selectedItem = wz.data.salesCatalogItemId
? merchandiseData.find(m => m.id === wz.data.salesCatalogItemId) : null;
return `
<div class="mb-3">
<label class="form-label fw-semibold">Item <span class="text-danger">*</span></label>
<div class="position-relative" id="wzMerchComboWrapper">
<div class="input-group">
<input type="text" id="wzMerchInput" class="form-control"
placeholder="Search merchandise…" autocomplete="off"
value="${escHtml(selectedItem ? selectedItem.name + (selectedItem.sku ? ' [' + selectedItem.sku + ']' : '') : '')}"
oninput="wzMerchComboInput()" onfocus="wzMerchComboOpen()"
onkeydown="wzMerchComboKey(event)" />
<button class="btn btn-outline-secondary" type="button" tabindex="-1" onclick="wzMerchComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="wzMerchDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:#fff;border:1px solid #dee2e6;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
</div>
</div>
<div class="text-danger d-none mt-1" id="err_salesCatalogItemId">Please select an item.</div>
</div>
<div class="row g-3" id="merch_details" style="${selectedItem ? '' : 'display:none'}">
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" step="1" value="${wz.data.quantity || 1}">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Unit Price <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_manualUnitPrice" min="0" step="0.01"
placeholder="0.00"
value="${wz.data.manualUnitPrice != null ? wz.data.manualUnitPrice : ''}">
</div>
<div class="text-danger d-none mt-1" id="err_manualUnitPrice">A valid unit price is required.</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="wz_notes"
placeholder="Size, color variant, any special notes…" maxlength="500"
value="${escHtml(wz.data.notes || '')}">
</div>
</div>`;
}
// ── Merchandise combobox functions (wizard context) ───────────────────────────
function wzMerchComboInput() {
wz.data.salesCatalogItemId = null; // clear selection while typing
const q = document.getElementById('wzMerchInput')?.value.toLowerCase() || '';
wzMerchComboRender(q);
wzMerchComboShow();
}
function wzMerchComboOpen() {
const q = document.getElementById('wzMerchInput')?.value.toLowerCase() || '';
wzMerchComboRender(q);
wzMerchComboShow();
}
function wzMerchComboToggle() {
const dd = document.getElementById('wzMerchDropdown');
if (!dd) return;
if (dd.style.display === 'none') {
document.getElementById('wzMerchInput')?.focus();
wzMerchComboOpen();
} else {
wzMerchComboClose();
}
}
function wzMerchComboRender(query) {
const dd = document.getElementById('wzMerchDropdown');
if (!dd) return;
const filtered = query
? merchandiseData.filter(m =>
m.name.toLowerCase().includes(query) ||
(m.sku && m.sku.toLowerCase().includes(query)) ||
m.category.toLowerCase().includes(query))
: merchandiseData;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match your search</div>';
return;
}
const groups = {};
filtered.forEach(m => {
if (!groups[m.category]) groups[m.category] = [];
groups[m.category].push(m);
});
dd.innerHTML = Object.keys(groups).sort().map(cat =>
`<div class="px-3 pt-2 pb-1" style="font-size:.75rem;font-weight:600;color:#6c757d;text-transform:uppercase;letter-spacing:.05em;">${escHtml(cat)}</div>` +
groups[cat].map(m =>
`<div class="wz-merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.875rem;cursor:pointer;"
data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}"
onmousedown="event.preventDefault();wzMerchComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'"
onmouseleave="this.style.background=''">
${escHtml(m.name)}${m.sku ? ` <span class="text-muted">[${escHtml(m.sku)}]</span>` : ''} <span class="text-muted">— $${parseFloat(m.price).toFixed(2)}</span>
</div>`
).join('')
).join('');
}
function wzMerchComboShow() {
const dd = document.getElementById('wzMerchDropdown');
const anchor = document.getElementById('wzMerchInput');
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
}
function wzMerchComboClose() {
const dd = document.getElementById('wzMerchDropdown');
if (dd) dd.style.display = 'none';
}
function wzMerchComboSelect(el) {
const id = parseInt(el.dataset.id);
const name = el.dataset.name;
const price = parseFloat(el.dataset.price);
const sku = el.dataset.sku;
wz.data.salesCatalogItemId = id;
document.getElementById('wzMerchInput').value = name + (sku ? ` [${sku}]` : '');
wzMerchComboClose();
// Show details panel and auto-fill price
const details = document.getElementById('merch_details');
if (details) details.style.display = '';
const priceInput = document.getElementById('wz_manualUnitPrice');
if (priceInput && (!priceInput.value || parseFloat(priceInput.value) === 0)) {
priceInput.value = price.toFixed(2);
}
document.getElementById('err_salesCatalogItemId')?.classList.add('d-none');
document.getElementById('wz_quantity')?.focus();
}
function wzMerchComboKey(e) {
const dd = document.getElementById('wzMerchDropdown');
if (!dd || dd.style.display === 'none') return;
const opts = Array.from(dd.querySelectorAll('.wz-merch-opt'));
const active = dd.querySelector('.wz-merch-opt.mc-active');
let idx = opts.indexOf(active);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (active) { active.classList.remove('mc-active'); active.style.background = ''; }
idx = (idx + 1) % opts.length;
opts[idx].classList.add('mc-active'); opts[idx].style.background = '#f0f4ff';
opts[idx].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (active) { active.classList.remove('mc-active'); active.style.background = ''; }
idx = (idx - 1 + opts.length) % opts.length;
opts[idx].classList.add('mc-active'); opts[idx].style.background = '#f0f4ff';
opts[idx].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter' && active) {
e.preventDefault();
wzMerchComboSelect(active);
} else if (e.key === 'Escape') {
wzMerchComboClose();
}
}
// Close dropdown when clicking outside the wizard combobox
document.addEventListener('mousedown', e => {
const wrapper = document.getElementById('wzMerchComboWrapper');
const dd = document.getElementById('wzMerchDropdown');
if (wrapper && dd && !wrapper.contains(e.target) && !dd.contains(e.target)) {
wzMerchComboClose();
}
});
function renderAiPhotoFields() {
const existingPhotoHtml = wz.ai.tempIds.map((tid, i) => {
const previewUrl = wz.ai.previewUrls[i] || '';
const thumb = previewUrl
? `<img src="${escHtml(previewUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:4px;flex-shrink:0;">`
: `<i class="bi bi-image text-primary fs-5"></i>`;
return `
<div class="d-flex align-items-center gap-2 p-2 border rounded bg-light mb-1" id="ai_photoRow_${i}">
${thumb}
<span class="flex-grow-1 small text-truncate">${escHtml(wz.ai.fileNames[i] || tid)}</span>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="aiRemovePhoto(${i})"><i class="bi bi-x"></i></button>
</div>`;
}).join('');
const resultHtml = wz.ai.accepted && wz.ai.result ? renderAiResultHtml(wz.ai.result) : '';
return `
<!-- Photo Upload Section -->
<div class="mb-3">
<label class="form-label fw-semibold">Photos <span class="text-danger">*</span>
<small class="text-muted fw-normal">(jpg, png, webp — max 10 MB each)</small>
</label>
<div id="ai_dropzone" class="border border-2 border-dashed rounded p-4 text-center text-muted mb-2"
style="cursor:pointer;border-style:dashed!important;min-height:80px;"
onclick="document.getElementById('ai_fileInput').click()"
ondragover="event.preventDefault();this.classList.add('bg-primary','bg-opacity-10')"
ondragleave="this.classList.remove('bg-primary','bg-opacity-10')"
ondrop="event.preventDefault();this.classList.remove('bg-primary','bg-opacity-10');aiHandleDrop(event)">
<i class="bi bi-cloud-upload fs-3 d-block mb-1"></i>
<span>Click to browse or drag &amp; drop photos here</span>
</div>
<input type="file" id="ai_fileInput" class="d-none" accept="image/*" multiple onchange="aiHandleFileSelect(this)">
<div id="ai_photoList">${existingPhotoHtml}</div>
<div class="text-danger d-none mt-1" id="ai_photoError">Please upload at least one photo.</div>
</div>
<!-- Metadata Inputs -->
<div class="row g-3 mb-3">
<div class="col-12">
<label class="form-label fw-semibold">Item Description &amp; Known Dimension <span class="text-danger">*</span>
<small class="text-muted fw-normal">Describe the item and include at least one known measurement so the AI can estimate surface area</small>
</label>
<input type="text" class="form-control" id="ai_referenceDim"
placeholder='e.g. "18-inch aluminum wheel", "steel bracket approx 24 × 12 inches", "truck bumper about 60 inches wide"'
value="${escHtml(wz.data.aiReferenceDim || '')}">
<div class="text-danger d-none mt-1" id="ai_dimError">Item description with dimension is required.</div>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="ai_quantity" min="1" value="${wz.data.quantity || 1}">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Desired Color / Finish</label>
<input type="text" class="form-control" id="ai_color"
placeholder="e.g., Gloss Black, RAL 7016…"
value="${escHtml(wz.data.aiColor || '')}">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Coating Stages</label>
<input type="number" class="form-control" id="ai_coatCount" min="1" max="5" value="${wz.data.aiCoatCount || 1}">
<small class="text-muted">Number of powder coats to apply</small>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Est. Weight (lbs)</label>
<input type="number" class="form-control" id="ai_weightLbs" min="0" step="0.5"
placeholder="Optional"
value="${wz.data.aiWeightLbs || ''}">
<small class="text-muted">Per piece — helps price heavy items</small>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label fw-semibold">Material Type</label>
<select class="form-select" id="ai_materialType">
<option value="" ${!wz.data.aiMaterialType ? 'selected' : ''}>Unknown / Let AI decide</option>
<option value="Cast Iron" ${'Cast Iron' === wz.data.aiMaterialType ? 'selected' : ''}>Cast Iron</option>
<option value="Cast Aluminum" ${'Cast Aluminum' === wz.data.aiMaterialType ? 'selected' : ''}>Cast Aluminum</option>
<option value="Steel (sheet/tube)" ${'Steel (sheet/tube)' === wz.data.aiMaterialType ? 'selected' : ''}>Steel (sheet / tube)</option>
<option value="Heavy Steel (structural/plate)" ${'Heavy Steel (structural/plate)' === wz.data.aiMaterialType ? 'selected' : ''}>Heavy Steel (structural / plate)</option>
<option value="Aluminum (sheet/extrusion)" ${'Aluminum (sheet/extrusion)' === wz.data.aiMaterialType ? 'selected' : ''}>Aluminum (sheet / extrusion)</option>
<option value="Stainless Steel" ${'Stainless Steel' === wz.data.aiMaterialType ? 'selected' : ''}>Stainless Steel</option>
<option value="Galvanized Steel" ${'Galvanized Steel' === wz.data.aiMaterialType ? 'selected' : ''}>Galvanized Steel</option>
<option value="Wrought Iron" ${'Wrought Iron' === wz.data.aiMaterialType ? 'selected' : ''}>Wrought Iron</option>
<option value="Other" ${'Other' === wz.data.aiMaterialType ? 'selected' : ''}>Other</option>
</select>
<small class="text-muted">Affects prep, outgassing, cure time</small>
</div>
</div>
<!-- Blast Setup Selector (shown only when 2+ setups defined) -->
${blastSetupData.length > 1 ? `
<div class="mb-3">
<label for="ai_blastSetupId" class="form-label fw-medium">
<i class="bi bi-wind me-1"></i>Blast Setup
<small class="text-muted fw-normal ms-1">— which rig will be used?</small>
</label>
<select class="form-select" id="ai_blastSetupId">
${blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')}
</select>
</div>` : ''}
<!-- Analyze Button -->
<div class="d-flex align-items-center gap-2 mb-3">
<button type="button" class="btn btn-primary" onclick="aiAnalyze()" id="ai_analyzeBtn">
<i class="bi bi-robot me-1"></i>Analyze with AI
</button>
<div id="ai_loadingSpinner" class="d-none spinner-border spinner-border-sm text-primary" role="status"></div>
<span id="ai_loadingText" class="d-none text-muted small">Analyzing photos, please wait…</span>
</div>
<!-- Follow-up Question Section -->
<div id="ai_followupSection" class="${wz.ai.phase === 'followup' ? '' : 'd-none'} alert alert-info">
<div class="fw-semibold mb-2"><i class="bi bi-question-circle me-1"></i>AI Follow-up Question:</div>
<p id="ai_followupQuestion" class="mb-2">${escHtml(wz.ai.followUpQuestion || '')}</p>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="ai_followupAnswer" placeholder="Your answer…">
<button type="button" class="btn btn-primary btn-sm text-nowrap" onclick="aiSendFollowup()">Send Answer</button>
</div>
</div>
<!-- AI Error -->
<div id="ai_errorAlert" class="d-none alert alert-danger"></div>
<!-- Results Section -->
<div id="ai_resultsSection" class="${wz.ai.phase === 'result' ? '' : 'd-none'}">
${resultHtml}
</div>
<!-- Accept Error -->
<div class="text-danger d-none mt-2" id="ai_acceptError">Please analyze your photos and accept the AI estimate before continuing.</div>`;
}
// Recompute AI unit price when the user changes sqft, minutes, or complexity.
// Debounced — calls the server so no pricing rates are exposed client-side.
let _recalcDebounce = null;
function aiRecalcPrice() {
clearTimeout(_recalcDebounce);
_recalcDebounce = setTimeout(_aiRecalcPriceAsync, 300);
}
async function _aiRecalcPriceAsync() {
const b = wz.ai.result?.breakdown;
if (!b) return;
const sqft = parseFloat(document.getElementById('ai_sqftOverride')?.value) || b.surfaceAreaSqFt;
const minutes = parseInt(document.getElementById('ai_minutesOverride')?.value) || b.estimatedMinutes;
const complexity = document.getElementById('ai_complexityOverride')?.value || b.complexity;
// Use the actual step-3 coat count so the price preview reflects whatever the user
// added in the coating layers screen, not the coat count fixed at AI analysis time.
const coatCount = (wz.data.coats || []).length || b.coatCount || 1;
const recalcUrl = pageMeta.aiRecalcUrl || '/Quotes/AiRecalcPrice';
const csrf = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
try {
const resp = await fetch(recalcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': csrf },
body: JSON.stringify({ surfaceAreaSqFt: sqft, estimatedMinutes: minutes, complexity, coatCount })
});
const result = await resp.json();
if (!result.success) return;
const unitPrice = result.unitPrice;
const priceOverrideEl = document.getElementById('ai_priceOverride');
if (priceOverrideEl) priceOverrideEl.placeholder = unitPrice.toFixed(2);
const priceDisplayEl = document.getElementById('ai_priceDisplay');
if (priceDisplayEl) {
const fmt = v => '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
priceDisplayEl.textContent = fmt(unitPrice);
}
// Store so Accept uses the recalculated price when no manual override is entered
wz.ai.recalcUnitPrice = unitPrice;
} catch (_) {
// Silent fail — user can still enter a manual price override
}
}
function buildAiPriceBreakdown(result) {
const b = result.breakdown;
if (!b) return '';
const fmt = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `<details class="mb-2">
<summary class="text-muted small fw-semibold" style="cursor:pointer">
<i class="bi bi-calculator me-1"></i>Price Breakdown <span class="text-muted fw-normal">(click to expand)</span>
</summary>
<table class="table table-sm table-borderless mb-0 mt-2" style="font-size:.82rem;">
<tbody>
<tr class="table-light">
<td colspan="2" class="fw-semibold text-muted py-1 px-2">Materials</td>
</tr>
<tr>
<td class="px-3 text-muted">Surface area</td>
<td class="text-end">${b.surfaceAreaSqFt.toFixed(2)} sq ft</td>
</tr>
<tr>
<td class="px-3 text-muted">Powder (${b.powderLbsPerCoat.toFixed(3)} lb/coat × ${b.coatCount} coat${b.coatCount !== 1 ? 's' : ''})</td>
<td class="text-end">${fmt(b.materialCost)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Consumables</td>
<td class="text-end">${fmt(b.consumablesCost)}</td>
</tr>
<tr class="table-light">
<td colspan="2" class="fw-semibold text-muted py-1 px-2">Labor &amp; Equipment</td>
</tr>
<tr>
<td class="px-3 text-muted">Active labor (${b.estimatedMinutes} min)${b.minFloorApplied ? ` <span class="badge bg-warning text-dark ms-1" title="AI estimated less than the ${b.materialMinMinutes}-min minimum for this material — floored">min floor</span>` : ''}</td>
<td class="text-end">${fmt(b.laborCost)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Oven cure (${b.ovenCycleMinutes} min) <span class="text-muted fst-italic small">— shared, priced at quote level</span></td>
<td class="text-end text-muted">—</td>
</tr>
${b.requiresPreheat ? `<tr>
<td class="px-3 text-muted">Outgassing preheat (${b.preheatMinutes} min)</td>
<td class="text-end">${fmt(b.preheatCost)}</td>
</tr>` : ''}
<tr class="border-top">
<td class="px-3 text-muted">Subtotal</td>
<td class="text-end fw-semibold">${fmt(b.subtotalBeforeComplexity)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Complexity ${b.complexity}</td>
<td class="text-end">${fmt(b.complexityCharge)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Markup</td>
<td class="text-end">${fmt(b.markupAmount)}</td>
</tr>
<tr class="border-top fw-bold">
<td class="px-2">Unit Price</td>
<td class="text-end text-success">${fmt(b.unitPrice)}</td>
</tr>
</tbody>
</table>
<p class="text-muted mb-0 mt-1" style="font-size:.75rem;">
<i class="bi bi-info-circle me-1"></i>Labor rate and markup are set in <strong>Company Settings → Operating Costs</strong>.
</p>
</details>`;
}
function renderAiResultHtml(result) {
const isSaved = !result.confidence;
const confidenceClass = result.confidence === 'High' ? 'success' : result.confidence === 'Low' ? 'warning' : 'info';
return `
<div class="card border-${isSaved ? 'info' : 'success'}">
<div class="card-header bg-${isSaved ? 'info' : 'success'} bg-opacity-10 d-flex align-items-center justify-content-between">
<span class="fw-semibold"><i class="bi bi-${isSaved ? 'pencil-square text-info' : 'check-circle-fill text-success'} me-1"></i>${isSaved ? 'Saved Estimate — edit values below' : 'AI Estimate Ready'}</span>
${result.confidence ? `<span class="badge bg-${confidenceClass}">${escHtml(result.confidence)} Confidence</span>` : ''}
</div>
<div class="card-body pb-2">
<div class="row g-3 mb-2">
<div class="col-12">
<label class="form-label fw-semibold mb-1">Description</label>
<input type="text" class="form-control" id="ai_descOverride"
value="${escHtml(result.description || '')}" placeholder="Item description…">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Sq Ft / Item</label>
<input type="number" class="form-control" id="ai_sqftOverride" step="0.01" min="0.01"
value="${result.surfaceAreaSqFt}" oninput="aiRecalcPrice()">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Complexity</label>
<select class="form-select" id="ai_complexityOverride" onchange="aiRecalcPrice()">
<option value="Simple"${result.complexity === 'Simple' ? ' selected' : ''}>Simple</option>
<option value="Moderate"${result.complexity === 'Moderate' ? ' selected' : ''}>Moderate</option>
<option value="Complex"${result.complexity === 'Complex' ? ' selected' : ''}>Complex</option>
<option value="Extreme"${result.complexity === 'Extreme' ? ' selected' : ''}>Extreme</option>
</select>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Est. Minutes</label>
<input type="number" class="form-control" id="ai_minutesOverride" min="1"
value="${result.estimatedMinutes}" oninput="aiRecalcPrice()">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Price Override</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="ai_priceOverride" min="0" step="0.01"
placeholder="${result.estimatedUnitPrice.toFixed(2)}">
</div>
<small class="text-muted">Leave blank to use engine calc</small>
</div>
</div>
<div class="alert alert-light py-2 mb-2">
<div class="d-flex justify-content-between">
<span><i class="bi bi-currency-dollar me-1"></i><strong>AI Estimated Unit Price:</strong></span>
<span id="ai_priceDisplay" class="fw-semibold text-success">$${result.estimatedUnitPrice.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted small"><i class="bi bi-stack me-1"></i>Estimated Total (× qty):</span>
<span class="text-muted small">$${result.estimatedTotal.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}</span>
</div>
<div class="mt-1" style="font-size:.78rem;color:#6c757d;">
<i class="bi bi-arrow-repeat me-1"></i>Price updates automatically when you change sq ft, minutes, or complexity above.
</div>
</div>
${buildAiPriceBreakdown(result)}
<div class="mb-2">
<div class="d-flex flex-wrap align-items-center gap-1" id="ai_tagList"></div>
<div class="input-group input-group-sm mt-1" style="max-width:280px">
<input type="text" class="form-control form-control-sm" id="ai_tagInput"
placeholder="Add tag, press Enter…" maxlength="40"
onkeydown="if(event.key==='Enter'||event.key===','){event.preventDefault();aiAddTag();}">
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="aiAddTag()">
<i class="bi bi-plus"></i>
</button>
</div>
</div>
${result.benchmark ? `
<div class="alert alert-info py-2 mb-2">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-clock-history me-2"></i>
<strong class="small">Historical Benchmark</strong>
<span class="badge bg-info ms-2">${result.benchmark.matchCount} similar job${result.benchmark.matchCount !== 1 ? 's' : ''}</span>
</div>
<div class="d-flex justify-content-between small">
<span class="text-muted">Range:</span>
<span>$${result.benchmark.minPrice.toFixed(2)} $${result.benchmark.maxPrice.toFixed(2)}</span>
</div>
<div class="d-flex justify-content-between small fw-semibold">
<span class="text-muted">Avg price / item:</span>
<span>$${result.benchmark.avgPrice.toFixed(2)}</span>
</div>
</div>` : ''}
${result.aiReasoning ? `
<details class="mb-2">
<summary class="text-muted small" style="cursor:pointer"><i class="bi bi-info-circle me-1"></i>AI Reasoning</summary>
<p class="small text-muted mt-1 mb-0">${escHtml(result.aiReasoning)}</p>
</details>` : ''}
<details class="mb-2">
<summary class="text-muted small fw-semibold" style="cursor:pointer">
<i class="bi bi-question-circle me-1"></i>Price seems off? Here's what to do
</summary>
<div class="mt-2 p-2 bg-light rounded" style="font-size:.82rem;">
<p class="mb-2 fw-semibold">The price is built from: <em>labor + powder + oven + complexity + markup.</em>
The most common reason for a high estimate is the AI over-estimating minutes.</p>
<ol class="mb-2 ps-3">
<li class="mb-1"><strong>Check the breakdown</strong> (above) — expand "Price Breakdown" to see exactly which line is driving the cost.</li>
<li class="mb-1"><strong>Adjust Est. Minutes</strong> — the AI estimates time for the entire job including blasting, masking, cure, and cleanup. If it's too high, lower it and the price will update instantly.</li>
<li class="mb-1"><strong>Adjust Complexity</strong> — dropping from Moderate to Simple can meaningfully reduce the price if the item is straightforward.</li>
<li class="mb-1"><strong>Adjust Sq Ft</strong> — if you know the surface area is wrong, fix it here.</li>
<li class="mb-1"><strong>Override the price directly</strong> — enter your own number in the Price Override field. This always wins over the calculated price.</li>
<li class="mb-1"><strong>Labor rate or markup too high?</strong> Those are set company-wide in <strong>Settings → Operating Costs</strong> and affect all quotes.</li>
</ol>
<p class="mb-0 text-muted">The AI learns from accepted quotes over time — the more quotes you run without overriding, the better it calibrates to your shop's pricing.</p>
</div>
</details>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="aiReAnalyze()">
<i class="bi bi-arrow-repeat me-1"></i>Re-analyze
</button>
</div>
</div>`;
}
// ─── AI Upload / Analysis Functions ──────────────────────────────────────────
function aiHandleFileSelect(input) {
Array.from(input.files).forEach(aiUploadFile);
input.value = ''; // reset so same file can be re-selected
}
function aiHandleDrop(event) {
Array.from(event.dataTransfer.files).forEach(aiUploadFile);
}
// Resize + recompress an image file before upload so phone photos (5-15 MB)
// don't saturate mobile upload bandwidth or slow down Anthropic processing.
// Max 1200px on the long edge, JPEG at 85% quality — ~150-250 KB typical output.
// Non-image files and GIFs are returned unchanged.
async function aiCompressImage(file, maxPx = 1200, quality = 0.85) {
if (!file.type.startsWith('image/') || file.type === 'image/gif') return file;
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, maxPx / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
canvas.toBlob(blob => {
if (!blob || blob.size >= file.size) { resolve(file); return; }
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
}, 'image/jpeg', quality);
};
img.onerror = () => resolve(file);
img.src = e.target.result;
};
reader.onerror = () => resolve(file);
reader.readAsDataURL(file);
});
}
async function aiUploadFile(file) {
// Compress before uploading — full-res phone photos slow upload + Anthropic API
const compressed = await aiCompressImage(file);
// Read compressed bytes for the thumbnail preview (blob: URLs blocked by CSP)
const previewUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = () => resolve('');
reader.readAsDataURL(compressed);
});
const formData = new FormData();
formData.append('file', compressed);
formData.append('__RequestVerificationToken',
document.querySelector('input[name="__RequestVerificationToken"]')?.value || '');
const uploadUrl = (pageMeta.aiUploadUrl || '/Quotes/UploadAiPhoto');
try {
const resp = await fetch(uploadUrl, { method: 'POST', body: formData });
const result = await resp.json();
if (result.success) {
wz.ai.tempIds.push(result.tempId);
wz.ai.fileNames.push(result.fileName);
wz.ai.previewUrls.push(previewUrl);
aiRefreshPhotoList();
document.getElementById('ai_photoError')?.classList.add('d-none');
} else {
aiShowError('Upload failed: ' + (result.error || 'Unknown error'));
}
} catch (err) {
aiShowError('Upload error: ' + err.message);
}
}
function aiRemovePhoto(index) {
wz.ai.tempIds.splice(index, 1);
wz.ai.fileNames.splice(index, 1);
wz.ai.previewUrls.splice(index, 1);
aiRefreshPhotoList();
}
function aiRefreshPhotoList() {
const container = document.getElementById('ai_photoList');
if (!container) return;
container.innerHTML = wz.ai.tempIds.map((tid, i) => {
const previewUrl = wz.ai.previewUrls[i] || '';
const thumb = previewUrl
? `<img src="${escHtml(previewUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:4px;flex-shrink:0;">`
: `<i class="bi bi-image text-primary fs-5"></i>`;
return `<div class="d-flex align-items-center gap-2 p-2 border rounded bg-light mb-1">
${thumb}
<span class="flex-grow-1 small text-truncate">${escHtml(wz.ai.fileNames[i] || tid)}</span>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="aiRemovePhoto(${i})"><i class="bi bi-x"></i></button>
</div>`;
}).join('');
}
async function aiAnalyze() {
// Reset any prior session so each click is a clean analysis
wz.ai.result = null;
wz.ai.conversationHistory = [];
wz.ai.accepted = false;
wz.ai.tags = [];
document.getElementById('ai_resultsSection')?.classList.add('d-none');
document.getElementById('ai_followupSection')?.classList.add('d-none');
// Validate
if (wz.ai.tempIds.length === 0) {
document.getElementById('ai_photoError')?.classList.remove('d-none');
return;
}
const refDim = document.getElementById('ai_referenceDim')?.value?.trim();
if (!refDim) {
document.getElementById('ai_dimError')?.classList.remove('d-none');
return;
}
document.getElementById('ai_dimError')?.classList.add('d-none');
const qty = parseInt(document.getElementById('ai_quantity')?.value) || 1;
const color = document.getElementById('ai_color')?.value?.trim() || '';
const coats = parseInt(document.getElementById('ai_coatCount')?.value) || 1;
const materialType = document.getElementById('ai_materialType')?.value?.trim() || '';
const weightLbsRaw = parseFloat(document.getElementById('ai_weightLbs')?.value);
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
// Persist all input fields before any renderStep() re-renders the form
wz.data.quantity = qty;
wz.data.aiReferenceDim = refDim;
wz.data.aiColor = color;
wz.data.aiCoatCount = coats;
wz.data.aiMaterialType = materialType;
wz.data.aiWeightLbs = weightLbs;
aiSetLoading(true);
document.getElementById('ai_followupSection')?.classList.add('d-none');
document.getElementById('ai_resultsSection')?.classList.add('d-none');
document.getElementById('ai_errorAlert')?.classList.add('d-none');
const blastSetupIdEl = document.getElementById('ai_blastSetupId');
const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null;
const payload = {
photoTempIds: wz.ai.tempIds,
referenceDimension: refDim,
materialType: materialType || null,
estimatedWeightLbs: weightLbs,
quantity: qty,
desiredColor: color,
coatCount: coats,
conversationHistory: wz.ai.conversationHistory,
followUpAnswer: null,
blastSetupId: blastSetupId
};
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
const controller = new AbortController();
// Abort after 120 s — server-side Anthropic timeout is 60 s per attempt with retries;
// 120 s gives room for one retry plus network round-trip on a slow mobile connection.
const hardTimeout = setTimeout(() => controller.abort(), 120_000);
// After 30 s without a response, update the spinner text so the user knows it's working.
const slowWarning = setTimeout(() => {
const t = document.getElementById('ai_loadingText');
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
}, 30_000);
try {
const resp = await fetch(analyzeUrl, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
clearTimeout(hardTimeout);
clearTimeout(slowWarning);
if (!resp.ok) {
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.');
}
throw new Error(`Server error (${resp.status}). Please try again.`);
}
const contentType = resp.headers.get('Content-Type') || '';
if (!contentType.includes('application/json')) {
// Server returned HTML (e.g. login redirect) instead of JSON
throw new Error('Your session may have expired. Please refresh the page and sign in again.');
}
const result = await resp.json();
aiHandleResult(result);
} catch (err) {
clearTimeout(hardTimeout);
clearTimeout(slowWarning);
console.error('AI analyze error:', err);
aiSetLoading(false);
if (err.name === 'AbortError') {
aiShowError('The request timed out — your connection may be slow. Please try again.');
} else {
aiShowError(err.message);
}
}
}
async function aiSendFollowup() {
const answer = document.getElementById('ai_followupAnswer')?.value?.trim();
if (!answer) return;
aiSetLoading(true);
document.getElementById('ai_followupSection')?.classList.add('d-none');
const qty = parseInt(document.getElementById('ai_quantity')?.value) || 1;
const color = document.getElementById('ai_color')?.value?.trim() || '';
const coats = parseInt(document.getElementById('ai_coatCount')?.value) || 1;
const ref = document.getElementById('ai_referenceDim')?.value?.trim() || '';
const materialType = document.getElementById('ai_materialType')?.value?.trim() || '';
const weightLbsRaw = parseFloat(document.getElementById('ai_weightLbs')?.value);
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
wz.data.quantity = qty; // persist before renderStep re-renders
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null;
const payload = {
photoTempIds: wz.ai.tempIds,
referenceDimension: ref,
materialType: materialType || null,
estimatedWeightLbs: weightLbs,
quantity: qty,
desiredColor: color,
coatCount: coats,
conversationHistory: wz.ai.conversationHistory,
followUpAnswer: answer,
blastSetupId: blastSetupId2
};
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
const controller2 = new AbortController();
const hardTimeout2 = setTimeout(() => controller2.abort(), 120_000);
const slowWarning2 = setTimeout(() => {
const t = document.getElementById('ai_loadingText');
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
}, 30_000);
try {
const resp = await fetch(analyzeUrl, {
method: 'POST',
signal: controller2.signal,
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
clearTimeout(hardTimeout2);
clearTimeout(slowWarning2);
if (!resp.ok) {
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.');
}
throw new Error(`Server error (${resp.status}). Please try again.`);
}
const contentType = resp.headers.get('Content-Type') || '';
if (!contentType.includes('application/json')) {
throw new Error('Your session may have expired. Please refresh the page and sign in again.');
}
const result = await resp.json();
aiHandleResult(result);
} catch (err) {
clearTimeout(hardTimeout2);
clearTimeout(slowWarning2);
console.error('AI follow-up error:', err);
aiSetLoading(false);
if (err.name === 'AbortError') {
aiShowError('The request timed out — your connection may be slow. Please try again.');
} else {
aiShowError(err.message);
}
}
}
function aiHandleResult(result) {
aiSetLoading(false);
console.log('AI result:', result);
if (!result.success) {
aiShowError(result.errorMessage || 'AI analysis failed. Please try again.');
return;
}
// Update conversation history for follow-up rounds
wz.ai.conversationHistory = result.conversationHistory || [];
if (result.needsFollowUp) {
// Store follow-up state and re-render step so elements are guaranteed fresh
wz.ai.phase = 'followup';
wz.ai.followUpQuestion = result.followUpQuestion || 'Can you provide more details?';
renderStep(wz.step);
document.getElementById('ai_followupAnswer')?.focus();
requestAnimationFrame(() => {
document.getElementById('ai_followupSection')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
} else {
// Store result state and re-render step so elements are guaranteed fresh
wz.ai.result = result;
wz.ai.tags = [...(result.tags || [])];
wz.ai.accepted = true;
wz.ai.phase = 'result';
renderStep(wz.step);
document.getElementById('ai_acceptError')?.classList.add('d-none');
requestAnimationFrame(() => {
document.getElementById('ai_resultsSection')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
}
function aiReAnalyze() {
wz.ai.accepted = false;
wz.ai.result = null;
wz.ai.recalcUnitPrice = null;
wz.ai.conversationHistory = [];
wz.ai.tags = [];
wz.ai.followUpQuestion = null;
wz.ai.phase = 'upload';
renderStep(wz.step);
}
function aiRenderTags() {
const container = document.getElementById('ai_tagList');
if (!container) return;
container.innerHTML = wz.ai.tags.map(t =>
`<span class="badge bg-secondary d-inline-flex align-items-center gap-1">
${escHtml(t)}
<button type="button" class="btn-close btn-close-white" style="font-size:.5rem"
aria-label="Remove" onclick="aiRemoveTag(${JSON.stringify(t)})"></button>
</span>`
).join('');
}
function aiAddTag() {
const input = document.getElementById('ai_tagInput');
if (!input) return;
const val = input.value.trim().toLowerCase().replace(/,/g, '');
if (!val) return;
if (!wz.ai.tags.includes(val)) {
wz.ai.tags.push(val);
aiRenderTags();
}
input.value = '';
input.focus();
}
function aiRemoveTag(tag) {
wz.ai.tags = wz.ai.tags.filter(t => t !== tag);
aiRenderTags();
}
function aiSetLoading(isLoading) {
const btn = document.getElementById('ai_analyzeBtn');
const spinner = document.getElementById('ai_loadingSpinner');
const text = document.getElementById('ai_loadingText');
if (btn) btn.disabled = isLoading;
spinner?.classList.toggle('d-none', !isLoading);
text?.classList.toggle('d-none', !isLoading);
// Reset text so a retry after the slow-connection warning shows the default message
if (!isLoading && text) text.textContent = 'Analyzing photos, please wait…';
}
function aiShowError(message) {
console.error('AI error shown:', message);
const el = document.getElementById('ai_errorAlert');
if (el) {
el.textContent = message;
el.classList.remove('d-none');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// Step 3: Coating layers
function renderStep3Html() {
const isSandblastOnly = !!wz.data.sandblastOnly;
return `
<div class="d-flex align-items-center border rounded py-2 px-3 mb-3 bg-light gap-2">
<div class="form-check form-switch mb-0 flex-shrink-0">
<input class="form-check-input" type="checkbox" id="sandblastOnlyToggle"
${isSandblastOnly ? 'checked' : ''} onchange="onSandblastOnlyToggle()" style="cursor:pointer">
</div>
<label for="sandblastOnlyToggle" class="mb-0" style="line-height:1.3; cursor:pointer;">
<strong>Sandblast / Prep Only</strong>
<span class="d-block text-muted fw-normal small">No powder coating — no oven or powder costs</span>
</label>
</div>
<div id="coatingSectionWrap"${isSandblastOnly ? ' class="d-none"' : ''}>
<p class="text-muted small mb-3">
<i class="bi bi-info-circle me-1"></i>
Add one or more coating layers. The first coat uses 100% of the labor estimate;
each additional coat adds 30%.
</p>
<div id="coatsListContainer"></div>
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
</button>
</div>
${isSandblastOnly ? `<div class="text-center text-muted py-3">
<i class="bi bi-tools fs-3 d-block mb-2 opacity-50"></i>
No powder coating — no oven or powder costs will be applied.
</div>` : ''}`;
}
function onSandblastOnlyToggle() {
const checked = document.getElementById('sandblastOnlyToggle')?.checked;
wz.data.sandblastOnly = checked;
if (checked) {
wz.data.coats = [];
// Keep manualUnitPrice — if the AI returned a price, preserve it as the sandblast price.
// The user can adjust it; clearing it causes a $0 quote when prep services aren't configured.
}
renderStep(3);
}
function renderCoatsList() {
// Sandblast-only items have no coating layers — don't auto-add a coat
if (wz.data.sandblastOnly) return;
const coats = wz.data.coats || [];
if (coats.length === 0) {
addCoatRow(); // auto-add a Base Coat
return;
}
// Normalise powderType for coats loaded from server (they won't have this client-side field).
// A coat with an inventoryItemId is stock; one with custom fields (colorName, colorCode,
// supplierId, powderCostPerLb, powderToOrder) but no inventoryItemId is custom.
coats.forEach(coat => {
if (!coat.powderType) {
const hasCustomFields = coat.colorName || coat.colorCode || coat.supplierId
|| coat.powderCostPerLb || coat.powderToOrder;
coat.powderType = (coat.inventoryItemId || !hasCustomFields) ? 'stock' : 'custom';
}
});
const container = document.getElementById('coatsListContainer');
if (!container) return;
container.innerHTML = '';
coats.forEach((coat, i) => {
container.insertAdjacentHTML('beforeend', buildCoatRowHtml(i, coat));
restoreCoatRow(i, coat);
});
updateAllPowderNeeded();
}
function addCoatRow() {
const coats = wz.data.coats = wz.data.coats || [];
const i = coats.length;
const defaultName = i === 0 ? 'Base Coat' : '';
const coat = { coatName: defaultName, sequence: i + 1, inventoryItemId: null, powderType: 'stock' };
coats.push(coat);
const container = document.getElementById('coatsListContainer');
if (!container) return;
container.insertAdjacentHTML('beforeend', buildCoatRowHtml(i, coat));
updatePowderNeeded(i);
// AI items: recalculate price preview so multi-coat surcharge is visible before saving
if (wz.itemType === 'ai') aiRecalcPrice();
}
const COAT_NAME_PRESETS = ['Primer', 'Base Coat', 'Mid Coat', 'Top Coat', 'Clear Coat'];
function buildCoatNameHtml(i, currentName) {
const isPreset = !currentName || COAT_NAME_PRESETS.includes(currentName);
const selectVal = isPreset ? (currentName || '') : '__other__';
const textVal = isPreset ? '' : (currentName || '');
const options = COAT_NAME_PRESETS.map(n =>
`<option value="${n}"${selectVal === n ? ' selected' : ''}>${n}</option>`
).join('');
return `
<select class="form-select form-select-sm" style="max-width:160px"
id="coat_name_sel_${i}" onchange="onCoatNameSelect(${i})">
<option value="">-- Select --</option>
${options}
<option value="__other__"${selectVal === '__other__' ? ' selected' : ''}>Other...</option>
</select>
<input type="text" class="form-control form-control-sm" style="max-width:140px;display:${selectVal === '__other__' ? 'block' : 'none'}"
id="coat_name_${i}" placeholder="Custom name" value="${escHtml(textVal)}">`;
}
function onCoatNameSelect(i) {
const sel = document.getElementById(`coat_name_sel_${i}`);
const txt = document.getElementById(`coat_name_${i}`);
if (!sel || !txt) return;
txt.style.display = sel.value === '__other__' ? 'block' : 'none';
if (sel.value !== '__other__') txt.value = '';
}
function buildCoatRowHtml(i, coat) {
const supplierOptions = supplierData.map(s =>
`<option value="${s.value}">${escHtml(s.text)}</option>`
).join('');
return `
<div class="coat-row" id="coatRow_${i}" data-coat-index="${i}">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary">#${i + 1}</span>
${buildCoatNameHtml(i, coat.coatName)}
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCoatRow(${i})">
<i class="bi bi-trash"></i>
</button>
</div>
<!-- Powder type toggle -->
<div class="d-flex gap-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="coat_type_${i}" id="coat_stock_${i}"
value="stock" ${(!coat.powderType || coat.powderType === 'stock') ? 'checked' : ''}
onchange="toggleCoatPowderType(${i})">
<label class="form-check-label" for="coat_stock_${i}">From inventory</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="coat_type_${i}" id="coat_custom_${i}"
value="custom" ${coat.powderType === 'custom' ? 'checked' : ''}
onchange="toggleCoatPowderType(${i})">
<label class="form-check-label" for="coat_custom_${i}">Custom / specify manually</label>
</div>
</div>
<!-- Stock powder -->
<div id="coat_stock_section_${i}" class="row g-2" style="display:${(!coat.powderType || coat.powderType === 'stock') ? 'flex' : 'none'}">
<div class="col-12">
<div class="position-relative" id="coat_powder_wrapper_${i}">
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" id="coat_powder_search_${i}"
placeholder="Search by name, color, or code…" autocomplete="off"
oninput="powderComboInput(${i})"
onfocus="powderComboOpen(${i})"
onkeydown="powderComboKey(event,${i})">
<button class="btn btn-outline-secondary btn-sm" type="button" tabindex="-1"
onclick="powderComboToggle(${i})">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<input type="hidden" id="coat_inventoryItemId_${i}">
<div id="coat_powder_dropdown_${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>
<div class="col-sm-4">
<label class="form-label form-label-sm">Coverage (sq ft/lb)</label>
<input type="number" class="form-control form-control-sm" id="coat_coverage_${i}" min="1" step="0.1" value="${coat.coverageSqFtPerLb || 30}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-4">
<label class="form-label form-label-sm">Transfer Efficiency (%)</label>
<input type="number" class="form-control form-control-sm" id="coat_efficiency_${i}" min="1" max="100" step="0.1" value="${coat.transferEfficiency || 65}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-4">
<label class="form-label form-label-sm">Cost ($/lb)</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<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">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Color Code</label>
<input type="text" class="form-control form-control-sm" id="coat_colorCode_${i}" value="${escHtml(coat.colorCode || '')}" placeholder="RAL-XXXX">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Finish</label>
<input type="text" class="form-control form-control-sm" id="coat_finish_${i}" value="${escHtml(coat.finish || '')}" placeholder="Gloss / Matte…">
</div>
<div class="col-sm-6">
<label class="form-label form-label-sm">Supplier (optional)</label>
<select class="form-select form-select-sm" id="coat_supplierId_${i}">
<option value="">-- None --</option>
${supplierOptions}
</select>
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Coverage (sq ft/lb)</label>
<input type="number" class="form-control form-control-sm" id="coat_custom_coverage_${i}" min="1" step="0.1" value="${coat.coverageSqFtPerLb || 30}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Transfer Eff. (%)</label>
<input type="number" class="form-control form-control-sm" id="coat_custom_efficiency_${i}" min="1" max="100" step="0.1" value="${coat.transferEfficiency || 65}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-4">
<label class="form-label form-label-sm">Cost ($/lb)</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<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>
<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_custom_orderQty_${i}" min="0" step="0.01"
placeholder="Enter lbs to order" value="${coat.powderToOrder != null ? coat.powderToOrder : ''}">
<span class="input-group-text">lbs</span>
</div>
<span class="text-muted small">Suggested from area: <strong id="coat_custom_calcQty_${i}">—</strong></span>
</div>
</div>
</div>
</div>
<!-- Powder needed display (shared across stock/custom) -->
<div class="alert alert-info py-2 mt-2 mb-0 d-flex align-items-center gap-2" id="coat_powderNeededRow_${i}" style="display:${(wz.data.surfaceAreaSqFt > 0) ? 'flex' : 'none'}!important">
<i class="bi bi-box-seam"></i>
<div>
<strong>Powder Needed for This Coat (Total Batch):</strong>
<span id="coat_powderNeededVal_${i}" class="fw-semibold ms-1">—</span>
<div class="small text-muted">This calculation is for the entire batch (all items × surface area)</div>
</div>
</div>
<div class="mt-2 d-flex align-items-center gap-3">
<input type="text" class="form-control form-control-sm" id="coat_notes_${i}"
value="${escHtml(coat.notes || '')}" placeholder="Notes about this coat layer (optional)">
<div class="form-check form-check-inline text-nowrap mb-0 ms-2">
<input class="form-check-input" type="checkbox" id="coat_noExtraCharge_${i}"
${coat.noExtraLayerCharge ? 'checked' : ''}>
<label class="form-check-label text-muted small" for="coat_noExtraCharge_${i}">
No extra layer charge
</label>
</div>
</div>
</div>`;
}
function restoreCoatRow(i, coat) {
// Restore inventory selection if present
if (coat.inventoryItemId) {
const hidden = document.getElementById(`coat_inventoryItemId_${i}`);
if (hidden) hidden.value = coat.inventoryItemId;
// Also fill the search display text
const search = document.getElementById(`coat_powder_search_${i}`);
if (search) {
const powder = powderData.find(p => p.value == coat.inventoryItemId);
if (powder) search.value = powder.text;
}
}
// Restore supplier selection
if (coat.supplierId) {
const sel = document.getElementById(`coat_supplierId_${i}`);
if (sel) sel.value = coat.supplierId;
}
// Restore order qty for custom powder
if (coat.powderType === 'custom' && coat.powderToOrder != null) {
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) {
const coats = wz.data.coats || [];
coats.splice(i, 1);
// Re-render coat list
const container = document.getElementById('coatsListContainer');
if (!container) return;
container.innerHTML = '';
coats.forEach((coat, idx) => {
container.insertAdjacentHTML('beforeend', buildCoatRowHtml(idx, coat));
restoreCoatRow(idx, coat);
});
updateAllPowderNeeded();
if (wz.itemType === 'ai') aiRecalcPrice();
}
function toggleCoatPowderType(i) {
const type = document.querySelector(`input[name="coat_type_${i}"]:checked`)?.value || 'stock';
document.getElementById(`coat_stock_section_${i}`).style.display = type === 'stock' ? 'flex' : 'none';
document.getElementById(`coat_custom_section_${i}`).style.display = type === 'custom' ? 'flex' : 'none';
updatePowderNeeded(i);
}
// ─── Powder combobox ──────────────────────────────────────────────────────────
function powderComboInput(i) {
const q = document.getElementById(`coat_powder_search_${i}`)?.value?.toLowerCase() || '';
powderComboRender(i, q);
powderComboShow(i);
// 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) {
const q = document.getElementById(`coat_powder_search_${i}`)?.value?.toLowerCase() || '';
powderComboRender(i, q);
powderComboShow(i);
}
function powderComboToggle(i) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (!dd) return;
if (dd.style.display === 'none') {
powderComboOpen(i);
document.getElementById(`coat_powder_search_${i}`)?.focus();
} else {
powderComboClose(i);
}
}
function powderComboRender(i, query) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (!dd) return;
const filtered = query
? powderData.filter(p => p.text.toLowerCase().includes(query))
: powderData;
if (filtered.length === 0) {
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 => {
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(displayText)}${badge}
</div>`;
}).join('');
}
function powderComboShow(i) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
const anchor = document.getElementById(`coat_powder_search_${i}`);
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = (rect.width - 20) + 'px';
dd.style.display = 'block';
}
function powderComboClose(i) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (dd) dd.style.display = 'none';
}
function powderComboSelect(i, value, text) {
const hidden = document.getElementById(`coat_inventoryItemId_${i}`);
const search = document.getElementById(`coat_powder_search_${i}`);
if (hidden) hidden.value = value;
if (search) search.value = text;
powderComboClose(i);
onPowderSelected(i);
}
function powderComboKey(event, i) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (!dd || dd.style.display === 'none') {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault();
powderComboOpen(i);
}
return;
}
const items = Array.from(dd.querySelectorAll('.powder-opt'));
let idx = items.findIndex(it => it.classList.contains('pw-active'));
if (event.key === 'ArrowDown') {
event.preventDefault();
idx = Math.min(idx + 1, items.length - 1);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.pw-active') || items[0];
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
} else if (event.key === 'Escape') {
powderComboClose(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;
const powder = powderData.find(p => p.value === sel.value);
if (!powder) return;
const covEl = document.getElementById(`coat_coverage_${i}`);
const effEl = document.getElementById(`coat_efficiency_${i}`);
const costEl = document.getElementById(`coat_costPerLb_${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);
}
function updatePowderNeeded(i) {
const sqft = parseFloat(wz.data.surfaceAreaSqFt) || 0;
const qty = parseInt(wz.data.quantity) || 1;
if (sqft <= 0) return; // only meaningful for calculated items with surface area
const isCustom = document.getElementById(`coat_custom_${i}`)?.checked;
const covId = isCustom ? `coat_custom_coverage_${i}` : `coat_coverage_${i}`;
const effId = isCustom ? `coat_custom_efficiency_${i}` : `coat_efficiency_${i}`;
const cov = parseFloat(document.getElementById(covId)?.value) || 30;
const eff = (parseFloat(document.getElementById(effId)?.value) || 65) / 100;
const lbs = (sqft * qty) / (cov * eff);
const valEl = document.getElementById(`coat_powderNeededVal_${i}`);
if (valEl) valEl.textContent = lbs.toFixed(2) + ' lbs';
// 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() {
const count = wz.data.coats ? wz.data.coats.length : 0;
for (let i = 0; i < count; i++) updatePowderNeeded(i);
}
// ─── Step 4: Prep Services ────────────────────────────────────────────────────
function renderStep4Html() {
if (prepServiceData.length === 0) {
return `<div class="text-center text-muted py-5">
<i class="bi bi-tools fs-1 mb-2 d-block"></i>
<p class="mb-1">No preparation services configured.</p>
<p class="small">Add them in <strong>Company Settings → Prep Services</strong>.</p>
</div>`;
}
const isSandblastOnly = !!wz.data.sandblastOnly;
const isCatalog = wz.itemType === 'product';
const isAi = wz.itemType === 'ai' && !isSandblastOnly;
const includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog
const current = wz.data.prepServices || [];
const catalogBanner = isCatalog ? `
<div class="alert alert-info py-2 mb-3">
<i class="bi bi-info-circle me-1"></i>
<strong>Catalog item:</strong> prep costs are already baked into the catalog price.
Check the services needed for your records, or turn on the toggle below to add a separate prep charge.
<div class="form-check form-switch mt-2 mb-0">
<input class="form-check-input" type="checkbox" id="prep_includeCost"
${includePrepCost ? 'checked' : ''} onchange="onPrepIncludeCostToggle()">
<label class="form-check-label fw-semibold" for="prep_includeCost">Add prep charge to this item's price</label>
</div>
</div>` : '';
const aiBanner = isAi ? `
<div class="alert alert-success py-2 mb-3">
<i class="bi bi-robot me-1"></i>
<strong>AI estimate:</strong> prep costs are already included in the AI price.
Select the services below for shop floor reference — they will <strong>not</strong> add to the item price.
</div>` : '';
const sandblastBanner = isSandblastOnly ? `
<div class="alert alert-warning py-2 mb-3">
<i class="bi bi-tools me-1"></i>
<strong>Sandblast / Prep Only:</strong> estimated minutes will be billed as labor — no powder or oven costs.
</div>` : '';
const blastOptions = blastSetupData.length > 0
? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')
: '';
const rows = prepServiceData.map(p => {
const existing = current.find(c => c.prepServiceId === p.id);
const checked = existing ? 'checked' : '';
const minutes = existing ? existing.estimatedMinutes : '';
const blastSelId = existing?.blastSetupId || null;
const blastSelector = (p.requiresBlastSetup && blastSetupData.length > 0) ? `
<div id="prep_blast_group_${p.id}" class="${checked ? '' : 'd-none'} mt-1">
<label class="form-label form-label-sm text-muted mb-1"><i class="bi bi-wind me-1"></i>Blast Setup</label>
<select class="form-select form-select-sm" id="prep_blast_${p.id}">
${blastSetupData.map(s => `<option value="${s.id}" ${(blastSelId === s.id || (!blastSelId && s.isDefault)) ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')}
</select>
</div>` : '';
return `
<div class="py-2 border-bottom">
<div class="d-flex align-items-center gap-3">
<div class="form-check mb-0 flex-grow-1">
<input class="form-check-input" type="checkbox" id="prep_${p.id}"
value="${p.id}" ${checked} onchange="onPrepToggle(${p.id})">
<label class="form-check-label fw-semibold" for="prep_${p.id}">${escHtml(p.name)}</label>
${p.description ? `<div class="text-muted small">${escHtml(p.description)}</div>` : ''}
</div>
<div id="prep_min_group_${p.id}" class="${checked ? '' : 'd-none'}" style="width:140px;">
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="prep_min_${p.id}"
min="1" value="${minutes}" placeholder="Minutes">
<span class="input-group-text">min</span>
</div>
</div>
</div>
${blastSelector}
</div>`;
}).join('');
const hint = isCatalog
? ''
: `<div class="text-muted small mb-3">Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.</div>`;
return `${catalogBanner}${aiBanner}${sandblastBanner}${hint}${rows}`;
}
function onPrepIncludeCostToggle() {
wz.data.includePrepCost = document.getElementById('prep_includeCost')?.checked ?? false;
}
function onPrepToggle(id) {
const checked = document.getElementById(`prep_${id}`)?.checked;
const group = document.getElementById(`prep_min_group_${id}`);
const blastGroup = document.getElementById(`prep_blast_group_${id}`);
if (group) group.classList.toggle('d-none', !checked);
if (blastGroup) blastGroup.classList.toggle('d-none', !checked);
if (checked) document.getElementById(`prep_min_${id}`)?.focus();
}
function collectStep4() {
// Catalog items: read the toggle (default false); calculated items: always true
if (wz.itemType === 'product') {
wz.data.includePrepCost = document.getElementById('prep_includeCost')?.checked ?? false;
} else {
wz.data.includePrepCost = true;
}
wz.data.prepServices = prepServiceData
.filter(p => document.getElementById(`prep_${p.id}`)?.checked)
.map(p => {
const blastEl = document.getElementById(`prep_blast_${p.id}`);
return {
prepServiceId: p.id,
prepServiceName: p.name,
estimatedMinutes: parseInt(document.getElementById(`prep_min_${p.id}`)?.value) || 0,
blastSetupId: blastEl ? (parseInt(blastEl.value) || null) : null
};
});
}
// ─── Data collection / validation ─────────────────────────────────────────────
function collectCurrentStep() {
if (wz.step === 1) {
if (!wz.itemType) {
document.getElementById('typeError')?.classList.remove('d-none');
return false;
}
return true;
}
if (wz.step === 2) {
return collectStep2();
}
if (wz.step === 3) {
collectStep3();
return true;
}
if (wz.step === 4) {
collectStep4();
return true;
}
if (wz.step === 5) return true; // Step 5 actions handled by saveToCatalogFromWizard / skipCatalogSave
return true;
}
function collectStep2() {
let valid = true;
if (wz.itemType === 'product') {
const sel = document.getElementById('wz_catalogItemId');
const val = sel?.value;
if (!val) {
document.getElementById('err_catalogItemId')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_catalogItemId')?.classList.add('d-none');
wz.data.catalogItemId = parseInt(val);
// Auto-fill description, surface area, and estimated minutes from catalog
const cat = catalogData.find(c => c.value === val);
wz.data.description = cat ? cat.text.split(' > ').pop().split(' - ')[0] : 'Product Item';
wz.data.surfaceAreaSqFt = cat ? (parseFloat(cat.approxArea) || 0) : 0;
wz.data.estimatedMinutes = cat ? (parseInt(cat.defaultMinutes) || 0) : 0;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
const override = parseFloat(document.getElementById('wz_powderCostOverride')?.value);
wz.data.powderCostOverride = isNaN(override) ? null : override;
}
if (wz.itemType === 'calculated') {
const desc = document.getElementById('wz_description')?.value?.trim();
if (!desc) {
document.getElementById('err_description')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_description')?.classList.add('d-none');
wz.data.description = desc;
}
wz.data.complexity = document.getElementById('wz_complexity')?.value || 'Simple';
const sqft = parseFloat(document.getElementById('wz_surfaceAreaSqFt')?.value);
if (!sqft || sqft <= 0) {
document.getElementById('err_surfaceAreaSqFt')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_surfaceAreaSqFt')?.classList.add('d-none');
wz.data.surfaceAreaSqFt = sqft;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
wz.data.estimatedMinutes = parseInt(document.getElementById('wz_estimatedMinutes')?.value) || 30;
}
if (wz.itemType === 'generic') {
const desc = document.getElementById('wz_description')?.value?.trim();
if (!desc) {
document.getElementById('err_description')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_description')?.classList.add('d-none');
wz.data.description = desc;
}
const price = parseFloat(document.getElementById('wz_manualUnitPrice')?.value);
if (isNaN(price) || price < 0) {
document.getElementById('err_manualUnitPrice')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_manualUnitPrice')?.classList.add('d-none');
wz.data.manualUnitPrice = price;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
wz.data.notes = document.getElementById('wz_notes')?.value?.trim() || null;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
wz.data.isGenericItem = true;
}
if (wz.itemType === 'labor') {
const desc = document.getElementById('wz_description')?.value?.trim();
if (!desc) {
document.getElementById('err_description')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_description')?.classList.add('d-none');
wz.data.description = desc;
}
wz.data.quantity = parseFloat(document.getElementById('wz_quantity')?.value) || 1;
wz.data.notes = document.getElementById('wz_notes')?.value?.trim() || null;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
wz.data.isLaborItem = true;
}
if (wz.itemType === 'sales') {
const itemId = wz.data.salesCatalogItemId;
if (!itemId) {
document.getElementById('err_salesCatalogItemId')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_salesCatalogItemId')?.classList.add('d-none');
// Pull description and sku from the merchandise data
const merch = merchandiseData.find(m => m.id === itemId);
wz.data.description = merch ? merch.name : '';
wz.data.sku = merch ? (merch.sku || null) : null;
}
const price = parseFloat(document.getElementById('wz_manualUnitPrice')?.value);
if (isNaN(price) || price < 0) {
document.getElementById('err_manualUnitPrice')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_manualUnitPrice')?.classList.add('d-none');
wz.data.manualUnitPrice = price;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
wz.data.notes = document.getElementById('wz_notes')?.value?.trim() || null;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
wz.data.isSalesItem = true;
}
if (wz.itemType === 'ai') {
if (!wz.ai.accepted || !wz.ai.result) {
document.getElementById('ai_acceptError')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('ai_acceptError')?.classList.add('d-none');
// Collect user overrides (or fall back to AI result)
const desc = document.getElementById('ai_descOverride')?.value?.trim() || wz.ai.result.description;
const sqft = parseFloat(document.getElementById('ai_sqftOverride')?.value) || wz.ai.result.surfaceAreaSqFt;
const complexity = document.getElementById('ai_complexityOverride')?.value || wz.ai.result.complexity;
const minutes = parseInt(document.getElementById('ai_minutesOverride')?.value) || wz.ai.result.estimatedMinutes;
const priceOverrideVal = parseFloat(document.getElementById('ai_priceOverride')?.value);
wz.data.description = desc;
wz.data.surfaceAreaSqFt = sqft;
wz.data.complexity = complexity;
wz.data.estimatedMinutes = minutes;
wz.data.quantity = parseInt(document.getElementById('ai_quantity')?.value) || 1;
wz.data.aiReferenceDim = document.getElementById('ai_referenceDim')?.value?.trim() || '';
wz.data.aiColor = document.getElementById('ai_color')?.value?.trim() || '';
wz.data.aiCoatCount = parseInt(document.getElementById('ai_coatCount')?.value) || 1;
wz.data.aiMaterialType = document.getElementById('ai_materialType')?.value?.trim() || '';
const _wlbs = parseFloat(document.getElementById('ai_weightLbs')?.value);
wz.data.aiWeightLbs = isNaN(_wlbs) || _wlbs <= 0 ? null : _wlbs;
wz.data.powderCostOverride = !isNaN(priceOverrideVal) ? priceOverrideVal : null;
// Store the final price: manual override → recalculated price → original AI estimate
wz.data.manualUnitPrice = !isNaN(priceOverrideVal)
? priceOverrideVal
: (wz.ai.recalcUnitPrice ?? wz.ai.result?.estimatedUnitPrice ?? 0);
wz.data.aiPhotoTempIds = [...wz.ai.tempIds];
wz.data.aiPhotoFileNames = [...wz.ai.fileNames];
wz.data.aiPhotoPreviewUrls = [...wz.ai.previewUrls];
wz.data.isAiItem = true;
wz.data.aiTags = [...wz.ai.tags];
wz.data.aiPredictionId = wz.ai.result?.aiPredictionId ?? null;
}
}
return valid;
}
function collectStep3() {
const rows = document.querySelectorAll('.coat-row');
const coats = [];
rows.forEach((row, i) => {
const isCustom = document.querySelector(`input[name="coat_type_${i}"]:checked`)?.value === 'custom';
const coat = {
coatName: (() => { const s = document.getElementById(`coat_name_sel_${i}`)?.value; return (s && s !== '__other__') ? s : (document.getElementById(`coat_name_${i}`)?.value?.trim() || `Coat ${i + 1}`); })(),
sequence: i + 1,
powderType: isCustom ? 'custom' : 'stock',
notes: document.getElementById(`coat_notes_${i}`)?.value?.trim() || null,
noExtraLayerCharge: document.getElementById(`coat_noExtraCharge_${i}`)?.checked || false,
};
if (!isCustom) {
const invId = document.getElementById(`coat_inventoryItemId_${i}`)?.value;
coat.inventoryItemId = invId ? parseInt(invId) : null;
// 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;
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;
coat.finish = document.getElementById(`coat_finish_${i}`)?.value?.trim() || null;
const suppId = document.getElementById(`coat_supplierId_${i}`)?.value;
coat.supplierId = suppId ? parseInt(suppId) : null;
coat.coverageSqFtPerLb = parseFloat(document.getElementById(`coat_custom_coverage_${i}`)?.value) || 30;
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/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;
if (sqft > 0 && coat.coverageSqFtPerLb > 0) {
const eff = (coat.transferEfficiency || 65) / 100;
coat.powderToOrder = (sqft * qty) / (coat.coverageSqFtPerLb * eff);
}
}
coats.push(coat);
});
wz.data.coats = coats;
}
function preFillStep2() {
const d = wz.data;
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val !== undefined && val !== null) el.value = val;
};
set('wz_quantity', d.quantity);
set('wz_description', d.description);
set('wz_surfaceAreaSqFt', d.surfaceAreaSqFt || '');
set('wz_estimatedMinutes', d.estimatedMinutes || '');
set('wz_manualUnitPrice', d.manualUnitPrice ?? '');
set('wz_powderCostOverride', d.powderCostOverride ?? '');
set('wz_notes', d.notes || '');
if (wz.itemType === 'product' && d.catalogItemId) {
const listItem = document.querySelector(`#catalogListbox [data-value="${d.catalogItemId}"]`);
if (listItem) {
pickCatalogItem(listItem);
listItem.scrollIntoView({ block: 'nearest' });
}
}
if (wz.itemType === 'calculated') {
const cplx = document.getElementById('wz_complexity');
if (cplx && d.complexity) cplx.value = d.complexity;
}
}
// ─── Build item from wizard data ──────────────────────────────────────────────
function buildItemFromWizard() {
const d = wz.data;
const isSandblastOnly = !!d.sandblastOnly;
// AI flag is preserved even for sandblast-only so the server uses the AI price (manualUnitPrice).
// Without this, sandblast-only AI items fall through to the pricing engine and return $0 when
// no prep services with minutes are configured.
const isAi = wz.itemType === 'ai';
return {
description: d.description || null,
quantity: d.quantity || 1,
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
estimatedMinutes: d.estimatedMinutes || 0,
catalogItemId: d.catalogItemId || null,
manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem ? (d.manualUnitPrice ?? null) : null),
powderCostOverride: d.powderCostOverride ?? null,
isGenericItem: !!d.isGenericItem,
isLaborItem: !!d.isLaborItem,
isSalesItem: !!d.isSalesItem,
salesCatalogItemId: d.salesCatalogItemId || null,
sku: d.sku || null,
isAiItem: isAi,
requiresSandblasting: false,
requiresMasking: false,
notes: d.notes || null,
coats: isSandblastOnly ? [] : (d.coats || []),
prepServices: d.prepServices || [],
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
complexity: d.complexity || 'Simple',
// Keep AI photos even for sandblast-only so they get promoted to permanent storage
aiPhotoTempIds: wz.itemType === 'ai' ? (d.aiPhotoTempIds || []) : [],
aiPhotoFileNames: wz.itemType === 'ai' ? (d.aiPhotoFileNames || []) : [],
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
};
}
// ─── Summary cards ────────────────────────────────────────────────────────────
function renderAllCards() {
const container = document.getElementById('itemCardsContainer');
const emptyMsg = document.getElementById('itemsEmptyMessage');
if (!container) return;
if (quoteItems.length === 0) {
container.innerHTML = '';
if (emptyMsg) emptyMsg.style.display = 'block';
return;
}
if (emptyMsg) emptyMsg.style.display = 'none';
container.innerHTML = `
<div class="d-flex gap-2 px-1 mb-1" style="font-size:.7rem; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:#6c757d;">
<div class="flex-grow-1">Description</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">Qty</div>
<div class="text-end flex-shrink-0" style="min-width:80px;">Unit Price</div>
<div class="text-end flex-shrink-0" style="min-width:80px;">Total</div>
<div style="min-width:66px;"></div>
</div>` + quoteItems.map((item, i) => buildCardHtml(item, i)).join('');
}
function buildCardHtml(item, i) {
const typeInfo = item.isLaborItem ? { label: 'Labor', cls: 'info', icon: 'bi-person-gear' }
: item.isGenericItem ? { label: 'Flat-Rate', cls: 'warning', icon: 'bi-tag' }
: item.isSalesItem ? { label: 'Merchandise', cls: 'success', icon: 'bi-shop' }
: item.catalogItemId ? { label: 'Product', cls: 'primary', icon: 'bi-bag-check' }
: item.isAiItem ? { label: 'AI Quoted', cls: 'secondary', icon: 'bi-robot' }
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const coatCount = item.coats?.length || 0;
const isPrepOnly = coatCount === 0 && !item.isGenericItem && !item.isLaborItem && !item.isSalesItem && !item.catalogItemId;
const coatBadge = coatCount > 0
? `<span class="badge bg-secondary ms-1">${coatCount} coat${coatCount > 1 ? 's' : ''}</span>`
: isPrepOnly
? `<span class="badge bg-warning text-dark ms-1" title="No powder coating"><i class="bi bi-tools me-1"></i>Prep Only</span>`
: '';
const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple')
? `<span class="badge bg-secondary bg-opacity-10 text-secondary border ms-1" style="font-size:.65em;">${escHtml(item.complexity)}</span>`
: '';
const priceLine = item.isGenericItem
? `$${fmtNum(item.manualUnitPrice)} × ${item.quantity}`
: item.isLaborItem
? `${item.quantity} hr${item.quantity !== 1 ? 's' : ''}`
: item.isSalesItem
? `$${fmtNum(item.manualUnitPrice)} × ${item.quantity}${item.sku ? ` · <span class="text-muted small">${escHtml(item.sku)}</span>` : ''}`
: item.surfaceAreaSqFt
? `${item.quantity} × ${fmtNum(item.surfaceAreaSqFt)} ${pageMeta.areaUnit || 'sq ft'}`
: `Qty: ${item.quantity}`;
const coatsHtml = (item.coats || []).map(c => {
const color = c.colorName ? escHtml(c.colorName) : '';
const code = c.colorCode ? ` <span class="opacity-75">(${escHtml(c.colorCode)})</span>` : '';
const orderBadge = (!c.inventoryItemId && c.powderToOrder)
? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>`
: '';
return `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? `${color}${code}` : ''}${orderBadge}
</div>`;
}).join('');
const prepHtml = (item.prepServices || []).length > 0
? `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-tools me-1"></i>${(item.prepServices).map(ps =>
`${escHtml(ps.prepServiceName || 'Prep')}${ps.estimatedMinutes ? ` (${ps.estimatedMinutes} min)` : ''}`
).join(' · ')}
</div>`
: '';
const notesHtml = item.notes
? `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-sticky me-1"></i>${escHtml(item.notes)}
</div>`
: '';
const unitPrice = item.isGenericItem
? `$${fmtNum(item.manualUnitPrice)}`
: `<span class="card-unit-price text-muted fst-italic" style="font-size:.8rem;">—</span>`;
const totalPrice = item.isGenericItem
? `$${fmtNum((item.manualUnitPrice || 0) * (item.quantity || 1))}`
: `<span class="card-total-price text-muted fst-italic" style="font-size:.8rem;">—</span>`;
return `
<div class="quote-item-card" data-item-index="${i}">
<div class="d-flex align-items-start gap-2">
<!-- Description -->
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-${typeInfo.cls} item-badge">
<i class="bi ${typeInfo.icon} me-1"></i>${typeInfo.label}
</span>
<span class="fw-semibold">${escHtml(item.description || '(Product from catalog)')}</span>
${coatBadge}${complexityBadge}
</div>
${coatsHtml}
${prepHtml}
${notesHtml}
</div>
<!-- Qty -->
<div class="text-center flex-shrink-0" style="min-width:45px;">
<div class="text-muted" style="font-size:.7rem;">QTY</div>
<div class="fw-semibold">${item.quantity}</div>
</div>
<!-- Unit Price -->
<div class="text-end flex-shrink-0" style="min-width:80px;">
<div class="text-muted" style="font-size:.7rem;">UNIT PRICE</div>
<div class="fw-semibold">${unitPrice}</div>
</div>
<!-- Total -->
<div class="text-end flex-shrink-0" style="min-width:80px;">
<div class="text-muted" style="font-size:.7rem;">TOTAL</div>
<div class="fw-semibold">${totalPrice}</div>
</div>
<!-- Actions -->
<div class="d-flex gap-1 flex-shrink-0 ms-1">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openWizard(${i})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItem(${i})" title="Remove">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>`;
}
function removeItem(i) {
quoteItems.splice(i, 1);
writeHiddenFields();
renderAllCards();
scheduleAutoPricing();
}
// ─── Hidden field writer ──────────────────────────────────────────────────────
function writeHiddenFields() {
const container = document.getElementById('hiddenFieldsContainer');
if (!container) return;
const fields = [];
quoteItems.forEach((item, i) => {
const p = `${pageMeta.itemsFieldPrefix || 'QuoteItems'}[${i}]`;
fields.push(h(p + '.Description', item.description || ''));
fields.push(h(p + '.Quantity', item.quantity));
fields.push(h(p + '.SurfaceAreaSqFt', item.surfaceAreaSqFt || 0));
fields.push(h(p + '.EstimatedMinutes', item.estimatedMinutes || 0));
fields.push(h(p + '.IsGenericItem', item.isGenericItem ? 'true' : 'false'));
fields.push(h(p + '.IsLaborItem', item.isLaborItem ? 'true' : 'false'));
fields.push(h(p + '.IsSalesItem', item.isSalesItem ? 'true' : 'false'));
if (item.sku) fields.push(h(p + '.Sku', item.sku));
if (item.isSalesItem && item.salesCatalogItemId) fields.push(h(p + '.CatalogItemId', item.salesCatalogItemId));
fields.push(h(p + '.RequiresSandblasting', 'false'));
fields.push(h(p + '.RequiresMasking', 'false'));
if (item.catalogItemId) fields.push(h(p + '.CatalogItemId', item.catalogItemId));
if (item.manualUnitPrice != null) fields.push(h(p + '.ManualUnitPrice', item.manualUnitPrice));
if (item.powderCostOverride != null) fields.push(h(p + '.PowderCostOverride', item.powderCostOverride));
if (item.notes) fields.push(h(p + '.Notes', item.notes));
fields.push(h(p + '.IncludePrepCost', item.includePrepCost ? 'true' : 'false'));
fields.push(h(p + '.Complexity', item.complexity || 'Simple'));
if (item.isAiItem) fields.push(h(p + '.IsAiItem', 'true'));
if (item.aiTags) fields.push(h(p + '.AiTags', item.aiTags));
if (item.aiPredictionId != null) fields.push(h(p + '.AiPredictionId', item.aiPredictionId));
(item.prepServices || []).forEach((ps, pi) => {
const pp = `${p}.PrepServices[${pi}]`;
fields.push(h(pp + '.PrepServiceId', ps.prepServiceId));
fields.push(h(pp + '.EstimatedMinutes', ps.estimatedMinutes || 0));
if (ps.blastSetupId != null) fields.push(h(pp + '.BlastSetupId', ps.blastSetupId));
});
(item.coats || []).forEach((coat, ci) => {
const cp = `${p}.Coats[${ci}]`;
fields.push(h(cp + '.CoatName', coat.coatName || `Coat ${ci + 1}`));
fields.push(h(cp + '.Sequence', coat.sequence || (ci + 1)));
fields.push(h(cp + '.CoverageSqFtPerLb', coat.coverageSqFtPerLb || 30));
fields.push(h(cp + '.TransferEfficiency', coat.transferEfficiency || 65));
if (coat.inventoryItemId) fields.push(h(cp + '.InventoryItemId', coat.inventoryItemId));
if (coat.colorName) fields.push(h(cp + '.ColorName', coat.colorName));
if (coat.colorCode) fields.push(h(cp + '.ColorCode', coat.colorCode));
if (coat.finish) fields.push(h(cp + '.Finish', coat.finish));
if (coat.supplierId) fields.push(h(cp + '.VendorId', coat.supplierId));
if (coat.powderCostPerLb != null) fields.push(h(cp + '.PowderCostPerLb', coat.powderCostPerLb));
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'));
});
});
container.innerHTML = fields.join('');
// Write all AI photo tempIds as top-level form fields for photo promotion on save
const aiContainer = document.getElementById('aiPhotoTempIdsContainer');
if (aiContainer) {
const allTempIds = quoteItems.flatMap(item => item.aiPhotoTempIds || []);
aiContainer.innerHTML = allTempIds.map((tid, i) => h(`AiPhotoTempIds[${i}]`, tid)).join('');
}
}
// Build a hidden input element string
function h(name, value) {
return `<input type="hidden" name="${escAttr(name)}" value="${escAttr(String(value))}">`;
}
// ─── Auto-pricing ─────────────────────────────────────────────────────────────
let _autoPricingTimer = null;
function scheduleAutoPricing() {
clearTimeout(_autoPricingTimer);
_autoPricingTimer = setTimeout(runAutoPricing, 600);
}
async function runAutoPricing() {
if (quoteItems.length === 0) {
resetPricingDisplay();
return;
}
const spinner = document.getElementById('pricingSpinner');
if (spinner) spinner.classList.remove('d-none');
try {
// Collect current form meta
const customerId = parseInt(document.querySelector('[name="CustomerId"]')?.value) || null;
const _taxField = document.querySelector('[name="TaxPercent"]');
const taxPercent = _taxField ? parseFloat(_taxField.value) : (pageMeta.taxPercent ?? 0);
const discountType = document.getElementById('discountTypeSelect')?.value || 'None';
const discountVal = parseFloat(document.getElementById('discountValueInput')?.value) || 0;
const isRushJob = document.getElementById('IsRushJob')?.checked || false;
const ovenCostId = parseInt(document.getElementById('OvenCostId')?.value) || null;
const ovenBatches = parseInt(document.getElementById('OvenBatches')?.value) || 1;
const ovenCycleMinutes = parseInt(document.getElementById('OvenCycleMinutes')?.value) || null;
const payload = {
items: quoteItems,
customerId,
taxPercent,
discountType,
discountValue: discountVal,
isRushJob,
ovenCostId,
ovenBatches,
ovenCycleMinutes
};
const url = pageMeta.pricingUrl || '/Quotes/CalculatePricing';
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
if (resp.ok) {
const result = await resp.json();
updatePricingDisplay(result);
updateCardPrices(result.itemResults || []);
}
} catch (err) {
console.warn('Auto-pricing failed:', err);
} finally {
if (spinner) spinner.classList.add('d-none');
}
}
function updatePricingDisplay(r) {
const show = (id, visible) => document.getElementById(id)?.classList.toggle('d-none', !visible);
const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
document.getElementById('pricingPlaceholder')?.classList.add('d-none');
show('itemsSubtotalRow', true);
setText('itemsSubtotalDisplay', '$' + fmtNum(r.itemsSubtotal));
const hasOvenCost = r.ovenBatchCost > 0;
show('ovenBatchCostRow', hasOvenCost);
if (hasOvenCost) {
setText('ovenBatchesDisplay', r.ovenBatches);
setText('ovenCycleMinDisplay', r.ovenCycleMinutes);
setText('ovenBatchCostDisplay', '$' + fmtNum(r.ovenBatchCost));
}
const hasTierDiscount = r.pricingTierDiscountAmount > 0;
show('pricingTierDiscountRow', hasTierDiscount);
if (hasTierDiscount) {
setText('pricingTierDiscountPercentDisplay', fmtNum(r.pricingTierDiscountPercent));
setText('pricingTierDiscountDisplay', '-$' + fmtNum(r.pricingTierDiscountAmount));
}
const hasQuoteDiscount = r.quoteDiscountAmount > 0;
show('quoteDiscountRow', hasQuoteDiscount);
if (hasQuoteDiscount) {
setText('quoteDiscountPercentDisplay', fmtNum(r.quoteDiscountPercent));
setText('quoteDiscountDisplay', '-$' + fmtNum(r.quoteDiscountAmount));
}
show('rushFeeRow', r.rushFee > 0);
setText('rushFeeDisplay', '$' + fmtNum(r.rushFee));
const hasShopSupplies = r.shopSuppliesAmount > 0;
show('shopSuppliesRow', hasShopSupplies);
if (hasShopSupplies) {
setText('shopSuppliesPercentDisplay', fmtNum(r.shopSuppliesPercent));
setText('shopSuppliesDisplay', '$' + fmtNum(r.shopSuppliesAmount));
}
show('subtotalRow', true);
setText('subtotalDisplay', '$' + fmtNum(r.subtotalAfterDiscount || r.subtotalBeforeDiscount));
show('taxRow', r.taxAmount > 0);
setText('taxPercentDisplay', fmtNum(r.taxPercent));
setText('taxDisplay', '$' + fmtNum(r.taxAmount));
document.getElementById('pricingDivider')?.classList.remove('d-none');
show('totalRow', true);
setText('totalDisplay', '$' + fmtNum(r.total));
}
function updateCardPrices(itemResults) {
itemResults.forEach((r, i) => {
const card = document.querySelector(`#itemCardsContainer [data-item-index="${i}"]`);
if (!card) return;
const priceEl = card.querySelector('.card-unit-price');
if (priceEl) priceEl.textContent = '$' + fmtNum(r.unitPrice);
const totalEl = card.querySelector('.card-total-price');
if (totalEl) totalEl.textContent = '$' + fmtNum(r.totalPrice);
});
}
function resetPricingDisplay() {
const hide = id => document.getElementById(id)?.classList.add('d-none');
document.getElementById('pricingPlaceholder')?.classList.remove('d-none');
['itemsSubtotalRow','ovenBatchCostRow','pricingTierDiscountRow','quoteDiscountRow','rushFeeRow',
'shopSuppliesRow','subtotalRow','taxRow','pricingDivider','totalRow'].forEach(hide);
}
// ─── Wizard UI helpers ────────────────────────────────────────────────────────
function updateWizardTitle() {
const titleEl = document.getElementById('wizardTitle');
if (titleEl) titleEl.textContent = wz.editIndex >= 0 ? 'Edit Item' : 'Add Item';
}
function updateWizardButtons() {
const btnBack = document.getElementById('btnWizardBack');
const btnNext = document.getElementById('btnWizardNext');
const btnSave = document.getElementById('btnWizardSave');
if (!btnBack || !btnNext || !btnSave) return;
const isLast = (wz.step === 4 && wz.itemType !== 'calculated' && wz.itemType !== 'ai')
|| (wz.step === 2 && (wz.itemType === 'generic' || wz.itemType === 'labor' || wz.itemType === 'sales'));
const isCatalogStep = wz.step === 5;
btnBack.classList.toggle('d-none', wz.step === 1);
btnNext.classList.toggle('d-none', isLast || isCatalogStep);
btnSave.classList.toggle('d-none', !isLast || isCatalogStep);
if (isLast) btnSave.textContent = wz.editIndex >= 0 ? '✓ Update Item' : '✓ Add Item';
}
function updateStepDots() {
const dots = document.querySelectorAll('.wizard-step-dot');
const step3Dot = document.getElementById('step3Dot');
const step4Dot = document.getElementById('step4Dot');
const step2Line = document.getElementById('step2Line');
const step3Line = document.getElementById('step3Line');
const hasCoats = wz.itemType !== 'generic' && wz.itemType !== 'labor' && wz.itemType !== 'sales';
if (step3Dot) step3Dot.classList.toggle('skip', !hasCoats);
if (step4Dot) step4Dot.classList.toggle('skip', !hasCoats);
if (step2Line) step2Line.style.opacity = hasCoats ? '1' : '0.3';
if (step3Line) step3Line.style.opacity = hasCoats ? '1' : '0.3';
dots.forEach(dot => {
const s = parseInt(dot.dataset.step);
dot.classList.remove('active', 'done');
if (s === wz.step) dot.classList.add('active');
else if (s < wz.step) dot.classList.add('done');
});
const totalSteps = hasCoats ? 4 : 2;
const currentDisplay = Math.min(wz.step, totalSteps);
const label = document.getElementById('wizardStepLabel');
if (label) label.textContent = wz.step === 5 ? 'Final Step' : `Step ${currentDisplay} of ${totalSteps}`;
const stepTitles = {
1: 'Choose Item Type',
2: 'Tell Me About The Item',
3: 'Setup Coating Layers',
4: 'Select Preparation Services Needed',
5: 'Save to Product Catalog?'
};
const titleEl = document.getElementById('wizardStepTitle');
if (titleEl) titleEl.textContent = stepTitles[wz.step] || '';
}
// ─── Inline Area Calculator ───────────────────────────────────────────────────
function wzToggleCalc() {
const panel = document.getElementById('wz_calcPanel');
if (!panel) return;
const opening = panel.classList.toggle('d-none') === false; // true when now visible
if (opening) wzCalcCompute();
}
function wzCalcShape() {
const shape = document.getElementById('wz_calcShape')?.value || 'rectangle';
document.getElementById('wz_calcRect')?.classList.toggle('d-none', shape !== 'rectangle');
document.getElementById('wz_calcCyl')?.classList.toggle('d-none', shape !== 'cylinder');
document.getElementById('wz_calcCirc')?.classList.toggle('d-none', shape !== 'circle');
wzCalcCompute();
}
function wzCalcCompute() {
const shape = document.getElementById('wz_calcShape')?.value || 'rectangle';
const isMetric = (pageMeta.areaUnit || 'sq ft').toLowerCase().includes('m');
let area = 0;
if (shape === 'rectangle') {
const l = parseFloat(document.getElementById('wz_calcL')?.value) || 0;
const w = parseFloat(document.getElementById('wz_calcW')?.value) || 0;
area = l * w;
} else if (shape === 'cylinder') {
const d = parseFloat(document.getElementById('wz_calcD')?.value) || 0;
const h = parseFloat(document.getElementById('wz_calcH')?.value) || 0;
const r = d / 2;
area = 2 * Math.PI * r * r + 2 * Math.PI * r * h;
} else if (shape === 'circle') {
const d = parseFloat(document.getElementById('wz_calcCD')?.value) || 0;
const r = d / 2;
area = Math.PI * r * r;
}
// sq inches → sq ft / sq cm → sq m
const result = isMetric ? area / 10000 : area / 144;
const el = document.getElementById('wz_calcResult');
if (el) el.textContent = result.toFixed(3);
return result;
}
function wzCalcApply() {
const result = wzCalcCompute();
const input = document.getElementById('wz_surfaceAreaSqFt');
if (input) {
input.value = result.toFixed(3);
input.dispatchEvent(new Event('input'));
}
document.getElementById('wz_calcPanel')?.classList.add('d-none');
}
// ─── Utilities ────────────────────────────────────────────────────────────────
// Scroll an element into view, respecting modal-dialog-scrollable containers
function wzScrollIntoView(el) {
const modalBody = el.closest('.modal-body');
if (modalBody) {
// Use setTimeout to let the browser finish laying out the newly-visible element
setTimeout(() => modalBody.scrollTop = modalBody.scrollHeight, 0);
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ─── Save-to-Catalog step (step 5) ───────────────────────────────────────────
function renderStep5Html() {
const isAi = wz.itemType === 'ai';
const aiDesc = wz.ai.result?.description || '';
const prefillName = isAi ? aiDesc : (wz.data.description || '');
const prefillDesc = isAi ? aiDesc : '';
const isCalc = wz.itemType === 'calculated';
const prefillPrice = (wz.data.manualUnitPrice != null && wz.data.manualUnitPrice > 0)
? Number(wz.data.manualUnitPrice).toFixed(2)
: '';
const priceNote = (isAi || isCalc)
? '<div class="text-muted small mb-1">Pre-filled from your item specs — adjust if needed for catalog use.</div>'
: '<div class="text-muted small mb-1">The system will calculate the final price when the item is added — enter a standard fixed price for catalog use.</div>';
// Detect sandblasting / masking from selected prep services
const selectedPrep = wz.data.prepServices || [];
const hasSandblasting = selectedPrep.some(p => {
const ps = prepServiceData.find(s => s.id === p.prepServiceId);
return /sandblast|blast/i.test(ps?.name || '');
});
const hasMasking = selectedPrep.some(p => {
const ps = prepServiceData.find(s => s.id === p.prepServiceId);
return /mask|tap/i.test(ps?.name || '');
});
return `
<div class="text-center mb-4 pt-2">
<div class="mb-2" style="font-size:2.5rem;color:var(--bs-success);">
<i class="bi bi-bookmark-star-fill"></i>
</div>
<h6 class="fw-semibold mb-1">Save to Product Catalog?</h6>
<p class="text-muted small mb-0">
Add this item to your catalog for quick reuse on future quotes and jobs.
</p>
</div>
<div class="card border-0 bg-body-secondary rounded-3 p-3 mb-3">
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">
Catalog Item Name <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="wz_cat_name" maxlength="200"
value="${escAttr(prefillName)}" placeholder="e.g. Steel Motorcycle Frame">
<div class="text-danger small d-none mt-1" id="wz_cat_nameError">Name is required.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">
Category <span class="text-danger">*</span>
</label>
<select class="form-select" id="wz_cat_categoryId">
<option value="">Loading categories…</option>
</select>
<div class="text-danger small d-none mt-1" id="wz_cat_catError">Please select a category.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">Default Price</label>
${priceNote}
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_cat_price"
min="0" step="0.01" value="${escAttr(prefillPrice)}">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">
Description <span class="text-muted fw-normal">(optional)</span>
</label>
<textarea class="form-control" id="wz_cat_description" rows="2"
placeholder="Brief description of this item">${escHtml(prefillDesc)}</textarea>
</div>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="wz_cat_sandblasting"
${hasSandblasting ? 'checked' : ''}>
<label class="form-check-label small" for="wz_cat_sandblasting">
Typically requires sandblasting
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="wz_cat_masking"
${hasMasking ? 'checked' : ''}>
<label class="form-check-label small" for="wz_cat_masking">
Typically requires masking
</label>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-secondary" onclick="skipCatalogSave()">
Skip — Add to Quote Only
</button>
<button type="button" class="btn btn-success" id="wz_cat_saveBtn"
onclick="saveToCatalogFromWizard()">
<i class="bi bi-bookmark-plus me-1"></i>Save to Catalog &amp; Add
</button>
</div>`;
}
async function loadCatalogCategoriesForWizard() {
const select = document.getElementById('wz_cat_categoryId');
if (!select) return;
try {
const resp = await fetch('/ItemWizard/GetCatalogCategories');
const cats = await resp.json();
if (!cats.length) {
select.innerHTML = '<option value="">No categories found — add one in Catalog Settings</option>';
return;
}
select.innerHTML = '<option value="">— Select Category —</option>' +
cats.map(c => `<option value="${c.id}">${escHtml(c.name)}</option>`).join('');
} catch {
select.innerHTML = '<option value="">Failed to load — please try again</option>';
}
}
/// <summary>
/// For calculated items arriving at the Save-to-Catalog step, there is no client-side price yet
/// (pricing happens server-side on form submit). This function calls the same AiRecalcPrice
/// endpoint used by the AI wizard path to fetch an estimated unit price from the item's
/// surface area, minutes, complexity, and coat count, then pre-fills the catalog price field.
/// </summary>
async function prefillCatalogPriceFromCalc() {
const priceInput = document.getElementById('wz_cat_price');
if (!priceInput) return;
const coatCount = (wz.data.coats || []).length || 1;
const recalcUrl = pageMeta.aiRecalcUrl || '/Quotes/AiRecalcPrice';
const csrf = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
priceInput.placeholder = 'Calculating…';
try {
const resp = await fetch(recalcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': csrf },
body: JSON.stringify({
surfaceAreaSqFt: wz.data.surfaceAreaSqFt || 0,
estimatedMinutes: wz.data.estimatedMinutes || 0,
complexity: wz.data.complexity || 'Moderate',
coatCount
})
});
const result = await resp.json();
if (result.success && result.unitPrice > 0) {
priceInput.value = result.unitPrice.toFixed(2);
}
} catch (_) { /* silent — user can type manually */ }
priceInput.placeholder = '0.00';
}
async function saveToCatalogFromWizard() {
const name = document.getElementById('wz_cat_name')?.value?.trim();
const catId = document.getElementById('wz_cat_categoryId')?.value;
let valid = true;
if (!name) {
document.getElementById('wz_cat_nameError')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('wz_cat_nameError')?.classList.add('d-none');
}
if (!catId) {
document.getElementById('wz_cat_catError')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('wz_cat_catError')?.classList.add('d-none');
}
if (!valid) return;
const btn = document.getElementById('wz_cat_saveBtn');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…'; }
const payload = {
name,
categoryId: parseInt(catId),
defaultPrice: parseFloat(document.getElementById('wz_cat_price')?.value) || 0,
description: document.getElementById('wz_cat_description')?.value?.trim() || null,
approximateArea: wz.data.surfaceAreaSqFt || 0,
defaultEstimatedMinutes: wz.data.estimatedMinutes || 0,
defaultRequiresSandblasting: document.getElementById('wz_cat_sandblasting')?.checked ?? false,
defaultRequiresMasking: document.getElementById('wz_cat_masking')?.checked ?? false
};
try {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
const resp = await fetch('/ItemWizard/SaveToCatalog', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': token },
body: JSON.stringify(payload)
});
const result = await resp.json();
if (result.ok) {
// Show brief success state then finalise the item as normal
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Saved!'; }
setTimeout(() => wizardSave(), 800);
} else {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-bookmark-plus me-1"></i>Save to Catalog &amp; Add'; }
alert(result.error || 'Could not save to catalog. Please try again.');
}
} catch {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-bookmark-plus me-1"></i>Save to Catalog &amp; Add'; }
alert('Network error saving to catalog. Please try again.');
}
}
function skipCatalogSave() {
wizardSave();
}
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escAttr(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
}
function fmtNum(n) {
return (parseFloat(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ─── Template loading ─────────────────────────────────────────────────────────
/**
* Pre-populates the item list from a job template.
* Called from the Jobs/Create view when a templateId is passed in the URL.
*/
function loadItemsFromTemplate(templateItems) {
if (!templateItems || !templateItems.length) return;
quoteItems = templateItems.map(ti => ({
description: ti.description || null,
quantity: ti.quantity || 1,
surfaceAreaSqFt: ti.surfaceAreaSqFt || 0,
estimatedMinutes: ti.estimatedMinutes || 0,
catalogItemId: ti.catalogItemId || null,
manualUnitPrice: ti.manualUnitPrice ?? null,
powderCostOverride: null,
isGenericItem: !!ti.isGenericItem,
isLaborItem: !!ti.isLaborItem,
isAiItem: false,
requiresSandblasting: !!ti.requiresSandblasting,
requiresMasking: !!ti.requiresMasking,
notes: null,
complexity: ti.complexity || 'Simple',
includePrepCost: ti.includePrepCost ?? true,
aiPhotoTempIds: [],
aiPhotoFileNames: [],
aiTags: null,
aiPredictionId: null,
coats: (ti.coats || []).map(c => ({
coatName: c.coatName || 'Coat',
sequence: c.sequence || 1,
powderType: c.inventoryItemId ? 'stock' : 'custom',
inventoryItemId: c.inventoryItemId || null,
colorName: c.colorName || null,
colorCode: c.colorCode || null,
finish: c.finish || null,
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
transferEfficiency: c.transferEfficiency || 65,
powderCostPerLb: c.powderCostPerLb || null,
noExtraLayerCharge: false
})),
prepServices: (ti.prepServices || []).map(p => ({
prepServiceId: p.prepServiceId,
prepServiceName: p.prepServiceName || '',
estimatedMinutes: p.estimatedMinutes || 0
}))
}));
writeHiddenFields();
renderAllCards();
scheduleAutoPricing();
}
// ── Surface area calculator modal ─────────────────────────────────────────────
let _sqFtTargetInput = null;
function openSqFtCalculator(inputId) {
_sqFtTargetInput = inputId;
document.getElementById('rectLength').value = 0;
document.getElementById('rectWidth').value = 0;
document.getElementById('calcResult').textContent = '0.00';
new bootstrap.Modal(document.getElementById('sqFtCalculatorModal')).show();
}
function toggleShapeInputs() {
const shape = document.getElementById('calcShape').value;
document.getElementById('rectangleInputs').style.display = shape === 'rectangle' ? 'block' : 'none';
document.getElementById('cylinderInputs').style.display = shape === 'cylinder' ? 'block' : 'none';
document.getElementById('circleInputs').style.display = shape === 'circle' ? 'block' : 'none';
calculateSqFt();
}
function calculateSqFt() {
const useMetric = !!(pageMeta && pageMeta.useMetric);
const divisor = useMetric ? 10000 : 144;
const shape = document.getElementById('calcShape').value;
let result = 0;
if (shape === 'rectangle') {
const l = parseFloat(document.getElementById('rectLength').value) || 0;
const w = parseFloat(document.getElementById('rectWidth').value) || 0;
result = (l * w) / divisor;
} else if (shape === 'cylinder') {
const d = parseFloat(document.getElementById('cylDiameter').value) || 0;
const h = parseFloat(document.getElementById('cylHeight').value) || 0;
const r = d / 2;
result = (2 * Math.PI * r * r + 2 * Math.PI * r * h) / divisor;
} else {
const d = parseFloat(document.getElementById('circDiameter').value) || 0;
const r = d / 2;
result = (Math.PI * r * r) / divisor;
}
document.getElementById('calcResult').textContent = result.toFixed(4);
}
function useSqFtResult() {
const val = document.getElementById('calcResult').textContent;
if (_sqFtTargetInput) {
const el = document.getElementById(_sqFtTargetInput) || document.querySelector(`[name="${_sqFtTargetInput}"]`);
if (el) { el.value = parseFloat(val).toFixed(2); el.dispatchEvent(new Event('change')); }
}
bootstrap.Modal.getInstance(document.getElementById('sqFtCalculatorModal'))?.hide();
}