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);
}
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.