Files
PowderCoatingLogix/publish-output/wwwroot/js/item-wizard.js
T
2026-04-23 21:38:24 -04:00

2658 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* item-wizard.js
* Generic multi-step item wizard — shared by quotes and jobs.
* Configured via pageMeta (itemsFieldPrefix, pricingUrl, etc.)
*
* Step 1: Choose item type (Product / Custom / Flat-Rate / Labor)
* Step 2: Gather item details (fields vary by type)
* Step 3: Coating layers (Product & Custom only; skipped for Flat-Rate & Labor)
*
* Auto-calculates pricing after each item add/edit/remove.
*/
// ─── State ────────────────────────────────────────────────────────────────────
let quoteItems = []; // Array of item objects matching CreateQuoteItemDto shape
const wz = { // Wizard state
step: 1,
editIndex: -1, // -1 = new item; >= 0 = editing
itemType: null, // 'product' | 'calculated' | 'generic' | 'labor' | 'ai'
data: {}, // Collected field values
ai: { // AI-specific wizard state
phase: 'upload', // 'upload' | 'loading' | 'followup' | 'result'
tempIds: [], // Temp photo IDs from server
fileNames: [], // Display file names
result: null, // Last AiAnalyzeItemResult from server
conversationHistory: [],
accepted: false,
tags: [] // Editable tags (AI-generated + user additions)
}
};
// ─── Page metadata (from embedded JSON) ───────────────────────────────────────
let pageMeta = {};
let powderData = [];
let catalogData = [];
let merchandiseData = [];
let supplierData = [];
let prepServiceData = [];
document.addEventListener('DOMContentLoaded', () => {
const metaEl = document.getElementById('quoteMetaData');
if (metaEl) pageMeta = JSON.parse(metaEl.textContent);
const powderEl = document.getElementById('inventoryPowdersData');
if (powderEl) powderData = JSON.parse(powderEl.textContent);
const catalogEl = document.getElementById('catalogItemsData');
if (catalogEl) catalogData = JSON.parse(catalogEl.textContent);
const merchEl = document.getElementById('merchandiseItemsData');
if (merchEl) merchandiseData = JSON.parse(merchEl.textContent);
const supplierEl = document.getElementById('vendorsData');
if (supplierEl) supplierData = JSON.parse(supplierEl.textContent);
const prepEl = document.getElementById('prepServicesData');
if (prepEl) prepServiceData = JSON.parse(prepEl.textContent);
// Restore items from server round-trip (validation failure re-render)
const existingEl = document.getElementById('existingItemsData');
if (existingEl) {
quoteItems = JSON.parse(existingEl.textContent);
renderAllCards();
writeHiddenFields();
window.scrollTo({ top: 0, behavior: 'instant' });
scheduleAutoPricing();
}
// Close any open powder combobox dropdown when clicking outside it
document.addEventListener('click', e => {
document.querySelectorAll('[id^="coat_powder_wrapper_"]').forEach(wrapper => {
if (!wrapper.contains(e.target)) {
const idx = wrapper.id.replace('coat_powder_wrapper_', '');
powderComboClose(parseInt(idx));
}
});
});
});
// ─── Wizard open / close ──────────────────────────────────────────────────────
function openWizard(editIndex = -1) {
wz.editIndex = editIndex;
wz.step = 1;
wz.data = {};
wz.ai = { phase: 'upload', tempIds: [], fileNames: [], previewUrls: [], result: null, conversationHistory: [], accepted: false, tags: [], followUpQuestion: null };
if (editIndex >= 0) {
const item = quoteItems[editIndex];
wz.itemType = item.isLaborItem ? 'labor'
: item.isSalesItem ? 'sales'
: item.isGenericItem ? 'generic'
: item.catalogItemId ? 'product'
: item.isAiItem ? 'ai'
: 'calculated';
// Pre-fill wizard data from existing item
wz.data = JSON.parse(JSON.stringify(item)); // deep copy
// Restore sales item catalog reference
if (wz.itemType === 'sales') {
wz.data.salesCatalogItemId = item.catalogItemId || null;
}
// Restore AI state if editing an AI item
if (wz.itemType === 'ai') {
wz.ai.tempIds = item.aiPhotoTempIds || [];
wz.ai.fileNames = item.aiPhotoFileNames || [];
wz.ai.previewUrls = item.aiPhotoPreviewUrls || [];
wz.ai.tags = item.aiTags ? item.aiTags.split(',').filter(Boolean) : [];
wz.ai.accepted = true;
wz.ai.phase = 'result';
// Reconstruct a synthetic result from saved values so the item can be saved
// without forcing another round-trip to the AI engine. confidence=null signals
// the card to render as "Saved Estimate" instead of "AI Estimate Ready".
wz.ai.result = {
description: item.description || '',
surfaceAreaSqFt: item.surfaceAreaSqFt || 0,
complexity: item.complexity || 'Moderate',
estimatedMinutes: item.estimatedMinutes || 30,
estimatedUnitPrice: item.manualUnitPrice || 0,
estimatedTotal: (item.manualUnitPrice || 0) * (item.quantity || 1),
confidence: null, // unknown on edit — renders "Saved Estimate" header
aiReasoning: null,
benchmark: null,
breakdown: null, // aiRecalcPrice returns early; user can override price manually
tags: item.aiTags ? item.aiTags.split(',').filter(Boolean) : [],
aiPredictionId: item.aiPredictionId || null
};
}
} else {
wz.itemType = null;
}
updateWizardTitle();
renderStep(1);
updateWizardButtons();
const modal = new bootstrap.Modal(document.getElementById('itemWizardModal'));
modal.show();
}
function closeWizard() {
bootstrap.Modal.getInstance(document.getElementById('itemWizardModal'))?.hide();
}
// ─── Step navigation ──────────────────────────────────────────────────────────
function wizardNext() {
if (!collectCurrentStep()) return; // validation failed
if (wz.step === 1) {
wz.step = 2;
} else if (wz.step === 2) {
// Generic / labor / sales: step 2 is the last step
if (wz.itemType === 'generic' || wz.itemType === 'labor' || wz.itemType === 'sales') {
wizardSave();
return;
}
// AI: step 2 must be accepted before proceeding
if (wz.itemType === 'ai') {
if (!wz.ai.accepted) {
document.getElementById('ai_acceptError')?.classList.remove('d-none');
return; // don't advance
}
}
wz.step = 3;
} else if (wz.step === 3) {
// Step 4 (prep services) for product, calculated, and ai items when prep services exist
if (prepServiceData.length > 0) {
wz.step = 4;
} else {
wizardSave();
return;
}
} else if (wz.step === 4) {
wizardSave();
return;
}
renderStep(wz.step);
updateWizardButtons();
updateStepDots();
}
function wizardBack() {
if (wz.step === 4) wz.step = 3;
else if (wz.step === 3) wz.step = 2;
else if (wz.step === 2) wz.step = 1;
renderStep(wz.step);
updateWizardButtons();
updateStepDots();
}
function wizardSave() {
if (!collectCurrentStep()) return;
const item = buildItemFromWizard();
if (wz.editIndex >= 0) {
quoteItems[wz.editIndex] = item;
} else {
quoteItems.push(item);
}
writeHiddenFields();
renderAllCards();
scheduleAutoPricing();
closeWizard();
}
// ─── Step renderers ───────────────────────────────────────────────────────────
function renderStep(step) {
wz.step = step;
const body = document.getElementById('wizardBody');
if (step === 1) body.innerHTML = renderStep1Html();
else if (step === 2) body.innerHTML = renderStep2Html();
else if (step === 3) body.innerHTML = renderStep3Html();
else if (step === 4) body.innerHTML = renderStep4Html();
// Re-select the previously chosen type card
if (step === 1 && wz.itemType) {
document.querySelectorAll('.item-type-card').forEach(c => {
c.classList.toggle('selected', c.dataset.type === wz.itemType);
});
}
// Pre-fill step 2 fields from wz.data
if (step === 2) preFillStep2();
// Pre-fill step 3 coats
if (step === 3) renderCoatsList();
}
// Step 1: Type picker
function renderStep1Html() {
const types = [
{
type: 'product',
icon: 'bi-bag-check',
label: 'Product from Catalog',
desc: 'Pick a pre-defined product. Dimensions and default pricing are already set up.'
},
{
type: 'calculated',
icon: 'bi-rulers',
label: 'Custom / Measured Item',
desc: 'Enter exact dimensions and coating time. Price is fully calculated by the engine.'
},
{
type: 'generic',
icon: 'bi-tag',
label: 'Flat-Rate Charge',
desc: 'Setup fees, handling, touch-ups — any charge with a fixed unit price.'
},
{
type: 'labor',
icon: 'bi-person-gear',
label: 'Labor Only',
desc: 'Billable hours at the shop labor rate. No coating required.'
},
{
type: 'ai',
icon: 'bi-robot',
label: 'AI Photo Quote',
desc: 'Upload a photo — our AI will analyze the item and estimate surface area, complexity, and price.'
},
{
type: 'sales',
icon: 'bi-shop',
label: 'Retail / Merchandise',
desc: 'Off-the-shelf items — T-shirts, tumblers, apparel, or any product sold at a fixed price.'
}
].filter(t => t.type !== 'ai' || pageMeta.aiPhotoQuotesEnabled !== false);
return `<div class="row g-3">` +
types.map(t => `
<div class="col-sm-6">
<div class="item-type-card h-100${wz.itemType === t.type ? ' selected' : ''}"
data-type="${t.type}" onclick="selectItemType('${t.type}')">
<div class="item-type-icon"><i class="bi ${t.icon} text-primary"></i></div>
<div class="fw-semibold mb-1">${t.label}</div>
<small class="text-muted">${t.desc}</small>
</div>
</div>`
).join('') +
`</div>
<div id="typeError" class="text-danger mt-2 d-none">Please select an item type.</div>`;
}
function selectItemType(type) {
wz.itemType = type;
document.querySelectorAll('.item-type-card').forEach(c => {
c.classList.toggle('selected', c.dataset.type === type);
});
document.getElementById('typeError')?.classList.add('d-none');
// Update step 3 dot visibility
updateStepDots();
}
// Step 2: Item-type-specific fields
function renderStep2Html() {
if (wz.itemType === 'product') return renderProductFields();
if (wz.itemType === 'calculated') return renderCalculatedFields();
if (wz.itemType === 'generic') return renderGenericFields();
if (wz.itemType === 'labor') return renderLaborFields();
if (wz.itemType === 'ai') return renderAiPhotoFields();
if (wz.itemType === 'sales') return renderSalesFields();
return '<p class="text-danger">Unknown item type.</p>';
}
function renderProductFields() {
const catalogOptions = catalogData.map(c =>
`<option value="${c.value}">${escHtml(c.text)}</option>`
).join('');
return `
<div class="mb-3">
<label class="form-label fw-semibold">Search / Filter Products</label>
<input type="text" class="form-control mb-2" id="catalogSearch" placeholder="Type to filter…" oninput="filterCatalog()">
<label class="form-label fw-semibold">Product <span class="text-danger">*</span></label>
<select class="form-select" id="wz_catalogItemId" size="6" style="height:auto">
<option value="">-- Select a product --</option>
${catalogOptions}
</select>
<div class="text-danger d-none mt-1" id="err_catalogItemId">Please select a product.</div>
</div>
<div class="row g-3">
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" value="1">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Price Override <small class="text-muted">($/unit, optional)</small></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_powderCostOverride" min="0" step="0.01" placeholder="Leave blank to use catalog price">
</div>
</div>
</div>`;
}
function filterCatalog() {
const q = document.getElementById('catalogSearch').value.toLowerCase();
const sel = document.getElementById('wz_catalogItemId');
Array.from(sel.options).forEach(opt => {
opt.hidden = q.length > 0 && !opt.text.toLowerCase().includes(q);
});
}
function renderCalculatedFields() {
const areaUnit = pageMeta.areaUnit || 'sq ft';
return `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="wz_description" placeholder="e.g., Steel bracket, aluminium frame…" maxlength="200">
<div class="text-danger d-none mt-1" id="err_description">Description is required.</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" value="1">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">${escHtml(areaUnit)} per item <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" class="form-control" id="wz_surfaceAreaSqFt" min="0.01" step="0.01" placeholder="0.00">
<button type="button" class="btn btn-outline-secondary" onclick="wzToggleCalc()" title="Area calculator">
<i class="bi bi-calculator"></i>
</button>
</div>
<div class="text-danger d-none mt-1" id="err_surfaceAreaSqFt">Surface area is required.</div>
<!-- Inline area calculator (avoids modal-on-modal) -->
<div id="wz_calcPanel" class="d-none mt-2 p-3 border rounded bg-light" style="font-size:.875rem;">
<div class="fw-semibold mb-2">Area Calculator</div>
<div class="mb-2">
<select class="form-select form-select-sm" id="wz_calcShape" onchange="wzCalcShape()">
<option value="rectangle">Rectangle / Flat panel</option>
<option value="cylinder">Cylinder</option>
<option value="circle">Circle / Disc</option>
</select>
</div>
<div id="wz_calcRect">
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label form-label-sm mb-1">Length (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcL" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
<div class="col-6">
<label class="form-label form-label-sm mb-1">Width (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcW" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
</div>
</div>
<div id="wz_calcCyl" class="d-none">
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label form-label-sm mb-1">Diameter (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcD" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
<div class="col-6">
<label class="form-label form-label-sm mb-1">Height (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcH" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
</div>
</div>
<div id="wz_calcCirc" class="d-none">
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label form-label-sm mb-1">Diameter (in)</label>
<input type="number" class="form-control form-control-sm" id="wz_calcCD" min="0" step="0.1" value="0" oninput="wzCalcCompute()">
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-1">
<span class="text-muted">Result: <strong id="wz_calcResult">0.000</strong> ${escHtml(areaUnit)}</span>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('wz_calcPanel').classList.add('d-none')">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="wzCalcApply()">Apply</button>
</div>
</div>
</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Part Complexity</label>
<select class="form-select" id="wz_complexity">
<option value="Simple">Simple (+0%)</option>
<option value="Moderate">Moderate (+${pageMeta.complexityModeratePercent ?? 5}%)</option>
<option value="Complex">Complex (+${pageMeta.complexityComplexPercent ?? 15}%)</option>
<option value="Extreme">Extreme (+${pageMeta.complexityExtremePercent ?? 25}%)</option>
</select>
<small class="text-muted">Price multiplier for part intricacy</small>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Coating time <small class="text-muted">(min/item)</small> <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_estimatedMinutes" min="1" value="30">
</div>
</div>`;
}
function renderGenericFields() {
return `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="wz_description" placeholder="e.g., Setup charge, Touch-up fee, Handling…" maxlength="200">
<div class="text-danger d-none mt-1" id="err_description">Description is required.</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" value="1">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Unit Price <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_manualUnitPrice" min="0" step="0.01" placeholder="0.00">
</div>
<div class="text-danger d-none mt-1" id="err_manualUnitPrice">Unit price is required.</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="wz_notes" placeholder="Internal notes about this charge…" maxlength="500">
</div>
</div>`;
}
function renderLaborFields() {
return `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="wz_description" placeholder="e.g., Sandblasting prep, Custom masking, Assembly…" maxlength="200">
<div class="text-danger d-none mt-1" id="err_description">Description is required.</div>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Billable Hours <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="0.25" step="0.25" value="1">
<small class="text-muted">Minimum 0.25 hr (15 min)</small>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="wz_notes" placeholder="What does this labor cover?" maxlength="500">
</div>
</div>`;
}
function renderSalesFields() {
if (merchandiseData.length === 0) {
return `<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
No merchandise items are set up yet. Go to <strong>Catalog Items</strong> and mark items as merchandise to make them available here.
</div>`;
}
// Resolve display name for the currently selected item (when editing)
const selectedItem = wz.data.salesCatalogItemId
? merchandiseData.find(m => m.id === wz.data.salesCatalogItemId) : null;
return `
<div class="mb-3">
<label class="form-label fw-semibold">Item <span class="text-danger">*</span></label>
<div class="position-relative" id="wzMerchComboWrapper">
<div class="input-group">
<input type="text" id="wzMerchInput" class="form-control"
placeholder="Search merchandise…" autocomplete="off"
value="${escHtml(selectedItem ? selectedItem.name + (selectedItem.sku ? ' [' + selectedItem.sku + ']' : '') : '')}"
oninput="wzMerchComboInput()" onfocus="wzMerchComboOpen()"
onkeydown="wzMerchComboKey(event)" />
<button class="btn btn-outline-secondary" type="button" tabindex="-1" onclick="wzMerchComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="wzMerchDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:#fff;border:1px solid #dee2e6;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
</div>
</div>
<div class="text-danger d-none mt-1" id="err_salesCatalogItemId">Please select an item.</div>
</div>
<div class="row g-3" id="merch_details" style="${selectedItem ? '' : 'display:none'}">
<div class="col-sm-4">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="wz_quantity" min="1" step="1" value="${wz.data.quantity || 1}">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold">Unit Price <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_manualUnitPrice" min="0" step="0.01"
placeholder="0.00"
value="${wz.data.manualUnitPrice != null ? wz.data.manualUnitPrice : ''}">
</div>
<div class="text-danger d-none mt-1" id="err_manualUnitPrice">A valid unit price is required.</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="wz_notes"
placeholder="Size, color variant, any special notes…" maxlength="500"
value="${escHtml(wz.data.notes || '')}">
</div>
</div>`;
}
// ── Merchandise combobox functions (wizard context) ───────────────────────────
function wzMerchComboInput() {
wz.data.salesCatalogItemId = null; // clear selection while typing
const q = document.getElementById('wzMerchInput')?.value.toLowerCase() || '';
wzMerchComboRender(q);
wzMerchComboShow();
}
function wzMerchComboOpen() {
const q = document.getElementById('wzMerchInput')?.value.toLowerCase() || '';
wzMerchComboRender(q);
wzMerchComboShow();
}
function wzMerchComboToggle() {
const dd = document.getElementById('wzMerchDropdown');
if (!dd) return;
if (dd.style.display === 'none') {
document.getElementById('wzMerchInput')?.focus();
wzMerchComboOpen();
} else {
wzMerchComboClose();
}
}
function wzMerchComboRender(query) {
const dd = document.getElementById('wzMerchDropdown');
if (!dd) return;
const filtered = query
? merchandiseData.filter(m =>
m.name.toLowerCase().includes(query) ||
(m.sku && m.sku.toLowerCase().includes(query)) ||
m.category.toLowerCase().includes(query))
: merchandiseData;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match your search</div>';
return;
}
const groups = {};
filtered.forEach(m => {
if (!groups[m.category]) groups[m.category] = [];
groups[m.category].push(m);
});
dd.innerHTML = Object.keys(groups).sort().map(cat =>
`<div class="px-3 pt-2 pb-1" style="font-size:.75rem;font-weight:600;color:#6c757d;text-transform:uppercase;letter-spacing:.05em;">${escHtml(cat)}</div>` +
groups[cat].map(m =>
`<div class="wz-merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.875rem;cursor:pointer;"
data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}"
onmousedown="event.preventDefault();wzMerchComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'"
onmouseleave="this.style.background=''">
${escHtml(m.name)}${m.sku ? ` <span class="text-muted">[${escHtml(m.sku)}]</span>` : ''} <span class="text-muted">— $${parseFloat(m.price).toFixed(2)}</span>
</div>`
).join('')
).join('');
}
function wzMerchComboShow() {
const dd = document.getElementById('wzMerchDropdown');
const anchor = document.getElementById('wzMerchInput');
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
}
function wzMerchComboClose() {
const dd = document.getElementById('wzMerchDropdown');
if (dd) dd.style.display = 'none';
}
function wzMerchComboSelect(el) {
const id = parseInt(el.dataset.id);
const name = el.dataset.name;
const price = parseFloat(el.dataset.price);
const sku = el.dataset.sku;
wz.data.salesCatalogItemId = id;
document.getElementById('wzMerchInput').value = name + (sku ? ` [${sku}]` : '');
wzMerchComboClose();
// Show details panel and auto-fill price
const details = document.getElementById('merch_details');
if (details) details.style.display = '';
const priceInput = document.getElementById('wz_manualUnitPrice');
if (priceInput && (!priceInput.value || parseFloat(priceInput.value) === 0)) {
priceInput.value = price.toFixed(2);
}
document.getElementById('err_salesCatalogItemId')?.classList.add('d-none');
document.getElementById('wz_quantity')?.focus();
}
function wzMerchComboKey(e) {
const dd = document.getElementById('wzMerchDropdown');
if (!dd || dd.style.display === 'none') return;
const opts = Array.from(dd.querySelectorAll('.wz-merch-opt'));
const active = dd.querySelector('.wz-merch-opt.mc-active');
let idx = opts.indexOf(active);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (active) { active.classList.remove('mc-active'); active.style.background = ''; }
idx = (idx + 1) % opts.length;
opts[idx].classList.add('mc-active'); opts[idx].style.background = '#f0f4ff';
opts[idx].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (active) { active.classList.remove('mc-active'); active.style.background = ''; }
idx = (idx - 1 + opts.length) % opts.length;
opts[idx].classList.add('mc-active'); opts[idx].style.background = '#f0f4ff';
opts[idx].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter' && active) {
e.preventDefault();
wzMerchComboSelect(active);
} else if (e.key === 'Escape') {
wzMerchComboClose();
}
}
// Close dropdown when clicking outside the wizard combobox
document.addEventListener('mousedown', e => {
const wrapper = document.getElementById('wzMerchComboWrapper');
const dd = document.getElementById('wzMerchDropdown');
if (wrapper && dd && !wrapper.contains(e.target) && !dd.contains(e.target)) {
wzMerchComboClose();
}
});
function renderAiPhotoFields() {
const existingPhotoHtml = wz.ai.tempIds.map((tid, i) => {
const previewUrl = wz.ai.previewUrls[i] || '';
const thumb = previewUrl
? `<img src="${escHtml(previewUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:4px;flex-shrink:0;">`
: `<i class="bi bi-image text-primary fs-5"></i>`;
return `
<div class="d-flex align-items-center gap-2 p-2 border rounded bg-light mb-1" id="ai_photoRow_${i}">
${thumb}
<span class="flex-grow-1 small text-truncate">${escHtml(wz.ai.fileNames[i] || tid)}</span>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="aiRemovePhoto(${i})"><i class="bi bi-x"></i></button>
</div>`;
}).join('');
const resultHtml = wz.ai.accepted && wz.ai.result ? renderAiResultHtml(wz.ai.result) : '';
return `
<!-- Photo Upload Section -->
<div class="mb-3">
<label class="form-label fw-semibold">Photos <span class="text-danger">*</span>
<small class="text-muted fw-normal">(jpg, png, webp — max 10 MB each)</small>
</label>
<div id="ai_dropzone" class="border border-2 border-dashed rounded p-4 text-center text-muted mb-2"
style="cursor:pointer;border-style:dashed!important;min-height:80px;"
onclick="document.getElementById('ai_fileInput').click()"
ondragover="event.preventDefault();this.classList.add('bg-primary','bg-opacity-10')"
ondragleave="this.classList.remove('bg-primary','bg-opacity-10')"
ondrop="event.preventDefault();this.classList.remove('bg-primary','bg-opacity-10');aiHandleDrop(event)">
<i class="bi bi-cloud-upload fs-3 d-block mb-1"></i>
<span>Click to browse or drag &amp; drop photos here</span>
</div>
<input type="file" id="ai_fileInput" class="d-none" accept="image/*" multiple onchange="aiHandleFileSelect(this)">
<div id="ai_photoList">${existingPhotoHtml}</div>
<div class="text-danger d-none mt-1" id="ai_photoError">Please upload at least one photo.</div>
</div>
<!-- Metadata Inputs -->
<div class="row g-3 mb-3">
<div class="col-12">
<label class="form-label fw-semibold">Item Description &amp; Known Dimension <span class="text-danger">*</span>
<small class="text-muted fw-normal">Describe the item and include at least one known measurement so the AI can estimate surface area</small>
</label>
<input type="text" class="form-control" id="ai_referenceDim"
placeholder='e.g. "18-inch aluminum wheel", "steel bracket approx 24 × 12 inches", "truck bumper about 60 inches wide"'
value="${escHtml(wz.data.aiReferenceDim || '')}">
<div class="text-danger d-none mt-1" id="ai_dimError">Item description with dimension is required.</div>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="ai_quantity" min="1" value="${wz.data.quantity || 1}">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Desired Color / Finish</label>
<input type="text" class="form-control" id="ai_color"
placeholder="e.g., Gloss Black, RAL 7016…"
value="${escHtml(wz.data.aiColor || '')}">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Coating Stages</label>
<input type="number" class="form-control" id="ai_coatCount" min="1" max="5" value="${wz.data.aiCoatCount || 1}">
<small class="text-muted">Number of powder coats to apply</small>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Est. Weight (lbs)</label>
<input type="number" class="form-control" id="ai_weightLbs" min="0" step="0.5"
placeholder="Optional"
value="${wz.data.aiWeightLbs || ''}">
<small class="text-muted">Per piece — helps price heavy items</small>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label fw-semibold">Material Type</label>
<select class="form-select" id="ai_materialType">
<option value="" ${!wz.data.aiMaterialType ? 'selected' : ''}>Unknown / Let AI decide</option>
<option value="Cast Iron" ${'Cast Iron' === wz.data.aiMaterialType ? 'selected' : ''}>Cast Iron</option>
<option value="Cast Aluminum" ${'Cast Aluminum' === wz.data.aiMaterialType ? 'selected' : ''}>Cast Aluminum</option>
<option value="Steel (sheet/tube)" ${'Steel (sheet/tube)' === wz.data.aiMaterialType ? 'selected' : ''}>Steel (sheet / tube)</option>
<option value="Heavy Steel (structural/plate)" ${'Heavy Steel (structural/plate)' === wz.data.aiMaterialType ? 'selected' : ''}>Heavy Steel (structural / plate)</option>
<option value="Aluminum (sheet/extrusion)" ${'Aluminum (sheet/extrusion)' === wz.data.aiMaterialType ? 'selected' : ''}>Aluminum (sheet / extrusion)</option>
<option value="Stainless Steel" ${'Stainless Steel' === wz.data.aiMaterialType ? 'selected' : ''}>Stainless Steel</option>
<option value="Galvanized Steel" ${'Galvanized Steel' === wz.data.aiMaterialType ? 'selected' : ''}>Galvanized Steel</option>
<option value="Wrought Iron" ${'Wrought Iron' === wz.data.aiMaterialType ? 'selected' : ''}>Wrought Iron</option>
<option value="Other" ${'Other' === wz.data.aiMaterialType ? 'selected' : ''}>Other</option>
</select>
<small class="text-muted">Affects prep, outgassing, cure time</small>
</div>
</div>
<!-- Analyze Button -->
<div class="d-flex align-items-center gap-2 mb-3">
<button type="button" class="btn btn-primary" onclick="aiAnalyze()" id="ai_analyzeBtn">
<i class="bi bi-robot me-1"></i>Analyze with AI
</button>
<div id="ai_loadingSpinner" class="d-none spinner-border spinner-border-sm text-primary" role="status"></div>
<span id="ai_loadingText" class="d-none text-muted small">Analyzing photos, please wait…</span>
</div>
<!-- Follow-up Question Section -->
<div id="ai_followupSection" class="${wz.ai.phase === 'followup' ? '' : 'd-none'} alert alert-info">
<div class="fw-semibold mb-2"><i class="bi bi-question-circle me-1"></i>AI Follow-up Question:</div>
<p id="ai_followupQuestion" class="mb-2">${escHtml(wz.ai.followUpQuestion || '')}</p>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="ai_followupAnswer" placeholder="Your answer…">
<button type="button" class="btn btn-primary btn-sm text-nowrap" onclick="aiSendFollowup()">Send Answer</button>
</div>
</div>
<!-- AI Error -->
<div id="ai_errorAlert" class="d-none alert alert-danger"></div>
<!-- Results Section -->
<div id="ai_resultsSection" class="${wz.ai.phase === 'result' ? '' : 'd-none'}">
${resultHtml}
</div>
<!-- Accept Error -->
<div class="text-danger d-none mt-2" id="ai_acceptError">Please analyze your photos and accept the AI estimate before continuing.</div>`;
}
// Recompute AI unit price client-side when the user changes sqft, minutes, or complexity.
// Uses the same formula as AiQuoteService.CalculatePricingPreview on the server.
function aiRecalcPrice() {
const b = wz.ai.result?.breakdown;
if (!b) return;
const sqft = parseFloat(document.getElementById('ai_sqftOverride')?.value) || b.surfaceAreaSqFt;
const minutes = parseInt(document.getElementById('ai_minutesOverride')?.value) || b.estimatedMinutes;
const complexity = document.getElementById('ai_complexityOverride')?.value || b.complexity;
const coatCount = b.coatCount || 1;
const coverage = b.coverageSqFtPerLb || 30;
const efficiency = (b.transferEfficiency || 65) / 100;
const lbsPerCoat = sqft > 0 ? sqft / (coverage * efficiency) : 0;
const material = lbsPerCoat * coatCount * b.powderCostPerLb;
const consumables = material * 0.05;
const labor = (minutes / 60) * b.laborRatePerHour;
const oven = (b.ovenCycleMinutes / 60) * b.ovenRatePerHour;
const subtotal = material + consumables + labor + oven;
const complexPctMap = {
Simple: b.simplePercent || 0,
Moderate: b.moderatePercent || 0,
Complex: b.complexPercent || 0,
Extreme: b.extremePercent || 0
};
const complexPct = (complexPctMap[complexity] || 0) / 100;
const complexCharge = subtotal * complexPct;
const preMarkup = subtotal + complexCharge;
const markupAmt = preMarkup * (b.markupPercent / 100);
const unitPrice = Math.max(0, Math.round((preMarkup + markupAmt) * 100) / 100);
// Update the price placeholder and the estimated price display
const priceOverrideEl = document.getElementById('ai_priceOverride');
if (priceOverrideEl) priceOverrideEl.placeholder = unitPrice.toFixed(2);
const priceDisplayEl = document.getElementById('ai_priceDisplay');
if (priceDisplayEl) {
const fmt = v => '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
priceDisplayEl.textContent = fmt(unitPrice);
}
// Store so Accept uses the recalculated price if no manual override
wz.ai.recalcUnitPrice = unitPrice;
}
function buildAiPriceBreakdown(result) {
const b = result.breakdown;
if (!b) return '';
const fmt = v => '$' + (v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `<details class="mb-2">
<summary class="text-muted small fw-semibold" style="cursor:pointer">
<i class="bi bi-calculator me-1"></i>Price Breakdown <span class="text-muted fw-normal">(click to expand)</span>
</summary>
<table class="table table-sm table-borderless mb-0 mt-2" style="font-size:.82rem;">
<tbody>
<tr class="table-light">
<td colspan="2" class="fw-semibold text-muted py-1 px-2">Materials</td>
</tr>
<tr>
<td class="px-3 text-muted">Surface area</td>
<td class="text-end">${b.surfaceAreaSqFt.toFixed(2)} sq ft</td>
</tr>
<tr>
<td class="px-3 text-muted">Powder (${b.powderLbsPerCoat.toFixed(3)} lb/coat × ${b.coatCount} coat${b.coatCount !== 1 ? 's' : ''} × ${fmt(b.powderCostPerLb)}/lb)</td>
<td class="text-end">${fmt(b.materialCost)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Consumables (5%)</td>
<td class="text-end">${fmt(b.consumablesCost)}</td>
</tr>
<tr class="table-light">
<td colspan="2" class="fw-semibold text-muted py-1 px-2">Labor &amp; Equipment</td>
</tr>
<tr>
<td class="px-3 text-muted">Active labor (${b.estimatedMinutes} min × ${fmt(b.laborRatePerHour)}/hr)${b.minFloorApplied ? ` <span class="badge bg-warning text-dark ms-1" title="AI estimated less than the ${b.materialMinMinutes}-min minimum for this material — floored">min floor</span>` : ''}</td>
<td class="text-end">${fmt(b.laborCost)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Oven cure (${b.ovenCycleMinutes} min × ${fmt(b.ovenRatePerHour)}/hr) <span class="text-muted fst-italic small">— shared, priced at quote level</span></td>
<td class="text-end text-muted">—</td>
</tr>
${b.requiresPreheat ? `<tr>
<td class="px-3 text-muted">Outgassing preheat (${b.preheatMinutes} min × ${fmt(b.ovenRatePerHour)}/hr)</td>
<td class="text-end">${fmt(b.preheatCost)}</td>
</tr>` : ''}
<tr class="border-top">
<td class="px-3 text-muted">Subtotal</td>
<td class="text-end fw-semibold">${fmt(b.subtotalBeforeComplexity)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Complexity ${b.complexity} (+${b.complexityPercent}%)</td>
<td class="text-end">${fmt(b.complexityCharge)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Markup (+${b.markupPercent}%)</td>
<td class="text-end">${fmt(b.markupAmount)}</td>
</tr>
<tr class="border-top fw-bold">
<td class="px-2">Unit Price</td>
<td class="text-end text-success">${fmt(b.unitPrice)}</td>
</tr>
</tbody>
</table>
<p class="text-muted mb-0 mt-1" style="font-size:.75rem;">
<i class="bi bi-info-circle me-1"></i>Labor rate and markup are set in <strong>Company Settings → Operating Costs</strong>.
</p>
</details>`;
}
function renderAiResultHtml(result) {
const isSaved = !result.confidence;
const confidenceClass = result.confidence === 'High' ? 'success' : result.confidence === 'Low' ? 'warning' : 'info';
return `
<div class="card border-${isSaved ? 'info' : 'success'}">
<div class="card-header bg-${isSaved ? 'info' : 'success'} bg-opacity-10 d-flex align-items-center justify-content-between">
<span class="fw-semibold"><i class="bi bi-${isSaved ? 'pencil-square text-info' : 'check-circle-fill text-success'} me-1"></i>${isSaved ? 'Saved Estimate — edit values below' : 'AI Estimate Ready'}</span>
${result.confidence ? `<span class="badge bg-${confidenceClass}">${escHtml(result.confidence)} Confidence</span>` : ''}
</div>
<div class="card-body pb-2">
<div class="row g-3 mb-2">
<div class="col-12">
<label class="form-label fw-semibold mb-1">Description</label>
<input type="text" class="form-control" id="ai_descOverride"
value="${escHtml(result.description || '')}" placeholder="Item description…">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Sq Ft / Item</label>
<input type="number" class="form-control" id="ai_sqftOverride" step="0.01" min="0.01"
value="${result.surfaceAreaSqFt}" oninput="aiRecalcPrice()">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Complexity</label>
<select class="form-select" id="ai_complexityOverride" onchange="aiRecalcPrice()">
<option value="Simple"${result.complexity === 'Simple' ? ' selected' : ''}>Simple</option>
<option value="Moderate"${result.complexity === 'Moderate' ? ' selected' : ''}>Moderate</option>
<option value="Complex"${result.complexity === 'Complex' ? ' selected' : ''}>Complex</option>
<option value="Extreme"${result.complexity === 'Extreme' ? ' selected' : ''}>Extreme</option>
</select>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Est. Minutes</label>
<input type="number" class="form-control" id="ai_minutesOverride" min="1"
value="${result.estimatedMinutes}" oninput="aiRecalcPrice()">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold mb-1">Price Override</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="ai_priceOverride" min="0" step="0.01"
placeholder="${result.estimatedUnitPrice.toFixed(2)}">
</div>
<small class="text-muted">Leave blank to use engine calc</small>
</div>
</div>
<div class="alert alert-light py-2 mb-2">
<div class="d-flex justify-content-between">
<span><i class="bi bi-currency-dollar me-1"></i><strong>AI Estimated Unit Price:</strong></span>
<span id="ai_priceDisplay" class="fw-semibold text-success">$${result.estimatedUnitPrice.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted small"><i class="bi bi-stack me-1"></i>Estimated Total (× qty):</span>
<span class="text-muted small">$${result.estimatedTotal.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}</span>
</div>
<div class="mt-1" style="font-size:.78rem;color:#6c757d;">
<i class="bi bi-arrow-repeat me-1"></i>Price updates automatically when you change sq ft, minutes, or complexity above.
</div>
</div>
${buildAiPriceBreakdown(result)}
<div class="mb-2">
<div class="d-flex flex-wrap align-items-center gap-1" id="ai_tagList"></div>
<div class="input-group input-group-sm mt-1" style="max-width:280px">
<input type="text" class="form-control form-control-sm" id="ai_tagInput"
placeholder="Add tag, press Enter…" maxlength="40"
onkeydown="if(event.key==='Enter'||event.key===','){event.preventDefault();aiAddTag();}">
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="aiAddTag()">
<i class="bi bi-plus"></i>
</button>
</div>
</div>
${result.benchmark ? `
<div class="alert alert-info py-2 mb-2">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-clock-history me-2"></i>
<strong class="small">Historical Benchmark</strong>
<span class="badge bg-info ms-2">${result.benchmark.matchCount} similar job${result.benchmark.matchCount !== 1 ? 's' : ''}</span>
</div>
<div class="d-flex justify-content-between small">
<span class="text-muted">Range:</span>
<span>$${result.benchmark.minPrice.toFixed(2)} $${result.benchmark.maxPrice.toFixed(2)}</span>
</div>
<div class="d-flex justify-content-between small fw-semibold">
<span class="text-muted">Avg price / item:</span>
<span>$${result.benchmark.avgPrice.toFixed(2)}</span>
</div>
</div>` : ''}
${result.aiReasoning ? `
<details class="mb-2">
<summary class="text-muted small" style="cursor:pointer"><i class="bi bi-info-circle me-1"></i>AI Reasoning</summary>
<p class="small text-muted mt-1 mb-0">${escHtml(result.aiReasoning)}</p>
</details>` : ''}
<details class="mb-2">
<summary class="text-muted small fw-semibold" style="cursor:pointer">
<i class="bi bi-question-circle me-1"></i>Price seems off? Here's what to do
</summary>
<div class="mt-2 p-2 bg-light rounded" style="font-size:.82rem;">
<p class="mb-2 fw-semibold">The price is built from: <em>labor + powder + oven + complexity + markup.</em>
The most common reason for a high estimate is the AI over-estimating minutes.</p>
<ol class="mb-2 ps-3">
<li class="mb-1"><strong>Check the breakdown</strong> (above) — expand "Price Breakdown" to see exactly which line is driving the cost.</li>
<li class="mb-1"><strong>Adjust Est. Minutes</strong> — the AI estimates time for the entire job including blasting, masking, cure, and cleanup. If it's too high, lower it and the price will update instantly.</li>
<li class="mb-1"><strong>Adjust Complexity</strong> — dropping from Moderate to Simple can meaningfully reduce the price if the item is straightforward.</li>
<li class="mb-1"><strong>Adjust Sq Ft</strong> — if you know the surface area is wrong, fix it here.</li>
<li class="mb-1"><strong>Override the price directly</strong> — enter your own number in the Price Override field. This always wins over the calculated price.</li>
<li class="mb-1"><strong>Labor rate or markup too high?</strong> Those are set company-wide in <strong>Settings → Operating Costs</strong> and affect all quotes.</li>
</ol>
<p class="mb-0 text-muted">The AI learns from accepted quotes over time — the more quotes you run without overriding, the better it calibrates to your shop's pricing.</p>
</div>
</details>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="aiReAnalyze()">
<i class="bi bi-arrow-repeat me-1"></i>Re-analyze
</button>
</div>
</div>`;
}
// ─── AI Upload / Analysis Functions ──────────────────────────────────────────
function aiHandleFileSelect(input) {
Array.from(input.files).forEach(aiUploadFile);
input.value = ''; // reset so same file can be re-selected
}
function aiHandleDrop(event) {
Array.from(event.dataTransfer.files).forEach(aiUploadFile);
}
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
? `<img src="${escHtml(previewUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:4px;flex-shrink:0;">`
: `<i class="bi bi-image text-primary fs-5"></i>`;
return `<div class="d-flex align-items-center gap-2 p-2 border rounded bg-light mb-1">
${thumb}
<span class="flex-grow-1 small text-truncate">${escHtml(wz.ai.fileNames[i] || tid)}</span>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="aiRemovePhoto(${i})"><i class="bi bi-x"></i></button>
</div>`;
}).join('');
}
async function aiAnalyze() {
// Reset any prior session so each click is a clean analysis
wz.ai.result = null;
wz.ai.conversationHistory = [];
wz.ai.accepted = false;
wz.ai.tags = [];
document.getElementById('ai_resultsSection')?.classList.add('d-none');
document.getElementById('ai_followupSection')?.classList.add('d-none');
// Validate
if (wz.ai.tempIds.length === 0) {
document.getElementById('ai_photoError')?.classList.remove('d-none');
return;
}
const refDim = document.getElementById('ai_referenceDim')?.value?.trim();
if (!refDim) {
document.getElementById('ai_dimError')?.classList.remove('d-none');
return;
}
document.getElementById('ai_dimError')?.classList.add('d-none');
const qty = parseInt(document.getElementById('ai_quantity')?.value) || 1;
const color = document.getElementById('ai_color')?.value?.trim() || '';
const coats = parseInt(document.getElementById('ai_coatCount')?.value) || 1;
const materialType = document.getElementById('ai_materialType')?.value?.trim() || '';
const weightLbsRaw = parseFloat(document.getElementById('ai_weightLbs')?.value);
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
// Persist all input fields before any renderStep() re-renders the form
wz.data.quantity = qty;
wz.data.aiReferenceDim = refDim;
wz.data.aiColor = color;
wz.data.aiCoatCount = coats;
wz.data.aiMaterialType = materialType;
wz.data.aiWeightLbs = weightLbs;
aiSetLoading(true);
document.getElementById('ai_followupSection')?.classList.add('d-none');
document.getElementById('ai_resultsSection')?.classList.add('d-none');
document.getElementById('ai_errorAlert')?.classList.add('d-none');
const 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 =>
`<span class="badge bg-secondary d-inline-flex align-items-center gap-1">
${escHtml(t)}
<button type="button" class="btn-close btn-close-white" style="font-size:.5rem"
aria-label="Remove" onclick="aiRemoveTag(${JSON.stringify(t)})"></button>
</span>`
).join('');
}
function aiAddTag() {
const input = document.getElementById('ai_tagInput');
if (!input) return;
const val = input.value.trim().toLowerCase().replace(/,/g, '');
if (!val) return;
if (!wz.ai.tags.includes(val)) {
wz.ai.tags.push(val);
aiRenderTags();
}
input.value = '';
input.focus();
}
function aiRemoveTag(tag) {
wz.ai.tags = wz.ai.tags.filter(t => t !== tag);
aiRenderTags();
}
function aiSetLoading(isLoading) {
const btn = document.getElementById('ai_analyzeBtn');
const spinner = document.getElementById('ai_loadingSpinner');
const text = document.getElementById('ai_loadingText');
if (btn) btn.disabled = isLoading;
spinner?.classList.toggle('d-none', !isLoading);
text?.classList.toggle('d-none', !isLoading);
}
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 `
<p class="text-muted small mb-3">
<i class="bi bi-info-circle me-1"></i>
Add one or more coating layers. The first coat uses 100% of the labor estimate;
each additional coat adds 30%.
</p>
<div id="coatsListContainer"></div>
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
</button>`;
}
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 =>
`<option value="${n}"${selectVal === n ? ' selected' : ''}>${n}</option>`
).join('');
return `
<select class="form-select form-select-sm" style="max-width:160px"
id="coat_name_sel_${i}" onchange="onCoatNameSelect(${i})">
<option value="">-- Select --</option>
${options}
<option value="__other__"${selectVal === '__other__' ? ' selected' : ''}>Other...</option>
</select>
<input type="text" class="form-control form-control-sm" style="max-width:140px;display:${selectVal === '__other__' ? 'block' : 'none'}"
id="coat_name_${i}" placeholder="Custom name" value="${escHtml(textVal)}">`;
}
function onCoatNameSelect(i) {
const sel = document.getElementById(`coat_name_sel_${i}`);
const txt = document.getElementById(`coat_name_${i}`);
if (!sel || !txt) return;
txt.style.display = sel.value === '__other__' ? 'block' : 'none';
if (sel.value !== '__other__') txt.value = '';
}
function buildCoatRowHtml(i, coat) {
const supplierOptions = supplierData.map(s =>
`<option value="${s.value}">${escHtml(s.text)}</option>`
).join('');
return `
<div class="coat-row" id="coatRow_${i}" data-coat-index="${i}">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary">#${i + 1}</span>
${buildCoatNameHtml(i, coat.coatName)}
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCoatRow(${i})">
<i class="bi bi-trash"></i>
</button>
</div>
<!-- Powder type toggle -->
<div class="d-flex gap-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="coat_type_${i}" id="coat_stock_${i}"
value="stock" ${(!coat.powderType || coat.powderType === 'stock') ? 'checked' : ''}
onchange="toggleCoatPowderType(${i})">
<label class="form-check-label" for="coat_stock_${i}">From inventory</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="coat_type_${i}" id="coat_custom_${i}"
value="custom" ${coat.powderType === 'custom' ? 'checked' : ''}
onchange="toggleCoatPowderType(${i})">
<label class="form-check-label" for="coat_custom_${i}">Custom / specify manually</label>
</div>
</div>
<!-- Stock powder -->
<div id="coat_stock_section_${i}" class="row g-2" style="display:${(!coat.powderType || coat.powderType === 'stock') ? 'flex' : 'none'}">
<div class="col-12">
<div class="position-relative" id="coat_powder_wrapper_${i}">
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" id="coat_powder_search_${i}"
placeholder="Search by name, color, or code…" autocomplete="off"
oninput="powderComboInput(${i})"
onfocus="powderComboOpen(${i})"
onkeydown="powderComboKey(event,${i})">
<button class="btn btn-outline-secondary btn-sm" type="button" tabindex="-1"
onclick="powderComboToggle(${i})">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<input type="hidden" id="coat_inventoryItemId_${i}">
<div id="coat_powder_dropdown_${i}"
class="powder-combo-dropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
</div>
<div class="col-sm-4">
<label class="form-label form-label-sm">Coverage (sq ft/lb)</label>
<input type="number" class="form-control form-control-sm" id="coat_coverage_${i}" min="1" step="0.1" value="${coat.coverageSqFtPerLb || 30}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-4">
<label class="form-label form-label-sm">Transfer Efficiency (%)</label>
<input type="number" class="form-control form-control-sm" id="coat_efficiency_${i}" min="1" max="100" step="0.1" value="${coat.transferEfficiency || 65}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-4">
<label class="form-label form-label-sm">Cost ($/lb)</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="coat_costPerLb_${i}" min="0" step="0.01" placeholder="auto" value="${coat.powderCostPerLb || ''}">
</div>
</div>
</div>
<!-- Custom powder -->
<div id="coat_custom_section_${i}" class="row g-2" style="display:${coat.powderType === 'custom' ? 'flex' : 'none'}">
<div class="col-sm-6">
<label class="form-label form-label-sm">Color Name</label>
<input type="text" class="form-control form-control-sm" id="coat_colorName_${i}" value="${escHtml(coat.colorName || '')}" placeholder="e.g., Gloss Black">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Color Code</label>
<input type="text" class="form-control form-control-sm" id="coat_colorCode_${i}" value="${escHtml(coat.colorCode || '')}" placeholder="RAL-XXXX">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Finish</label>
<input type="text" class="form-control form-control-sm" id="coat_finish_${i}" value="${escHtml(coat.finish || '')}" placeholder="Gloss / Matte…">
</div>
<div class="col-sm-6">
<label class="form-label form-label-sm">Supplier (optional)</label>
<select class="form-select form-select-sm" id="coat_supplierId_${i}">
<option value="">-- None --</option>
${supplierOptions}
</select>
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Coverage (sq ft/lb)</label>
<input type="number" class="form-control form-control-sm" id="coat_custom_coverage_${i}" min="1" step="0.1" value="${coat.coverageSqFtPerLb || 30}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-3">
<label class="form-label form-label-sm">Transfer Eff. (%)</label>
<input type="number" class="form-control form-control-sm" id="coat_custom_efficiency_${i}" min="1" max="100" step="0.1" value="${coat.transferEfficiency || 65}"
oninput="updatePowderNeeded(${i})">
</div>
<div class="col-sm-4">
<label class="form-label form-label-sm">Cost ($/lb)</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="coat_custom_costPerLb_${i}" min="0" step="0.01" placeholder="0.00" value="${coat.powderCostPerLb || ''}">
</div>
</div>
<div class="col-12">
<div class="alert alert-warning py-2 mb-0">
<label class="form-label form-label-sm fw-semibold mb-1"><i class="bi bi-cart me-1"></i>Qty to Order (lbs) — this powder must be purchased before the job</label>
<div class="d-flex align-items-center gap-2">
<div class="input-group input-group-sm" style="max-width:200px">
<input type="number" class="form-control" id="coat_custom_orderQty_${i}" min="0" step="0.01"
placeholder="Enter lbs to order" value="${coat.powderToOrder != null ? coat.powderToOrder : ''}">
<span class="input-group-text">lbs</span>
</div>
<span class="text-muted small">Suggested from area: <strong id="coat_custom_calcQty_${i}">—</strong></span>
</div>
</div>
</div>
</div>
<!-- Powder needed display (shared across stock/custom) -->
<div class="alert alert-info py-2 mt-2 mb-0 d-flex align-items-center gap-2" id="coat_powderNeededRow_${i}" style="display:${(wz.data.surfaceAreaSqFt > 0) ? 'flex' : 'none'}!important">
<i class="bi bi-box-seam"></i>
<div>
<strong>Powder Needed for This Coat (Total Batch):</strong>
<span id="coat_powderNeededVal_${i}" class="fw-semibold ms-1">—</span>
<div class="small text-muted">This calculation is for the entire batch (all items × surface area)</div>
</div>
</div>
<div class="mt-2 d-flex align-items-center gap-3">
<input type="text" class="form-control form-control-sm" id="coat_notes_${i}"
value="${escHtml(coat.notes || '')}" placeholder="Notes about this coat layer (optional)">
<div class="form-check form-check-inline text-nowrap mb-0 ms-2">
<input class="form-check-input" type="checkbox" id="coat_noExtraCharge_${i}"
${coat.noExtraLayerCharge ? 'checked' : ''}>
<label class="form-check-label text-muted small" for="coat_noExtraCharge_${i}">
No extra layer charge
</label>
</div>
</div>
</div>`;
}
function restoreCoatRow(i, coat) {
// Restore inventory selection if present
if (coat.inventoryItemId) {
const hidden = document.getElementById(`coat_inventoryItemId_${i}`);
if (hidden) hidden.value = coat.inventoryItemId;
// Also fill the search display text
const search = document.getElementById(`coat_powder_search_${i}`);
if (search) {
const powder = powderData.find(p => p.value == coat.inventoryItemId);
if (powder) search.value = powder.text;
}
}
// Restore supplier selection
if (coat.supplierId) {
const sel = document.getElementById(`coat_supplierId_${i}`);
if (sel) sel.value = coat.supplierId;
}
// Restore order qty for custom powder
if (coat.powderType === 'custom' && coat.powderToOrder != null) {
const el = document.getElementById(`coat_custom_orderQty_${i}`);
if (el) el.value = coat.powderToOrder;
}
}
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 = '<div class="px-3 py-2 text-muted small">No powders match your search</div>';
return;
}
dd.innerHTML = filtered.map(p =>
`<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
data-val="${escHtml(String(p.value))}"
data-txt="${escHtml(p.text)}"
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
${escHtml(p.text)}
</div>`
).join('');
}
function powderComboShow(i) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
const anchor = document.getElementById(`coat_powder_search_${i}`);
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = (rect.width - 20) + 'px';
dd.style.display = 'block';
}
function powderComboClose(i) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (dd) dd.style.display = 'none';
}
function powderComboSelect(i, value, text) {
const hidden = document.getElementById(`coat_inventoryItemId_${i}`);
const search = document.getElementById(`coat_powder_search_${i}`);
if (hidden) hidden.value = value;
if (search) search.value = text;
powderComboClose(i);
onPowderSelected(i);
}
function powderComboKey(event, i) {
const dd = document.getElementById(`coat_powder_dropdown_${i}`);
if (!dd || dd.style.display === 'none') {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault();
powderComboOpen(i);
}
return;
}
const items = Array.from(dd.querySelectorAll('.powder-opt'));
let idx = items.findIndex(it => it.classList.contains('pw-active'));
if (event.key === 'ArrowDown') {
event.preventDefault();
idx = Math.min(idx + 1, items.length - 1);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.pw-active') || items[0];
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
} else if (event.key === 'Escape') {
powderComboClose(i);
}
}
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 `<div class="text-center text-muted py-5">
<i class="bi bi-tools fs-1 mb-2 d-block"></i>
<p class="mb-1">No preparation services configured.</p>
<p class="small">Add them in <strong>Company Settings → Prep Services</strong>.</p>
</div>`;
}
const 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 ? `
<div class="alert alert-info py-2 mb-3">
<i class="bi bi-info-circle me-1"></i>
<strong>Catalog item:</strong> prep costs are already baked into the catalog price.
Check the services needed for your records, or turn on the toggle below to add a separate prep charge.
<div class="form-check form-switch mt-2 mb-0">
<input class="form-check-input" type="checkbox" id="prep_includeCost"
${includePrepCost ? 'checked' : ''} onchange="onPrepIncludeCostToggle()">
<label class="form-check-label fw-semibold" for="prep_includeCost">Add prep charge to this item's price</label>
</div>
</div>` : '';
const aiBanner = isAi ? `
<div class="alert alert-success py-2 mb-3">
<i class="bi bi-robot me-1"></i>
<strong>AI estimate:</strong> prep costs are already included in the AI price.
Select the services below for shop floor reference — they will <strong>not</strong> add to the item price.
</div>` : '';
const rows = prepServiceData.map(p => {
const existing = current.find(c => c.prepServiceId === p.id);
const checked = existing ? 'checked' : '';
const minutes = existing ? existing.estimatedMinutes : '';
return `
<div class="d-flex align-items-center gap-3 py-2 border-bottom">
<div class="form-check mb-0 flex-grow-1">
<input class="form-check-input" type="checkbox" id="prep_${p.id}"
value="${p.id}" ${checked} onchange="onPrepToggle(${p.id})">
<label class="form-check-label fw-semibold" for="prep_${p.id}">${escHtml(p.name)}</label>
${p.description ? `<div class="text-muted small">${escHtml(p.description)}</div>` : ''}
</div>
<div id="prep_min_group_${p.id}" class="${checked ? '' : 'd-none'}" style="width:140px;">
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="prep_min_${p.id}"
min="1" value="${minutes}" placeholder="Minutes">
<span class="input-group-text">min</span>
</div>
</div>
</div>`;
}).join('');
const hint = isCatalog
? ''
: `<div class="text-muted small mb-3">Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.</div>`;
return `${catalogBanner}${aiBanner}${hint}${rows}`;
}
function onPrepIncludeCostToggle() {
wz.data.includePrepCost = document.getElementById('prep_includeCost')?.checked ?? false;
}
function onPrepToggle(id) {
const checked = document.getElementById(`prep_${id}`)?.checked;
const group = document.getElementById(`prep_min_group_${id}`);
if (group) group.classList.toggle('d-none', !checked);
if (checked) document.getElementById(`prep_min_${id}`)?.focus();
}
function collectStep4() {
// Catalog items: read the toggle (default false); calculated items: always true
if (wz.itemType === 'product') {
wz.data.includePrepCost = document.getElementById('prep_includeCost')?.checked ?? false;
} else {
wz.data.includePrepCost = true;
}
wz.data.prepServices = prepServiceData
.filter(p => document.getElementById(`prep_${p.id}`)?.checked)
.map(p => ({
prepServiceId: p.id,
prepServiceName: p.name,
estimatedMinutes: parseInt(document.getElementById(`prep_min_${p.id}`)?.value) || 0
}));
}
// ─── Data collection / validation ─────────────────────────────────────────────
function collectCurrentStep() {
if (wz.step === 1) {
if (!wz.itemType) {
document.getElementById('typeError')?.classList.remove('d-none');
return false;
}
return true;
}
if (wz.step === 2) {
return collectStep2();
}
if (wz.step === 3) {
collectStep3();
return true;
}
if (wz.step === 4) {
collectStep4();
return true;
}
return true;
}
function collectStep2() {
let valid = true;
if (wz.itemType === 'product') {
const sel = document.getElementById('wz_catalogItemId');
const val = sel?.value;
if (!val) {
document.getElementById('err_catalogItemId')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_catalogItemId')?.classList.add('d-none');
wz.data.catalogItemId = parseInt(val);
// Auto-fill description, surface area, and estimated minutes from catalog
const cat = catalogData.find(c => c.value === val);
wz.data.description = cat ? cat.text.split(' > ').pop().split(' - ')[0] : 'Product Item';
wz.data.surfaceAreaSqFt = cat ? (parseFloat(cat.approxArea) || 0) : 0;
wz.data.estimatedMinutes = cat ? (parseInt(cat.defaultMinutes) || 0) : 0;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
const override = parseFloat(document.getElementById('wz_powderCostOverride')?.value);
wz.data.powderCostOverride = isNaN(override) ? null : override;
}
if (wz.itemType === 'calculated') {
const desc = document.getElementById('wz_description')?.value?.trim();
if (!desc) {
document.getElementById('err_description')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_description')?.classList.add('d-none');
wz.data.description = desc;
}
wz.data.complexity = document.getElementById('wz_complexity')?.value || 'Simple';
const sqft = parseFloat(document.getElementById('wz_surfaceAreaSqFt')?.value);
if (!sqft || sqft <= 0) {
document.getElementById('err_surfaceAreaSqFt')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_surfaceAreaSqFt')?.classList.add('d-none');
wz.data.surfaceAreaSqFt = sqft;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
wz.data.estimatedMinutes = parseInt(document.getElementById('wz_estimatedMinutes')?.value) || 30;
}
if (wz.itemType === 'generic') {
const desc = document.getElementById('wz_description')?.value?.trim();
if (!desc) {
document.getElementById('err_description')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_description')?.classList.add('d-none');
wz.data.description = desc;
}
const price = parseFloat(document.getElementById('wz_manualUnitPrice')?.value);
if (isNaN(price) || price < 0) {
document.getElementById('err_manualUnitPrice')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_manualUnitPrice')?.classList.add('d-none');
wz.data.manualUnitPrice = price;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
wz.data.notes = document.getElementById('wz_notes')?.value?.trim() || null;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
wz.data.isGenericItem = true;
}
if (wz.itemType === 'labor') {
const desc = document.getElementById('wz_description')?.value?.trim();
if (!desc) {
document.getElementById('err_description')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_description')?.classList.add('d-none');
wz.data.description = desc;
}
wz.data.quantity = parseFloat(document.getElementById('wz_quantity')?.value) || 1;
wz.data.notes = document.getElementById('wz_notes')?.value?.trim() || null;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
wz.data.isLaborItem = true;
}
if (wz.itemType === 'sales') {
const itemId = wz.data.salesCatalogItemId;
if (!itemId) {
document.getElementById('err_salesCatalogItemId')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_salesCatalogItemId')?.classList.add('d-none');
// Pull description and sku from the merchandise data
const merch = merchandiseData.find(m => m.id === itemId);
wz.data.description = merch ? merch.name : '';
wz.data.sku = merch ? (merch.sku || null) : null;
}
const price = parseFloat(document.getElementById('wz_manualUnitPrice')?.value);
if (isNaN(price) || price < 0) {
document.getElementById('err_manualUnitPrice')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_manualUnitPrice')?.classList.add('d-none');
wz.data.manualUnitPrice = price;
}
wz.data.quantity = parseInt(document.getElementById('wz_quantity')?.value) || 1;
wz.data.notes = document.getElementById('wz_notes')?.value?.trim() || null;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
wz.data.isSalesItem = true;
}
if (wz.itemType === 'ai') {
if (!wz.ai.accepted || !wz.ai.result) {
document.getElementById('ai_acceptError')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('ai_acceptError')?.classList.add('d-none');
// Collect user overrides (or fall back to AI result)
const desc = document.getElementById('ai_descOverride')?.value?.trim() || wz.ai.result.description;
const sqft = parseFloat(document.getElementById('ai_sqftOverride')?.value) || wz.ai.result.surfaceAreaSqFt;
const complexity = document.getElementById('ai_complexityOverride')?.value || wz.ai.result.complexity;
const minutes = parseInt(document.getElementById('ai_minutesOverride')?.value) || wz.ai.result.estimatedMinutes;
const priceOverrideVal = parseFloat(document.getElementById('ai_priceOverride')?.value);
wz.data.description = desc;
wz.data.surfaceAreaSqFt = sqft;
wz.data.complexity = complexity;
wz.data.estimatedMinutes = minutes;
wz.data.quantity = parseInt(document.getElementById('ai_quantity')?.value) || 1;
wz.data.aiReferenceDim = document.getElementById('ai_referenceDim')?.value?.trim() || '';
wz.data.aiColor = document.getElementById('ai_color')?.value?.trim() || '';
wz.data.aiCoatCount = parseInt(document.getElementById('ai_coatCount')?.value) || 1;
wz.data.aiMaterialType = document.getElementById('ai_materialType')?.value?.trim() || '';
const _wlbs = parseFloat(document.getElementById('ai_weightLbs')?.value);
wz.data.aiWeightLbs = isNaN(_wlbs) || _wlbs <= 0 ? null : _wlbs;
wz.data.powderCostOverride = !isNaN(priceOverrideVal) ? priceOverrideVal : null;
// Store the final price: manual override → recalculated price → original AI estimate
wz.data.manualUnitPrice = !isNaN(priceOverrideVal)
? priceOverrideVal
: (wz.ai.recalcUnitPrice ?? wz.ai.result?.estimatedUnitPrice ?? 0);
wz.data.aiPhotoTempIds = [...wz.ai.tempIds];
wz.data.aiPhotoFileNames = [...wz.ai.fileNames];
wz.data.aiPhotoPreviewUrls = [...wz.ai.previewUrls];
wz.data.isAiItem = true;
wz.data.aiTags = [...wz.ai.tags];
wz.data.aiPredictionId = wz.ai.result?.aiPredictionId ?? null;
}
}
return valid;
}
function collectStep3() {
const rows = document.querySelectorAll('.coat-row');
const coats = [];
rows.forEach((row, i) => {
const isCustom = document.querySelector(`input[name="coat_type_${i}"]:checked`)?.value === 'custom';
const coat = {
coatName: (() => { const s = document.getElementById(`coat_name_sel_${i}`)?.value; return (s && s !== '__other__') ? s : (document.getElementById(`coat_name_${i}`)?.value?.trim() || `Coat ${i + 1}`); })(),
sequence: i + 1,
powderType: isCustom ? 'custom' : 'stock',
notes: document.getElementById(`coat_notes_${i}`)?.value?.trim() || null,
noExtraLayerCharge: document.getElementById(`coat_noExtraCharge_${i}`)?.checked || false,
};
if (!isCustom) {
const invId = document.getElementById(`coat_inventoryItemId_${i}`)?.value;
coat.inventoryItemId = invId ? parseInt(invId) : null;
// Resolve color name from powderData for display purposes
if (coat.inventoryItemId) {
const powder = powderData.find(p => p.value === String(invId));
if (powder) coat.colorName = powder.colorName || null;
}
coat.coverageSqFtPerLb = parseFloat(document.getElementById(`coat_coverage_${i}`)?.value) || 30;
coat.transferEfficiency = parseFloat(document.getElementById(`coat_efficiency_${i}`)?.value) || 65;
const costEl = document.getElementById(`coat_costPerLb_${i}`)?.value;
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null;
} else {
coat.colorName = document.getElementById(`coat_colorName_${i}`)?.value?.trim() || null;
coat.colorCode = document.getElementById(`coat_colorCode_${i}`)?.value?.trim() || null;
coat.finish = document.getElementById(`coat_finish_${i}`)?.value?.trim() || null;
const suppId = document.getElementById(`coat_supplierId_${i}`)?.value;
coat.supplierId = suppId ? parseInt(suppId) : null;
coat.coverageSqFtPerLb = parseFloat(document.getElementById(`coat_custom_coverage_${i}`)?.value) || 30;
coat.transferEfficiency = parseFloat(document.getElementById(`coat_custom_efficiency_${i}`)?.value) || 65;
const costEl = document.getElementById(`coat_custom_costPerLb_${i}`)?.value;
coat.powderCostPerLb = costEl ? parseFloat(costEl) : null;
}
// Powder to order: custom coats read from the user-entered field; stock coats auto-calculate
if (isCustom) {
const orderQtyVal = document.getElementById(`coat_custom_orderQty_${i}`)?.value;
coat.powderToOrder = orderQtyVal ? parseFloat(orderQtyVal) : null;
} else {
const sqft = parseFloat(wz.data.surfaceAreaSqFt) || 0;
const qty = parseInt(wz.data.quantity) || 1;
if (sqft > 0 && coat.coverageSqFtPerLb > 0) {
const eff = (coat.transferEfficiency || 65) / 100;
coat.powderToOrder = (sqft * qty) / (coat.coverageSqFtPerLb * eff);
}
}
coats.push(coat);
});
wz.data.coats = coats;
}
function preFillStep2() {
const d = wz.data;
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val !== undefined && val !== null) el.value = val;
};
set('wz_quantity', d.quantity);
set('wz_description', d.description);
set('wz_surfaceAreaSqFt', d.surfaceAreaSqFt || '');
set('wz_estimatedMinutes', d.estimatedMinutes || '');
set('wz_manualUnitPrice', d.manualUnitPrice ?? '');
set('wz_powderCostOverride', d.powderCostOverride ?? '');
set('wz_notes', d.notes || '');
if (wz.itemType === 'product' && d.catalogItemId) {
const sel = document.getElementById('wz_catalogItemId');
if (sel) sel.value = d.catalogItemId;
}
if (wz.itemType === 'calculated') {
const cplx = document.getElementById('wz_complexity');
if (cplx && d.complexity) cplx.value = d.complexity;
}
}
// ─── Build item from wizard data ──────────────────────────────────────────────
function buildItemFromWizard() {
const d = wz.data;
const isAi = wz.itemType === 'ai';
return {
description: d.description || null,
quantity: d.quantity || 1,
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
estimatedMinutes: d.estimatedMinutes || 0,
catalogItemId: d.catalogItemId || null,
manualUnitPrice: d.manualUnitPrice ?? null,
powderCostOverride: d.powderCostOverride ?? null,
isGenericItem: !!d.isGenericItem,
isLaborItem: !!d.isLaborItem,
isSalesItem: !!d.isSalesItem,
salesCatalogItemId: d.salesCatalogItemId || null,
sku: d.sku || null,
isAiItem: isAi,
requiresSandblasting: false,
requiresMasking: false,
notes: d.notes || null,
coats: d.coats || [],
prepServices: d.prepServices || [],
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
complexity: d.complexity || 'Simple',
aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [],
aiPhotoFileNames: isAi ? (d.aiPhotoFileNames || []) : [],
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
};
}
// ─── Summary cards ────────────────────────────────────────────────────────────
function renderAllCards() {
const container = document.getElementById('itemCardsContainer');
const emptyMsg = document.getElementById('itemsEmptyMessage');
if (!container) return;
if (quoteItems.length === 0) {
container.innerHTML = '';
if (emptyMsg) emptyMsg.style.display = 'block';
return;
}
if (emptyMsg) emptyMsg.style.display = 'none';
container.innerHTML = `
<div class="d-flex gap-2 px-1 mb-1" style="font-size:.7rem; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:#6c757d;">
<div class="flex-grow-1">Description</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">Qty</div>
<div class="text-end flex-shrink-0" style="min-width:80px;">Unit Price</div>
<div class="text-end flex-shrink-0" style="min-width:80px;">Total</div>
<div style="min-width:66px;"></div>
</div>` + quoteItems.map((item, i) => buildCardHtml(item, i)).join('');
}
function buildCardHtml(item, i) {
const typeInfo = item.isLaborItem ? { label: 'Labor', cls: 'info', icon: 'bi-person-gear' }
: item.isGenericItem ? { label: 'Flat-Rate', cls: 'warning', icon: 'bi-tag' }
: item.isSalesItem ? { label: 'Merchandise', cls: 'success', icon: 'bi-shop' }
: item.catalogItemId ? { label: 'Product', cls: 'primary', icon: 'bi-bag-check' }
: item.isAiItem ? { label: 'AI Quoted', cls: 'secondary', icon: 'bi-robot' }
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const coatCount = item.coats?.length || 0;
const coatBadge = (coatCount > 0)
? `<span class="badge bg-secondary ms-1">${coatCount} coat${coatCount > 1 ? 's' : ''}</span>`
: '';
const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple')
? `<span class="badge bg-secondary bg-opacity-10 text-secondary border ms-1" style="font-size:.65em;">${escHtml(item.complexity)}</span>`
: '';
const priceLine = item.isGenericItem
? `$${fmtNum(item.manualUnitPrice)} × ${item.quantity}`
: item.isLaborItem
? `${item.quantity} hr${item.quantity !== 1 ? 's' : ''}`
: item.isSalesItem
? `$${fmtNum(item.manualUnitPrice)} × ${item.quantity}${item.sku ? ` · <span class="text-muted small">${escHtml(item.sku)}</span>` : ''}`
: item.surfaceAreaSqFt
? `${item.quantity} × ${fmtNum(item.surfaceAreaSqFt)} ${pageMeta.areaUnit || 'sq ft'}`
: `Qty: ${item.quantity}`;
const coatsHtml = (item.coats || []).map(c => {
const color = c.colorName ? escHtml(c.colorName) : '';
const code = c.colorCode ? ` <span class="opacity-75">(${escHtml(c.colorCode)})</span>` : '';
const orderBadge = (!c.inventoryItemId && c.powderToOrder)
? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>`
: '';
return `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? `${color}${code}` : ''}${orderBadge}
</div>`;
}).join('');
const prepHtml = (item.prepServices || []).length > 0
? `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-tools me-1"></i>${(item.prepServices).map(ps =>
`${escHtml(ps.prepServiceName || 'Prep')}${ps.estimatedMinutes ? ` (${ps.estimatedMinutes} min)` : ''}`
).join(' · ')}
</div>`
: '';
const notesHtml = item.notes
? `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-sticky me-1"></i>${escHtml(item.notes)}
</div>`
: '';
const unitPrice = item.isGenericItem
? `$${fmtNum(item.manualUnitPrice)}`
: `<span class="card-unit-price text-muted fst-italic" style="font-size:.8rem;">—</span>`;
const totalPrice = item.isGenericItem
? `$${fmtNum((item.manualUnitPrice || 0) * (item.quantity || 1))}`
: `<span class="card-total-price text-muted fst-italic" style="font-size:.8rem;">—</span>`;
return `
<div class="quote-item-card" data-item-index="${i}">
<div class="d-flex align-items-start gap-2">
<!-- Description -->
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-${typeInfo.cls} item-badge">
<i class="bi ${typeInfo.icon} me-1"></i>${typeInfo.label}
</span>
<span class="fw-semibold">${escHtml(item.description || '(Product from catalog)')}</span>
${coatBadge}${complexityBadge}
</div>
${coatsHtml}
${prepHtml}
${notesHtml}
</div>
<!-- Qty -->
<div class="text-center flex-shrink-0" style="min-width:45px;">
<div class="text-muted" style="font-size:.7rem;">QTY</div>
<div class="fw-semibold">${item.quantity}</div>
</div>
<!-- Unit Price -->
<div class="text-end flex-shrink-0" style="min-width:80px;">
<div class="text-muted" style="font-size:.7rem;">UNIT PRICE</div>
<div class="fw-semibold">${unitPrice}</div>
</div>
<!-- Total -->
<div class="text-end flex-shrink-0" style="min-width:80px;">
<div class="text-muted" style="font-size:.7rem;">TOTAL</div>
<div class="fw-semibold">${totalPrice}</div>
</div>
<!-- Actions -->
<div class="d-flex gap-1 flex-shrink-0 ms-1">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openWizard(${i})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItem(${i})" title="Remove">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>`;
}
function removeItem(i) {
quoteItems.splice(i, 1);
writeHiddenFields();
renderAllCards();
scheduleAutoPricing();
}
// ─── Hidden field writer ──────────────────────────────────────────────────────
function writeHiddenFields() {
const container = document.getElementById('hiddenFieldsContainer');
if (!container) return;
const fields = [];
quoteItems.forEach((item, i) => {
const p = `${pageMeta.itemsFieldPrefix || 'QuoteItems'}[${i}]`;
fields.push(h(p + '.Description', item.description || ''));
fields.push(h(p + '.Quantity', item.quantity));
fields.push(h(p + '.SurfaceAreaSqFt', item.surfaceAreaSqFt || 0));
fields.push(h(p + '.EstimatedMinutes', item.estimatedMinutes || 0));
fields.push(h(p + '.IsGenericItem', item.isGenericItem ? 'true' : 'false'));
fields.push(h(p + '.IsLaborItem', item.isLaborItem ? 'true' : 'false'));
fields.push(h(p + '.IsSalesItem', item.isSalesItem ? 'true' : 'false'));
if (item.sku) fields.push(h(p + '.Sku', item.sku));
if (item.isSalesItem && item.salesCatalogItemId) fields.push(h(p + '.CatalogItemId', item.salesCatalogItemId));
fields.push(h(p + '.RequiresSandblasting', 'false'));
fields.push(h(p + '.RequiresMasking', 'false'));
if (item.catalogItemId) fields.push(h(p + '.CatalogItemId', item.catalogItemId));
if (item.manualUnitPrice != null) fields.push(h(p + '.ManualUnitPrice', item.manualUnitPrice));
if (item.powderCostOverride != null) fields.push(h(p + '.PowderCostOverride', item.powderCostOverride));
if (item.notes) fields.push(h(p + '.Notes', item.notes));
fields.push(h(p + '.IncludePrepCost', item.includePrepCost ? 'true' : 'false'));
fields.push(h(p + '.Complexity', item.complexity || 'Simple'));
if (item.isAiItem) fields.push(h(p + '.IsAiItem', 'true'));
if (item.aiTags) fields.push(h(p + '.AiTags', item.aiTags));
if (item.aiPredictionId != null) fields.push(h(p + '.AiPredictionId', item.aiPredictionId));
(item.prepServices || []).forEach((ps, pi) => {
const pp = `${p}.PrepServices[${pi}]`;
fields.push(h(pp + '.PrepServiceId', ps.prepServiceId));
fields.push(h(pp + '.EstimatedMinutes', ps.estimatedMinutes || 0));
});
(item.coats || []).forEach((coat, ci) => {
const cp = `${p}.Coats[${ci}]`;
fields.push(h(cp + '.CoatName', coat.coatName || `Coat ${ci + 1}`));
fields.push(h(cp + '.Sequence', coat.sequence || (ci + 1)));
fields.push(h(cp + '.CoverageSqFtPerLb', coat.coverageSqFtPerLb || 30));
fields.push(h(cp + '.TransferEfficiency', coat.transferEfficiency || 65));
if (coat.inventoryItemId) fields.push(h(cp + '.InventoryItemId', coat.inventoryItemId));
if (coat.colorName) fields.push(h(cp + '.ColorName', coat.colorName));
if (coat.colorCode) fields.push(h(cp + '.ColorCode', coat.colorCode));
if (coat.finish) fields.push(h(cp + '.Finish', coat.finish));
if (coat.supplierId) fields.push(h(cp + '.VendorId', coat.supplierId));
if (coat.powderCostPerLb != null) fields.push(h(cp + '.PowderCostPerLb', coat.powderCostPerLb));
if (coat.powderToOrder) fields.push(h(cp + '.PowderToOrder', coat.powderToOrder));
if (coat.notes) fields.push(h(cp + '.Notes', coat.notes));
fields.push(h(cp + '.NoExtraLayerCharge', coat.noExtraLayerCharge ? 'true' : 'false'));
});
});
container.innerHTML = fields.join('');
// Write all AI photo tempIds as top-level form fields for photo promotion on save
const aiContainer = document.getElementById('aiPhotoTempIdsContainer');
if (aiContainer) {
const allTempIds = quoteItems.flatMap(item => item.aiPhotoTempIds || []);
aiContainer.innerHTML = allTempIds.map((tid, i) => h(`AiPhotoTempIds[${i}]`, tid)).join('');
}
}
// Build a hidden input element string
function h(name, value) {
return `<input type="hidden" name="${escAttr(name)}" value="${escAttr(String(value))}">`;
}
// ─── Auto-pricing ─────────────────────────────────────────────────────────────
let _autoPricingTimer = null;
function scheduleAutoPricing() {
clearTimeout(_autoPricingTimer);
_autoPricingTimer = setTimeout(runAutoPricing, 600);
}
async function runAutoPricing() {
if (quoteItems.length === 0) {
resetPricingDisplay();
return;
}
const spinner = document.getElementById('pricingSpinner');
if (spinner) spinner.classList.remove('d-none');
try {
// Collect current form meta
const customerId = parseInt(document.querySelector('[name="CustomerId"]')?.value) || null;
const taxPercent = parseFloat(document.querySelector('[name="TaxPercent"]')?.value) || pageMeta.taxPercent || 0;
const discountType = document.getElementById('discountTypeSelect')?.value || 'None';
const discountVal = parseFloat(document.getElementById('discountValueInput')?.value) || 0;
const isRushJob = document.getElementById('IsRushJob')?.checked || false;
const ovenCostId = parseInt(document.getElementById('OvenCostId')?.value) || null;
const ovenBatches = parseInt(document.getElementById('OvenBatches')?.value) || 1;
const ovenCycleMinutes = parseInt(document.getElementById('OvenCycleMinutes')?.value) || null;
const payload = {
items: quoteItems,
customerId,
taxPercent,
discountType,
discountValue: discountVal,
isRushJob,
ovenCostId,
ovenBatches,
ovenCycleMinutes
};
const url = pageMeta.pricingUrl || '/Quotes/CalculatePricing';
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
if (resp.ok) {
const result = await resp.json();
updatePricingDisplay(result);
updateCardPrices(result.itemResults || []);
}
} catch (err) {
console.warn('Auto-pricing failed:', err);
} finally {
if (spinner) spinner.classList.add('d-none');
}
}
function updatePricingDisplay(r) {
const show = (id, visible) => document.getElementById(id)?.classList.toggle('d-none', !visible);
const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
document.getElementById('pricingPlaceholder')?.classList.add('d-none');
show('itemsSubtotalRow', true);
setText('itemsSubtotalDisplay', '$' + fmtNum(r.itemsSubtotal));
const hasOvenCost = r.ovenBatchCost > 0;
show('ovenBatchCostRow', hasOvenCost);
if (hasOvenCost) {
setText('ovenBatchesDisplay', r.ovenBatches);
setText('ovenCycleMinDisplay', r.ovenCycleMinutes);
setText('ovenBatchCostDisplay', '$' + fmtNum(r.ovenBatchCost));
}
const hasTierDiscount = r.pricingTierDiscountAmount > 0;
show('pricingTierDiscountRow', hasTierDiscount);
if (hasTierDiscount) {
setText('pricingTierDiscountPercentDisplay', fmtNum(r.pricingTierDiscountPercent));
setText('pricingTierDiscountDisplay', '-$' + fmtNum(r.pricingTierDiscountAmount));
}
const hasQuoteDiscount = r.quoteDiscountAmount > 0;
show('quoteDiscountRow', hasQuoteDiscount);
if (hasQuoteDiscount) {
setText('quoteDiscountPercentDisplay', fmtNum(r.quoteDiscountPercent));
setText('quoteDiscountDisplay', '-$' + fmtNum(r.quoteDiscountAmount));
}
show('rushFeeRow', r.rushFee > 0);
setText('rushFeeDisplay', '$' + fmtNum(r.rushFee));
const hasShopSupplies = r.shopSuppliesAmount > 0;
show('shopSuppliesRow', hasShopSupplies);
if (hasShopSupplies) {
setText('shopSuppliesPercentDisplay', fmtNum(r.shopSuppliesPercent));
setText('shopSuppliesDisplay', '$' + fmtNum(r.shopSuppliesAmount));
}
show('subtotalRow', true);
setText('subtotalDisplay', '$' + fmtNum(r.subtotalAfterDiscount || r.subtotalBeforeDiscount));
show('taxRow', r.taxAmount > 0);
setText('taxPercentDisplay', fmtNum(r.taxPercent));
setText('taxDisplay', '$' + fmtNum(r.taxAmount));
document.getElementById('pricingDivider')?.classList.remove('d-none');
show('totalRow', true);
setText('totalDisplay', '$' + fmtNum(r.total));
}
function updateCardPrices(itemResults) {
itemResults.forEach((r, i) => {
const card = document.querySelector(`#itemCardsContainer [data-item-index="${i}"]`);
if (!card) return;
const priceEl = card.querySelector('.card-unit-price');
if (priceEl) priceEl.textContent = '$' + fmtNum(r.unitPrice);
const totalEl = card.querySelector('.card-total-price');
if (totalEl) totalEl.textContent = '$' + fmtNum(r.totalPrice);
});
}
function resetPricingDisplay() {
const hide = id => document.getElementById(id)?.classList.add('d-none');
document.getElementById('pricingPlaceholder')?.classList.remove('d-none');
['itemsSubtotalRow','ovenBatchCostRow','pricingTierDiscountRow','quoteDiscountRow','rushFeeRow',
'shopSuppliesRow','subtotalRow','taxRow','pricingDivider','totalRow'].forEach(hide);
}
// ─── Wizard UI helpers ────────────────────────────────────────────────────────
function updateWizardTitle() {
const titleEl = document.getElementById('wizardTitle');
if (titleEl) titleEl.textContent = wz.editIndex >= 0 ? 'Edit Item' : 'Add Item';
}
function updateWizardButtons() {
const btnBack = document.getElementById('btnWizardBack');
const btnNext = document.getElementById('btnWizardNext');
const btnSave = document.getElementById('btnWizardSave');
if (!btnBack || !btnNext || !btnSave) return;
const hasPrepServices = prepServiceData.length > 0;
const isLast = wz.step === 4
|| (wz.step === 3 && !hasPrepServices)
|| (wz.step === 2 && (wz.itemType === 'generic' || wz.itemType === 'labor' || wz.itemType === 'sales'));
btnBack.classList.toggle('d-none', wz.step === 1);
btnNext.classList.toggle('d-none', isLast);
btnSave.classList.toggle('d-none', !isLast);
if (isLast) btnSave.textContent = wz.editIndex >= 0 ? '✓ Update Item' : '✓ Add Item';
}
function updateStepDots() {
const dots = document.querySelectorAll('.wizard-step-dot');
const step3Dot = document.getElementById('step3Dot');
const step4Dot = document.getElementById('step4Dot');
const step2Line = document.getElementById('step2Line');
const step3Line = document.getElementById('step3Line');
const hasCoats = wz.itemType !== 'generic' && wz.itemType !== 'labor' && wz.itemType !== 'sales'; // product, calculated, and ai all get coats
const hasPrepServices = prepServiceData.length > 0;
const showStep4 = hasCoats && hasPrepServices; // product, calculated, and ai get step 4
if (step3Dot) step3Dot.classList.toggle('skip', !hasCoats);
if (step4Dot) step4Dot.classList.toggle('skip', !showStep4);
if (step2Line) step2Line.style.opacity = hasCoats ? '1' : '0.3';
if (step3Line) step3Line.style.opacity = showStep4 ? '1' : '0.3';
dots.forEach(dot => {
const s = parseInt(dot.dataset.step);
dot.classList.remove('active', 'done');
if (s === wz.step) dot.classList.add('active');
else if (s < wz.step) dot.classList.add('done');
});
const totalSteps = hasCoats ? (hasPrepServices ? 4 : 3) : 2;
const currentDisplay = Math.min(wz.step, totalSteps);
const label = document.getElementById('wizardStepLabel');
if (label) label.textContent = `Step ${currentDisplay} of ${totalSteps}`;
const stepTitles = {
1: 'Choose Item Type',
2: 'Tell Me About The Item',
3: 'Setup Coating Layers',
4: 'Select Preparation Services Needed'
};
const titleEl = document.getElementById('wizardStepTitle');
if (titleEl) titleEl.textContent = stepTitles[wz.step] || '';
}
// ─── Inline Area Calculator ───────────────────────────────────────────────────
function wzToggleCalc() {
const panel = document.getElementById('wz_calcPanel');
if (!panel) return;
const opening = panel.classList.toggle('d-none') === false; // true when now visible
if (opening) wzCalcCompute();
}
function wzCalcShape() {
const shape = document.getElementById('wz_calcShape')?.value || 'rectangle';
document.getElementById('wz_calcRect')?.classList.toggle('d-none', shape !== 'rectangle');
document.getElementById('wz_calcCyl')?.classList.toggle('d-none', shape !== 'cylinder');
document.getElementById('wz_calcCirc')?.classList.toggle('d-none', shape !== 'circle');
wzCalcCompute();
}
function wzCalcCompute() {
const shape = document.getElementById('wz_calcShape')?.value || 'rectangle';
const isMetric = (pageMeta.areaUnit || 'sq ft').toLowerCase().includes('m');
let area = 0;
if (shape === 'rectangle') {
const l = parseFloat(document.getElementById('wz_calcL')?.value) || 0;
const w = parseFloat(document.getElementById('wz_calcW')?.value) || 0;
area = l * w;
} else if (shape === 'cylinder') {
const d = parseFloat(document.getElementById('wz_calcD')?.value) || 0;
const h = parseFloat(document.getElementById('wz_calcH')?.value) || 0;
const r = d / 2;
area = 2 * Math.PI * r * r + 2 * Math.PI * r * h;
} else if (shape === 'circle') {
const d = parseFloat(document.getElementById('wz_calcCD')?.value) || 0;
const r = d / 2;
area = Math.PI * r * r;
}
// sq inches → sq ft / sq cm → sq m
const result = isMetric ? area / 10000 : area / 144;
const el = document.getElementById('wz_calcResult');
if (el) el.textContent = result.toFixed(3);
return result;
}
function wzCalcApply() {
const result = wzCalcCompute();
const input = document.getElementById('wz_surfaceAreaSqFt');
if (input) {
input.value = result.toFixed(3);
input.dispatchEvent(new Event('input'));
}
document.getElementById('wz_calcPanel')?.classList.add('d-none');
}
// ─── Utilities ────────────────────────────────────────────────────────────────
// Scroll an element into view, respecting modal-dialog-scrollable containers
function wzScrollIntoView(el) {
const modalBody = el.closest('.modal-body');
if (modalBody) {
// Use setTimeout to let the browser finish laying out the newly-visible element
setTimeout(() => modalBody.scrollTop = modalBody.scrollHeight, 0);
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escAttr(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
}
function fmtNum(n) {
return (parseFloat(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ─── Template loading ─────────────────────────────────────────────────────────
/**
* Pre-populates the item list from a job template.
* Called from the Jobs/Create view when a templateId is passed in the URL.
*/
function loadItemsFromTemplate(templateItems) {
if (!templateItems || !templateItems.length) return;
quoteItems = templateItems.map(ti => ({
description: ti.description || null,
quantity: ti.quantity || 1,
surfaceAreaSqFt: ti.surfaceAreaSqFt || 0,
estimatedMinutes: ti.estimatedMinutes || 0,
catalogItemId: ti.catalogItemId || null,
manualUnitPrice: ti.manualUnitPrice ?? null,
powderCostOverride: null,
isGenericItem: !!ti.isGenericItem,
isLaborItem: !!ti.isLaborItem,
isAiItem: false,
requiresSandblasting: !!ti.requiresSandblasting,
requiresMasking: !!ti.requiresMasking,
notes: null,
complexity: ti.complexity || 'Simple',
includePrepCost: ti.includePrepCost ?? true,
aiPhotoTempIds: [],
aiPhotoFileNames: [],
aiTags: null,
aiPredictionId: null,
coats: (ti.coats || []).map(c => ({
coatName: c.coatName || 'Coat',
sequence: c.sequence || 1,
powderType: c.inventoryItemId ? 'stock' : 'custom',
inventoryItemId: c.inventoryItemId || null,
colorName: c.colorName || null,
colorCode: c.colorCode || null,
finish: c.finish || null,
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
transferEfficiency: c.transferEfficiency || 65,
powderCostPerLb: c.powderCostPerLb || null,
noExtraLayerCharge: false
})),
prepServices: (ti.prepServices || []).map(p => ({
prepServiceId: p.prepServiceId,
prepServiceName: p.prepServiceName || '',
estimatedMinutes: p.estimatedMinutes || 0
}))
}));
writeHiddenFields();
renderAllCards();
scheduleAutoPricing();
}