/** * 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 = []; 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); // Restore items from server round-trip (validation failure re-render) const existingEl = document.getElementById('existingItemsData'); if (existingEl) { quoteItems = JSON.parse(existingEl.textContent); renderAllCards(); writeHiddenFields(); window.scrollTo({ top: 0, behavior: 'instant' }); scheduleAutoPricing(); } // Close any open powder combobox 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)); } }); }); }); // ─── 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 ────────────────────────────────────────────────────────── function wizardNext() { 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 when prep services exist if (prepServiceData.length > 0) { wz.step = 4; } else { wizardSave(); return; } } else if (wz.step === 4) { wizardSave(); return; } renderStep(wz.step); updateWizardButtons(); updateStepDots(); } function wizardBack() { 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 (!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(); // 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 `
` + types.map(t => `
${t.label}
${t.desc}
` ).join('') + `
Please select an item type.
`; } 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'); // Update step 3 dot visibility updateStepDots(); } // 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 '

Unknown item type.

'; } function renderProductFields() { const catalogOptions = catalogData.map(c => `` ).join(''); return `
Please select a product.
$
`; } function filterCatalog() { const q = document.getElementById('catalogSearch').value.toLowerCase(); const sel = document.getElementById('wz_catalogItemId'); Array.from(sel.options).forEach(opt => { opt.hidden = q.length > 0 && !opt.text.toLowerCase().includes(q); }); } function renderCalculatedFields() { const areaUnit = pageMeta.areaUnit || 'sq ft'; return `
Description is required.
Surface area is required.
Area Calculator
Result: 0.000 ${escHtml(areaUnit)}
Price multiplier for part intricacy
`; } function renderGenericFields() { return `
Description is required.
$
Unit price is required.
`; } function renderLaborFields() { return `
Description is required.
Minimum 0.25 hr (15 min)
`; } function renderSalesFields() { if (merchandiseData.length === 0) { return `
No merchandise items are set up yet. Go to Catalog Items and mark items as merchandise to make them available here.
`; } // 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 `
Please select an item.
$
A valid unit price is required.
`; } // ── 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 = '
No items match your search
'; 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 => `
${escHtml(cat)}
` + groups[cat].map(m => `
${escHtml(m.name)}${m.sku ? ` [${escHtml(m.sku)}]` : ''} — $${parseFloat(m.price).toFixed(2)}
` ).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 ? `` : ``; return `
${thumb} ${escHtml(wz.ai.fileNames[i] || tid)}
`; }).join(''); const resultHtml = wz.ai.accepted && wz.ai.result ? renderAiResultHtml(wz.ai.result) : ''; return `
Click to browse or drag & drop photos here
${existingPhotoHtml}
Please upload at least one photo.
Item description with dimension is required.
Number of powder coats to apply
Per piece — helps price heavy items
Affects prep, outgassing, cure time
Analyzing photos, please wait…
AI Follow-up Question:

${escHtml(wz.ai.followUpQuestion || '')}

${resultHtml}
Please analyze your photos and accept the AI estimate before continuing.
`; } // Recompute AI unit price client-side when the user changes sqft, minutes, or complexity. // Uses the same formula as AiQuoteService.CalculatePricingPreview on the server. function aiRecalcPrice() { 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; const coatCount = b.coatCount || 1; const coverage = b.coverageSqFtPerLb || 30; const efficiency = (b.transferEfficiency || 65) / 100; const lbsPerCoat = sqft > 0 ? sqft / (coverage * efficiency) : 0; const material = lbsPerCoat * coatCount * b.powderCostPerLb; const consumables = material * 0.05; const labor = (minutes / 60) * b.laborRatePerHour; const oven = (b.ovenCycleMinutes / 60) * b.ovenRatePerHour; const subtotal = material + consumables + labor + oven; const complexPctMap = { Simple: b.simplePercent || 0, Moderate: b.moderatePercent || 0, Complex: b.complexPercent || 0, Extreme: b.extremePercent || 0 }; const complexPct = (complexPctMap[complexity] || 0) / 100; const complexCharge = subtotal * complexPct; const preMarkup = subtotal + complexCharge; const markupAmt = preMarkup * (b.markupPercent / 100); const unitPrice = Math.max(0, Math.round((preMarkup + markupAmt) * 100) / 100); // Update the price placeholder and the estimated price display 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 if no manual override wz.ai.recalcUnitPrice = unitPrice; } function buildAiPriceBreakdown(result) { const b = result.breakdown; if (!b) return ''; const fmt = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return `
Price Breakdown (click to expand) ${b.requiresPreheat ? `` : ''}
Materials
Surface area ${b.surfaceAreaSqFt.toFixed(2)} sq ft
Powder (${b.powderLbsPerCoat.toFixed(3)} lb/coat × ${b.coatCount} coat${b.coatCount !== 1 ? 's' : ''} × ${fmt(b.powderCostPerLb)}/lb) ${fmt(b.materialCost)}
Consumables (5%) ${fmt(b.consumablesCost)}
Labor & Equipment
Active labor (${b.estimatedMinutes} min × ${fmt(b.laborRatePerHour)}/hr)${b.minFloorApplied ? ` min floor` : ''} ${fmt(b.laborCost)}
Oven cure (${b.ovenCycleMinutes} min × ${fmt(b.ovenRatePerHour)}/hr) — shared, priced at quote level
Outgassing preheat (${b.preheatMinutes} min × ${fmt(b.ovenRatePerHour)}/hr) ${fmt(b.preheatCost)}
Subtotal ${fmt(b.subtotalBeforeComplexity)}
Complexity – ${b.complexity} (+${b.complexityPercent}%) ${fmt(b.complexityCharge)}
Markup (+${b.markupPercent}%) ${fmt(b.markupAmount)}
Unit Price ${fmt(b.unitPrice)}

Labor rate and markup are set in Company Settings → Operating Costs.

`; } function renderAiResultHtml(result) { const isSaved = !result.confidence; const confidenceClass = result.confidence === 'High' ? 'success' : result.confidence === 'Low' ? 'warning' : 'info'; return `
${isSaved ? 'Saved Estimate — edit values below' : 'AI Estimate Ready'} ${result.confidence ? `${escHtml(result.confidence)} Confidence` : ''}
$
Leave blank to use engine calc
AI Estimated Unit Price: $${result.estimatedUnitPrice.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
Estimated Total (× qty): $${result.estimatedTotal.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
Price updates automatically when you change sq ft, minutes, or complexity above.
${buildAiPriceBreakdown(result)}
${result.benchmark ? `
Historical Benchmark ${result.benchmark.matchCount} similar job${result.benchmark.matchCount !== 1 ? 's' : ''}
Range: $${result.benchmark.minPrice.toFixed(2)} – $${result.benchmark.maxPrice.toFixed(2)}
Avg price / item: $${result.benchmark.avgPrice.toFixed(2)}
` : ''} ${result.aiReasoning ? `
AI Reasoning

${escHtml(result.aiReasoning)}

` : ''}
Price seems off? Here's what to do

The price is built from: labor + powder + oven + complexity + markup. The most common reason for a high estimate is the AI over-estimating minutes.

  1. Check the breakdown (above) — expand "Price Breakdown" to see exactly which line is driving the cost.
  2. Adjust Est. Minutes — 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.
  3. Adjust Complexity — dropping from Moderate to Simple can meaningfully reduce the price if the item is straightforward.
  4. Adjust Sq Ft — if you know the surface area is wrong, fix it here.
  5. Override the price directly — enter your own number in the Price Override field. This always wins over the calculated price.
  6. Labor rate or markup too high? Those are set company-wide in Settings → Operating Costs and affect all quotes.

The AI learns from accepted quotes over time — the more quotes you run without overriding, the better it calibrates to your shop's pricing.

`; } // ─── 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); } async function aiUploadFile(file) { // Read as data: URL — blob: URLs are blocked by CSP; data: is explicitly allowed const previewUrl = await new Promise(resolve => { const reader = new FileReader(); reader.onload = e => resolve(e.target.result); reader.onerror = () => resolve(''); reader.readAsDataURL(file); }); const formData = new FormData(); formData.append('file', file); 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 { alert('Upload failed: ' + (result.error || 'Unknown error')); } } catch (err) { alert('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 ? `` : ``; return `
${thumb} ${escHtml(wz.ai.fileNames[i] || tid)}
`; }).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 payload = { photoTempIds: wz.ai.tempIds, referenceDimension: refDim, materialType: materialType || null, estimatedWeightLbs: weightLbs, quantity: qty, desiredColor: color, coatCount: coats, conversationHistory: wz.ai.conversationHistory, followUpAnswer: null }; const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem'; try { const resp = await fetch(analyzeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || '' }, body: JSON.stringify(payload) }); if (!resp.ok) { throw new Error(`Server returned ${resp.status} ${resp.statusText}`); } const result = await resp.json(); aiHandleResult(result); } catch (err) { console.error('AI analyze error:', err); aiSetLoading(false); aiShowError('Error: ' + 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 payload = { photoTempIds: wz.ai.tempIds, referenceDimension: ref, materialType: materialType || null, estimatedWeightLbs: weightLbs, quantity: qty, desiredColor: color, coatCount: coats, conversationHistory: wz.ai.conversationHistory, followUpAnswer: answer }; const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem'; try { const resp = await fetch(analyzeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || '' }, body: JSON.stringify(payload) }); if (!resp.ok) { throw new Error(`Server returned ${resp.status} ${resp.statusText}`); } const result = await resp.json(); aiHandleResult(result); } catch (err) { console.error('AI follow-up error:', err); aiSetLoading(false); aiShowError('Error: ' + 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(); } 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'); } } 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 => ` ${escHtml(t)} ` ).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); } 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' }); } else { // Fallback if element not found alert('AI Error: ' + message); } } // Step 3: Coating layers function renderStep3Html() { return `

Add one or more coating layers. The first coat uses 100% of the labor estimate; each additional coat adds 30%.

`; } function renderCoatsList() { 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); } 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 => `` ).join(''); return ` `; } 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 => `` ).join(''); return `
#${i + 1} ${buildCoatNameHtml(i, coat.coatName)}
$
$
lbs
Suggested from area:
Powder Needed for This Coat (Total Batch):
This calculation is for the entire batch (all items × surface area)
`; } 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; } } 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); }); } 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 when the user edits the text (forces a fresh pick) const hidden = document.getElementById(`coat_inventoryItemId_${i}`); if (hidden) hidden.value = ''; } 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) { dd.innerHTML = '
No powders match your search
'; return; } dd.innerHTML = filtered.map(p => `
${escHtml(p.text)}
` ).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); } } 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); 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 label next to the custom order qty input const calcQtyEl = document.getElementById(`coat_custom_calcQty_${i}`); if (calcQtyEl) calcQtyEl.textContent = lbs.toFixed(2) + ' lbs'; } 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 `

No preparation services configured.

Add them in Company Settings → Prep Services.

`; } const isCatalog = wz.itemType === 'product'; const isAi = wz.itemType === 'ai'; const includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog const current = wz.data.prepServices || []; const catalogBanner = isCatalog ? `
Catalog item: 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.
` : ''; const aiBanner = isAi ? `
AI estimate: prep costs are already included in the AI price. Select the services below for shop floor reference — they will not add to the item price.
` : ''; const rows = prepServiceData.map(p => { const existing = current.find(c => c.prepServiceId === p.id); const checked = existing ? 'checked' : ''; const minutes = existing ? existing.estimatedMinutes : ''; return `
${p.description ? `
${escHtml(p.description)}
` : ''}
min
`; }).join(''); const hint = isCatalog ? '' : `
Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.
`; return `${catalogBanner}${aiBanner}${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}`); if (group) group.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 => ({ prepServiceId: p.id, prepServiceName: p.name, estimatedMinutes: parseInt(document.getElementById(`prep_min_${p.id}`)?.value) || 0 })); } // ─── 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; } 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 from powderData for display purposes if (coat.inventoryItemId) { const powder = powderData.find(p => p.value === String(invId)); if (powder) coat.colorName = powder.colorName || null; } 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; } 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; } // Powder to order: custom coats read from the user-entered field; stock coats auto-calculate if (isCustom) { const orderQtyVal = document.getElementById(`coat_custom_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 sel = document.getElementById('wz_catalogItemId'); if (sel) sel.value = d.catalogItemId; } 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 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: d.manualUnitPrice ?? 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: d.coats || [], prepServices: d.prepServices || [], includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'), complexity: d.complexity || 'Simple', aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [], aiPhotoFileNames: isAi ? (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 = `
Description
Qty
Unit Price
Total
` + 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 coatBadge = (coatCount > 0) ? `${coatCount} coat${coatCount > 1 ? 's' : ''}` : ''; const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple') ? `${escHtml(item.complexity)}` : ''; 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 ? ` · ${escHtml(item.sku)}` : ''}` : 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 ? ` (${escHtml(c.colorCode)})` : ''; const orderBadge = (!c.inventoryItemId && c.powderToOrder) ? ` ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs` : ''; return `
${escHtml(c.coatName || 'Coat')}${color ? ` — ${color}${code}` : ''}${orderBadge}
`; }).join(''); const prepHtml = (item.prepServices || []).length > 0 ? `
${(item.prepServices).map(ps => `${escHtml(ps.prepServiceName || 'Prep')}${ps.estimatedMinutes ? ` (${ps.estimatedMinutes} min)` : ''}` ).join(' · ')}
` : ''; const notesHtml = item.notes ? `
${escHtml(item.notes)}
` : ''; const unitPrice = item.isGenericItem ? `$${fmtNum(item.manualUnitPrice)}` : ``; const totalPrice = item.isGenericItem ? `$${fmtNum((item.manualUnitPrice || 0) * (item.quantity || 1))}` : ``; return `
${typeInfo.label} ${escHtml(item.description || '(Product from catalog)')} ${coatBadge}${complexityBadge}
${coatsHtml} ${prepHtml} ${notesHtml}
QTY
${item.quantity}
UNIT PRICE
${unitPrice}
TOTAL
${totalPrice}
`; } 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)); }); (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')); }); }); 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 ``; } // ─── 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 taxPercent = parseFloat(document.querySelector('[name="TaxPercent"]')?.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 hasPrepServices = prepServiceData.length > 0; const isLast = wz.step === 4 || (wz.step === 3 && !hasPrepServices) || (wz.step === 2 && (wz.itemType === 'generic' || wz.itemType === 'labor' || wz.itemType === 'sales')); btnBack.classList.toggle('d-none', wz.step === 1); btnNext.classList.toggle('d-none', isLast); btnSave.classList.toggle('d-none', !isLast); 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'; // product, calculated, and ai all get coats const hasPrepServices = prepServiceData.length > 0; const showStep4 = hasCoats && hasPrepServices; // product, calculated, and ai get step 4 if (step3Dot) step3Dot.classList.toggle('skip', !hasCoats); if (step4Dot) step4Dot.classList.toggle('skip', !showStep4); if (step2Line) step2Line.style.opacity = hasCoats ? '1' : '0.3'; if (step3Line) step3Line.style.opacity = showStep4 ? '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 ? (hasPrepServices ? 4 : 3) : 2; const currentDisplay = Math.min(wz.step, totalSteps); const label = document.getElementById('wizardStepLabel'); if (label) label.textContent = `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' }; 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' }); } } function escHtml(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function escAttr(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(/"/g,'"'); } 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(); }