/**
* 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 `
` +
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');
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 '
Unknown item type.
';
}
function renderProductFields() {
ensureCatalogPreviewEl();
const catalogItems = catalogData.map(c => {
const thumbHtml = c.thumbnailPath
? ``
: ``;
// 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 `
${thumbHtml}${escHtml(c.text)}
`;
}).join('');
return `
${catalogItems || '
No catalog items found.
'}
Please select a product.
$
`;
}
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 = '';
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 `
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 `
The price is built from: labor + powder + oven + complexity + markup.
The most common reason for a high estimate is the AI over-estimating minutes.
Check the breakdown (above) — expand "Price Breakdown" to see exactly which line is driving the cost.
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.
Adjust Complexity — dropping from Moderate to Simple can meaningfully reduce the price if the item is straightforward.
Adjust Sq Ft — if you know the surface area is wrong, fix it here.
Override the price directly — enter your own number in the Price Override field. This always wins over the calculated price.
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);
}
// 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
? ``
: ``;
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 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 =>
`
${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);
// 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 `
Add one or more coating layers. The first coat uses 100% of the labor estimate;
each additional coat adds 30%.
${isSandblastOnly ? `
No powder coating — no oven or powder costs will be applied.
` : ''}`;
}
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 =>
``
).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)}
$
Incoming / On Order — powder not yet in stock
Pricing will charge for the full quantity ordered, not just calculated usage.
lbs
Calculated from area: —
or fill in manually below
$
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;
}
// 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 = `
';
});
}
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) ─────────────
///
/// 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,
/// POSTs to the server to create a 0-balance
/// inventory item with IsIncoming=true and then selects it for the current coat.
///
function powderCatalogSearch(i, query) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (!dd) return;
dd.innerHTML = `
';
});
}
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 `
No preparation services configured.
Add them in Company Settings → Prep Services.
`;
}
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 ? `
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.