/**
* 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) {
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 ──────────────────────────────────────────────────────────
// 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();
document.querySelectorAll('#catalogListbox .catalog-list-item').forEach(el => {
el.style.display = (q && !el.textContent.toLowerCase().includes(q)) ? 'none' : '';
});
}
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);
}
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 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';
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 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';
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.