Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/item-wizard.js
T
spouliot 00bf8a4cd0 Add catalog item images with thumbnail preview in wizard
Each catalog item now supports one optional image (jpg/jpeg/png/gif/webp,
max 10 MB). Uploading generates a 200x200 JPEG thumbnail automatically via
SixLabors.ImageSharp. Images are stored in Azure Blob Storage under a new
catalogimages container, keyed by {companyId}/catalog/{itemId}/.

- CatalogItem entity: ImagePath + ThumbnailPath (nullable string fields)
- Migration: AddCatalogItemImages applied
- ICatalogImageService / CatalogImageService: upload, thumbnail generation,
  delete; old blobs replaced atomically on re-upload
- CatalogItemsController: Create/Edit accept optional IFormFile image;
  Image(id, thumbnail) action serves blobs with [Authorize] so wizard users
  can load thumbnails without CanManageProducts policy
- Catalog index (_CategoryNode): 40x40 thumbnail (or placeholder icon)
  left of each item name
- Details view: image card in right column with click-to-full-size link
- Create/Edit views: file picker with live preview; Edit shows current
  thumbnail with Remove checkbox
- Wizard (item-wizard.js): thumbnails in product list with hover preview
  that follows the cursor (showCatalogPreview / moveCatalogPreview);
  fixed Bootstrap d-flex !important bug that broke the filter box by
  moving flex layout to an inner wrapper div

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:33:59 -04:00

3006 lines
144 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 = [];
let blastSetupData = [];
document.addEventListener('DOMContentLoaded', () => {
const metaEl = document.getElementById('quoteMetaData');
if (metaEl) pageMeta = JSON.parse(metaEl.textContent);
const powderEl = document.getElementById('inventoryPowdersData');
if (powderEl) powderData = JSON.parse(powderEl.textContent);
const catalogEl = document.getElementById('catalogItemsData');
if (catalogEl) catalogData = JSON.parse(catalogEl.textContent);
const merchEl = document.getElementById('merchandiseItemsData');
if (merchEl) merchandiseData = JSON.parse(merchEl.textContent);
const supplierEl = document.getElementById('vendorsData');
if (supplierEl) supplierData = JSON.parse(supplierEl.textContent);
const prepEl = document.getElementById('prepServicesData');
if (prepEl) prepServiceData = JSON.parse(prepEl.textContent);
const blastEl = document.getElementById('blastSetupsData');
if (blastEl) blastSetupData = JSON.parse(blastEl.textContent);
// Restore items from server round-trip (validation failure re-render)
const existingEl = document.getElementById('existingItemsData');
if (existingEl) {
quoteItems = JSON.parse(existingEl.textContent);
renderAllCards();
writeHiddenFields();
window.scrollTo({ top: 0, behavior: 'instant' });
scheduleAutoPricing();
}
// Close any open powder combobox dropdown when clicking outside it
document.addEventListener('click', e => {
document.querySelectorAll('[id^="coat_powder_wrapper_"]').forEach(wrapper => {
if (!wrapper.contains(e.target)) {
const idx = wrapper.id.replace('coat_powder_wrapper_', '');
powderComboClose(parseInt(idx));
}
});
});
});
// ─── Wizard open / close ──────────────────────────────────────────────────────
function openWizard(editIndex = -1) {
wz.editIndex = editIndex;
wz.step = 1;
wz.data = {};
wz.ai = { phase: 'upload', tempIds: [], fileNames: [], previewUrls: [], result: null, conversationHistory: [], accepted: false, tags: [], followUpQuestion: null };
if (editIndex >= 0) {
const item = quoteItems[editIndex];
wz.itemType = item.isLaborItem ? 'labor'
: item.isSalesItem ? 'sales'
: item.isGenericItem ? 'generic'
: item.catalogItemId ? 'product'
: item.isAiItem ? 'ai'
: 'calculated';
// Pre-fill wizard data from existing item
wz.data = JSON.parse(JSON.stringify(item)); // deep copy
// Restore sales item catalog reference
if (wz.itemType === 'sales') {
wz.data.salesCatalogItemId = item.catalogItemId || null;
}
// Restore AI state if editing an AI item
if (wz.itemType === 'ai') {
wz.ai.tempIds = item.aiPhotoTempIds || [];
wz.ai.fileNames = item.aiPhotoFileNames || [];
wz.ai.previewUrls = item.aiPhotoPreviewUrls || [];
wz.ai.tags = item.aiTags ? item.aiTags.split(',').filter(Boolean) : [];
wz.ai.accepted = true;
wz.ai.phase = 'result';
// Reconstruct a synthetic result from saved values so the item can be saved
// without forcing another round-trip to the AI engine. confidence=null signals
// the card to render as "Saved Estimate" instead of "AI Estimate Ready".
wz.ai.result = {
description: item.description || '',
surfaceAreaSqFt: item.surfaceAreaSqFt || 0,
complexity: item.complexity || 'Moderate',
estimatedMinutes: item.estimatedMinutes || 30,
estimatedUnitPrice: item.manualUnitPrice || 0,
estimatedTotal: (item.manualUnitPrice || 0) * (item.quantity || 1),
confidence: null, // unknown on edit — renders "Saved Estimate" header
aiReasoning: null,
benchmark: null,
breakdown: null, // aiRecalcPrice returns early; user can override price manually
tags: item.aiTags ? item.aiTags.split(',').filter(Boolean) : [],
aiPredictionId: item.aiPredictionId || null
};
}
} else {
wz.itemType = null;
}
updateWizardTitle();
renderStep(1);
updateWizardButtons();
const modal = new bootstrap.Modal(document.getElementById('itemWizardModal'));
modal.show();
}
function closeWizard() {
bootstrap.Modal.getInstance(document.getElementById('itemWizardModal'))?.hide();
}
// ─── Step navigation ──────────────────────────────────────────────────────────
// Guard against Android ghost-clicks: after a touch tap the browser fires a
// synthetic click ~300ms later at the same screen coordinates. By then the
// Next button has been replaced by Save, so the ghost click saves the item
// before the user sees step 4. Disabling for 400ms covers the ghost-click window.
let _wizardNavBusy = false;
function _guardNav() {
if (_wizardNavBusy) return false;
_wizardNavBusy = true;
setTimeout(() => { _wizardNavBusy = false; }, 400);
return true;
}
function wizardNext() {
if (!_guardNav()) return;
if (!collectCurrentStep()) return; // validation failed
if (wz.step === 1) {
wz.step = 2;
} else if (wz.step === 2) {
// Generic / labor / sales: step 2 is the last step
if (wz.itemType === 'generic' || wz.itemType === 'labor' || wz.itemType === 'sales') {
wizardSave();
return;
}
// AI: step 2 must be accepted before proceeding
if (wz.itemType === 'ai') {
if (!wz.ai.accepted) {
document.getElementById('ai_acceptError')?.classList.remove('d-none');
return; // don't advance
}
}
wz.step = 3;
} else if (wz.step === 3) {
// Step 4 (prep services) for product, calculated, and ai items — always shown
// so users with no prep services configured see the empty-state prompt
wz.step = 4;
} else if (wz.step === 4) {
// Calculated and AI items offer a Save-to-Catalog step before finalising
if (wz.itemType === 'calculated' || wz.itemType === 'ai') {
wz.step = 5;
} else {
wizardSave();
return;
}
} else if (wz.step === 5) {
// Step 5 actions are handled by saveToCatalogFromWizard() / skipCatalogSave()
return;
}
renderStep(wz.step);
updateWizardButtons();
updateStepDots();
}
function wizardBack() {
if (!_guardNav()) return;
if (wz.step === 5) wz.step = 4;
else if (wz.step === 4) wz.step = 3;
else if (wz.step === 3) wz.step = 2;
else if (wz.step === 2) wz.step = 1;
renderStep(wz.step);
updateWizardButtons();
updateStepDots();
}
function wizardSave() {
if (!_guardNav()) return;
if (!collectCurrentStep()) return;
const item = buildItemFromWizard();
if (wz.editIndex >= 0) {
quoteItems[wz.editIndex] = item;
} else {
quoteItems.push(item);
}
writeHiddenFields();
renderAllCards();
scheduleAutoPricing();
closeWizard();
}
// ─── Step renderers ───────────────────────────────────────────────────────────
function renderStep(step) {
wz.step = step;
const body = document.getElementById('wizardBody');
if (step === 1) body.innerHTML = renderStep1Html();
else if (step === 2) body.innerHTML = renderStep2Html();
else if (step === 3) body.innerHTML = renderStep3Html();
else if (step === 4) body.innerHTML = renderStep4Html();
else if (step === 5) {
body.innerHTML = renderStep5Html();
loadCatalogCategoriesForWizard();
if (wz.itemType === 'calculated') prefillCatalogPriceFromCalc();
}
// Re-select the previously chosen type card
if (step === 1 && wz.itemType) {
document.querySelectorAll('.item-type-card').forEach(c => {
c.classList.toggle('selected', c.dataset.type === wz.itemType);
});
}
// Pre-fill step 2 fields from wz.data
if (step === 2) preFillStep2();
// Pre-fill step 3 coats
if (step === 3) renderCoatsList();
}
// Step 1: Type picker
function renderStep1Html() {
const types = [
{
type: 'product',
icon: 'bi-bag-check',
label: 'Product from Catalog',
desc: 'Pick a pre-defined product. Dimensions and default pricing are already set up.'
},
{
type: 'calculated',
icon: 'bi-rulers',
label: 'Custom / Measured Item',
desc: 'Enter exact dimensions and coating time. Price is fully calculated by the engine.'
},
{
type: 'generic',
icon: 'bi-tag',
label: 'Flat-Rate Charge',
desc: 'Setup fees, handling, touch-ups — any charge with a fixed unit price.'
},
{
type: 'labor',
icon: 'bi-person-gear',
label: 'Labor Only',
desc: 'Billable hours at the shop labor rate. No coating required.'
},
{
type: 'ai',
icon: 'bi-robot',
label: 'AI Photo Quote',
desc: 'Upload a photo — our AI will analyze the item and estimate surface area, complexity, and price.'
},
{
type: 'sales',
icon: 'bi-shop',
label: 'Retail / Merchandise',
desc: 'Off-the-shelf items — T-shirts, tumblers, apparel, or any product sold at a fixed price.'
}
].filter(t => t.type !== 'ai' || pageMeta.aiPhotoQuotesEnabled !== false);
return `<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');
updateStepDots();
// Brief pause so the selected state is visible before advancing
setTimeout(wizardNext, 180);
}
// Step 2: Item-type-specific fields
function renderStep2Html() {
if (wz.itemType === 'product') return renderProductFields();
if (wz.itemType === 'calculated') return renderCalculatedFields();
if (wz.itemType === 'generic') return renderGenericFields();
if (wz.itemType === 'labor') return renderLaborFields();
if (wz.itemType === 'ai') return renderAiPhotoFields();
if (wz.itemType === 'sales') return renderSalesFields();
return '<p class="text-danger">Unknown item type.</p>';
}
function renderProductFields() {
ensureCatalogPreviewEl();
const catalogItems = catalogData.map(c => {
const thumbHtml = c.thumbnailPath
? `<img src="/CatalogItems/Image?id=${c.value}&thumbnail=true" alt=""
style="width:36px;height:36px;object-fit:cover;border-radius:4px;flex-shrink:0;cursor:zoom-in;"
onmouseenter="showCatalogPreview(event,'/CatalogItems/Image?id=${c.value}&thumbnail=true')"
onmousemove="moveCatalogPreview(event)"
onmouseleave="hideCatalogPreview()" />`
: `<span style="width:36px;height:36px;background:#f0f0f0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;"><i class='bi bi-image text-muted' style='font-size:.85rem;'></i></span>`;
// Inner div carries the flex layout — the outer catalog-list-item div must stay a plain block element
// so filterCatalog() can set el.style.display='none' without Bootstrap d-flex !important overriding it.
return `<div class="catalog-list-item px-2 py-2" data-value="${c.value}" onclick="pickCatalogItem(this)"><div style="display:flex;align-items:center;gap:0.5rem;">${thumbHtml}<span>${escHtml(c.text)}</span></div></div>`;
}).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()" autocomplete="off">
<label class="form-label fw-semibold">Product <span class="text-danger">*</span></label>
<input type="hidden" id="wz_catalogItemId">
<div class="border rounded" id="catalogListbox" style="max-height:220px;overflow-y:auto;">
${catalogItems || '<div class="px-3 py-2 text-muted small">No catalog items found.</div>'}
</div>
<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();
document.querySelectorAll('#catalogListbox .catalog-list-item').forEach(el => {
el.style.display = (q && !el.textContent.toLowerCase().includes(q)) ? 'none' : '';
});
}
function pickCatalogItem(el) {
document.querySelectorAll('#catalogListbox .catalog-list-item').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('wz_catalogItemId').value = el.dataset.value;
document.getElementById('err_catalogItemId')?.classList.add('d-none');
}
// ── Catalog thumbnail hover preview ──────────────────────────────────────────
function ensureCatalogPreviewEl() {
if (document.getElementById('catalogThumbPreview')) return;
const el = document.createElement('div');
el.id = 'catalogThumbPreview';
el.style.cssText = 'position:fixed;display:none;z-index:9999;pointer-events:none;' +
'border:1px solid #dee2e6;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.18);' +
'background:#fff;padding:4px;';
el.innerHTML = '<img id="catalogThumbPreviewImg" style="display:block;width:200px;height:200px;object-fit:contain;border-radius:4px;" />';
document.body.appendChild(el);
}
function showCatalogPreview(event, url) {
const preview = document.getElementById('catalogThumbPreview');
const img = document.getElementById('catalogThumbPreviewImg');
if (!preview || !img) return;
img.src = url;
_placeCatalogPreview(event, preview);
preview.style.display = 'block';
}
function moveCatalogPreview(event) {
const preview = document.getElementById('catalogThumbPreview');
if (preview && preview.style.display !== 'none') _placeCatalogPreview(event, preview);
}
function hideCatalogPreview() {
const preview = document.getElementById('catalogThumbPreview');
if (preview) preview.style.display = 'none';
}
function _placeCatalogPreview(event, preview) {
const pad = 16, pw = 216, ph = 216;
let x = event.clientX + pad;
let y = event.clientY - ph / 2;
if (x + pw > window.innerWidth) x = event.clientX - pw - pad;
if (y < 8) y = 8;
if (y + ph > window.innerHeight) y = window.innerHeight - ph - 8;
preview.style.left = x + 'px';
preview.style.top = y + 'px';
}
function renderCalculatedFields() {
const areaUnit = pageMeta.areaUnit || 'sq ft';
return `
<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</option>
<option value="Moderate">Moderate</option>
<option value="Complex">Complex</option>
<option value="Extreme">Extreme</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>
<!-- Blast Setup Selector (shown only when 2+ setups defined) -->
${blastSetupData.length > 1 ? `
<div class="mb-3">
<label for="ai_blastSetupId" class="form-label fw-medium">
<i class="bi bi-wind me-1"></i>Blast Setup
<small class="text-muted fw-normal ms-1">— which rig will be used?</small>
</label>
<select class="form-select" id="ai_blastSetupId">
${blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')}
</select>
</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 when the user changes sqft, minutes, or complexity.
// Debounced — calls the server so no pricing rates are exposed client-side.
let _recalcDebounce = null;
function aiRecalcPrice() {
clearTimeout(_recalcDebounce);
_recalcDebounce = setTimeout(_aiRecalcPriceAsync, 300);
}
async function _aiRecalcPriceAsync() {
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 recalcUrl = pageMeta.aiRecalcUrl || '/Quotes/AiRecalcPrice';
const csrf = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
try {
const resp = await fetch(recalcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': csrf },
body: JSON.stringify({ surfaceAreaSqFt: sqft, estimatedMinutes: minutes, complexity, coatCount })
});
const result = await resp.json();
if (!result.success) return;
const unitPrice = result.unitPrice;
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 when no manual override is entered
wz.ai.recalcUnitPrice = unitPrice;
} catch (_) {
// Silent fail — user can still enter a manual price override
}
}
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' : ''})</td>
<td class="text-end">${fmt(b.materialCost)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Consumables</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)${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) <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)</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}</td>
<td class="text-end">${fmt(b.complexityCharge)}</td>
</tr>
<tr>
<td class="px-3 text-muted">Markup</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 blastSetupIdEl = document.getElementById('ai_blastSetupId');
const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null;
const payload = {
photoTempIds: wz.ai.tempIds,
referenceDimension: refDim,
materialType: materialType || null,
estimatedWeightLbs: weightLbs,
quantity: qty,
desiredColor: color,
coatCount: coats,
conversationHistory: wz.ai.conversationHistory,
followUpAnswer: null,
blastSetupId: blastSetupId
};
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
try {
const resp = await fetch(analyzeUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
if (!resp.ok) {
throw new Error(`Server returned ${resp.status} ${resp.statusText}`);
}
const result = await resp.json();
aiHandleResult(result);
} catch (err) {
console.error('AI analyze error:', err);
aiSetLoading(false);
aiShowError('Error: ' + err.message);
}
}
async function aiSendFollowup() {
const answer = document.getElementById('ai_followupAnswer')?.value?.trim();
if (!answer) return;
aiSetLoading(true);
document.getElementById('ai_followupSection')?.classList.add('d-none');
const qty = parseInt(document.getElementById('ai_quantity')?.value) || 1;
const color = document.getElementById('ai_color')?.value?.trim() || '';
const coats = parseInt(document.getElementById('ai_coatCount')?.value) || 1;
const ref = document.getElementById('ai_referenceDim')?.value?.trim() || '';
const materialType = document.getElementById('ai_materialType')?.value?.trim() || '';
const weightLbsRaw = parseFloat(document.getElementById('ai_weightLbs')?.value);
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
wz.data.quantity = qty; // persist before renderStep re-renders
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null;
const payload = {
photoTempIds: wz.ai.tempIds,
referenceDimension: ref,
materialType: materialType || null,
estimatedWeightLbs: weightLbs,
quantity: qty,
desiredColor: color,
coatCount: coats,
conversationHistory: wz.ai.conversationHistory,
followUpAnswer: answer,
blastSetupId: blastSetupId2
};
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
try {
const resp = await fetch(analyzeUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
},
body: JSON.stringify(payload)
});
if (!resp.ok) {
throw new Error(`Server returned ${resp.status} ${resp.statusText}`);
}
const result = await resp.json();
aiHandleResult(result);
} catch (err) {
console.error('AI follow-up error:', err);
aiSetLoading(false);
aiShowError('Error: ' + err.message);
}
}
function aiHandleResult(result) {
aiSetLoading(false);
console.log('AI result:', result);
if (!result.success) {
aiShowError(result.errorMessage || 'AI analysis failed. Please try again.');
return;
}
// Update conversation history for follow-up rounds
wz.ai.conversationHistory = result.conversationHistory || [];
if (result.needsFollowUp) {
// Store follow-up state and re-render step so elements are guaranteed fresh
wz.ai.phase = 'followup';
wz.ai.followUpQuestion = result.followUpQuestion || 'Can you provide more details?';
renderStep(wz.step);
document.getElementById('ai_followupAnswer')?.focus();
} else {
// Store result state and re-render step so elements are guaranteed fresh
wz.ai.result = result;
wz.ai.tags = [...(result.tags || [])];
wz.ai.accepted = true;
wz.ai.phase = 'result';
renderStep(wz.step);
document.getElementById('ai_acceptError')?.classList.add('d-none');
}
}
function aiReAnalyze() {
wz.ai.accepted = false;
wz.ai.result = null;
wz.ai.recalcUnitPrice = null;
wz.ai.conversationHistory = [];
wz.ai.tags = [];
wz.ai.followUpQuestion = null;
wz.ai.phase = 'upload';
renderStep(wz.step);
}
function aiRenderTags() {
const container = document.getElementById('ai_tagList');
if (!container) return;
container.innerHTML = wz.ai.tags.map(t =>
`<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 blastOptions = blastSetupData.length > 0
? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')
: '';
const rows = prepServiceData.map(p => {
const existing = current.find(c => c.prepServiceId === p.id);
const checked = existing ? 'checked' : '';
const minutes = existing ? existing.estimatedMinutes : '';
const blastSelId = existing?.blastSetupId || null;
const blastSelector = (p.requiresBlastSetup && blastSetupData.length > 0) ? `
<div id="prep_blast_group_${p.id}" class="${checked ? '' : 'd-none'} mt-1">
<label class="form-label form-label-sm text-muted mb-1"><i class="bi bi-wind me-1"></i>Blast Setup</label>
<select class="form-select form-select-sm" id="prep_blast_${p.id}">
${blastSetupData.map(s => `<option value="${s.id}" ${(blastSelId === s.id || (!blastSelId && s.isDefault)) ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')}
</select>
</div>` : '';
return `
<div class="py-2 border-bottom">
<div class="d-flex align-items-center gap-3">
<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>
${blastSelector}
</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}`);
const blastGroup = document.getElementById(`prep_blast_group_${id}`);
if (group) group.classList.toggle('d-none', !checked);
if (blastGroup) blastGroup.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 => {
const blastEl = document.getElementById(`prep_blast_${p.id}`);
return {
prepServiceId: p.id,
prepServiceName: p.name,
estimatedMinutes: parseInt(document.getElementById(`prep_min_${p.id}`)?.value) || 0,
blastSetupId: blastEl ? (parseInt(blastEl.value) || null) : null
};
});
}
// ─── 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;
}
if (wz.step === 5) return true; // Step 5 actions handled by saveToCatalogFromWizard / skipCatalogSave
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 listItem = document.querySelector(`#catalogListbox [data-value="${d.catalogItemId}"]`);
if (listItem) pickCatalogItem(listItem);
}
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));
if (ps.blastSetupId != null) fields.push(h(pp + '.BlastSetupId', ps.blastSetupId));
});
(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 isLast = (wz.step === 4 && wz.itemType !== 'calculated' && wz.itemType !== 'ai')
|| (wz.step === 2 && (wz.itemType === 'generic' || wz.itemType === 'labor' || wz.itemType === 'sales'));
const isCatalogStep = wz.step === 5;
btnBack.classList.toggle('d-none', wz.step === 1);
btnNext.classList.toggle('d-none', isLast || isCatalogStep);
btnSave.classList.toggle('d-none', !isLast || isCatalogStep);
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';
if (step3Dot) step3Dot.classList.toggle('skip', !hasCoats);
if (step4Dot) step4Dot.classList.toggle('skip', !hasCoats);
if (step2Line) step2Line.style.opacity = hasCoats ? '1' : '0.3';
if (step3Line) step3Line.style.opacity = hasCoats ? '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 ? 4 : 2;
const currentDisplay = Math.min(wz.step, totalSteps);
const label = document.getElementById('wizardStepLabel');
if (label) label.textContent = wz.step === 5 ? 'Final Step' : `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',
5: 'Save to Product Catalog?'
};
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' });
}
}
// ─── Save-to-Catalog step (step 5) ───────────────────────────────────────────
function renderStep5Html() {
const isAi = wz.itemType === 'ai';
const aiDesc = wz.ai.result?.description || '';
const prefillName = isAi ? aiDesc : (wz.data.description || '');
const prefillDesc = isAi ? aiDesc : '';
const isCalc = wz.itemType === 'calculated';
const prefillPrice = (wz.data.manualUnitPrice != null && wz.data.manualUnitPrice > 0)
? Number(wz.data.manualUnitPrice).toFixed(2)
: '';
const priceNote = (isAi || isCalc)
? '<div class="text-muted small mb-1">Pre-filled from your item specs — adjust if needed for catalog use.</div>'
: '<div class="text-muted small mb-1">The system will calculate the final price when the item is added — enter a standard fixed price for catalog use.</div>';
// Detect sandblasting / masking from selected prep services
const selectedPrep = wz.data.prepServices || [];
const hasSandblasting = selectedPrep.some(p => {
const ps = prepServiceData.find(s => s.id === p.prepServiceId);
return /sandblast|blast/i.test(ps?.name || '');
});
const hasMasking = selectedPrep.some(p => {
const ps = prepServiceData.find(s => s.id === p.prepServiceId);
return /mask|tap/i.test(ps?.name || '');
});
return `
<div class="text-center mb-4 pt-2">
<div class="mb-2" style="font-size:2.5rem;color:var(--bs-success);">
<i class="bi bi-bookmark-star-fill"></i>
</div>
<h6 class="fw-semibold mb-1">Save to Product Catalog?</h6>
<p class="text-muted small mb-0">
Add this item to your catalog for quick reuse on future quotes and jobs.
</p>
</div>
<div class="card border-0 bg-body-secondary rounded-3 p-3 mb-3">
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">
Catalog Item Name <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="wz_cat_name" maxlength="200"
value="${escAttr(prefillName)}" placeholder="e.g. Steel Motorcycle Frame">
<div class="text-danger small d-none mt-1" id="wz_cat_nameError">Name is required.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">
Category <span class="text-danger">*</span>
</label>
<select class="form-select" id="wz_cat_categoryId">
<option value="">Loading categories…</option>
</select>
<div class="text-danger small d-none mt-1" id="wz_cat_catError">Please select a category.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">Default Price</label>
${priceNote}
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="wz_cat_price"
min="0" step="0.01" value="${escAttr(prefillPrice)}">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">
Description <span class="text-muted fw-normal">(optional)</span>
</label>
<textarea class="form-control" id="wz_cat_description" rows="2"
placeholder="Brief description of this item">${escHtml(prefillDesc)}</textarea>
</div>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="wz_cat_sandblasting"
${hasSandblasting ? 'checked' : ''}>
<label class="form-check-label small" for="wz_cat_sandblasting">
Typically requires sandblasting
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="wz_cat_masking"
${hasMasking ? 'checked' : ''}>
<label class="form-check-label small" for="wz_cat_masking">
Typically requires masking
</label>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-secondary" onclick="skipCatalogSave()">
Skip — Add to Quote Only
</button>
<button type="button" class="btn btn-success" id="wz_cat_saveBtn"
onclick="saveToCatalogFromWizard()">
<i class="bi bi-bookmark-plus me-1"></i>Save to Catalog &amp; Add
</button>
</div>`;
}
async function loadCatalogCategoriesForWizard() {
const select = document.getElementById('wz_cat_categoryId');
if (!select) return;
try {
const resp = await fetch('/ItemWizard/GetCatalogCategories');
const cats = await resp.json();
if (!cats.length) {
select.innerHTML = '<option value="">No categories found — add one in Catalog Settings</option>';
return;
}
select.innerHTML = '<option value="">— Select Category —</option>' +
cats.map(c => `<option value="${c.id}">${escHtml(c.name)}</option>`).join('');
} catch {
select.innerHTML = '<option value="">Failed to load — please try again</option>';
}
}
/// <summary>
/// For calculated items arriving at the Save-to-Catalog step, there is no client-side price yet
/// (pricing happens server-side on form submit). This function calls the same AiRecalcPrice
/// endpoint used by the AI wizard path to fetch an estimated unit price from the item's
/// surface area, minutes, complexity, and coat count, then pre-fills the catalog price field.
/// </summary>
async function prefillCatalogPriceFromCalc() {
const priceInput = document.getElementById('wz_cat_price');
if (!priceInput) return;
const coatCount = (wz.data.coats || []).length || 1;
const recalcUrl = pageMeta.aiRecalcUrl || '/Quotes/AiRecalcPrice';
const csrf = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
priceInput.placeholder = 'Calculating…';
try {
const resp = await fetch(recalcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': csrf },
body: JSON.stringify({
surfaceAreaSqFt: wz.data.surfaceAreaSqFt || 0,
estimatedMinutes: wz.data.estimatedMinutes || 0,
complexity: wz.data.complexity || 'Moderate',
coatCount
})
});
const result = await resp.json();
if (result.success && result.unitPrice > 0) {
priceInput.value = result.unitPrice.toFixed(2);
}
} catch (_) { /* silent — user can type manually */ }
priceInput.placeholder = '0.00';
}
async function saveToCatalogFromWizard() {
const name = document.getElementById('wz_cat_name')?.value?.trim();
const catId = document.getElementById('wz_cat_categoryId')?.value;
let valid = true;
if (!name) {
document.getElementById('wz_cat_nameError')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('wz_cat_nameError')?.classList.add('d-none');
}
if (!catId) {
document.getElementById('wz_cat_catError')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('wz_cat_catError')?.classList.add('d-none');
}
if (!valid) return;
const btn = document.getElementById('wz_cat_saveBtn');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…'; }
const payload = {
name,
categoryId: parseInt(catId),
defaultPrice: parseFloat(document.getElementById('wz_cat_price')?.value) || 0,
description: document.getElementById('wz_cat_description')?.value?.trim() || null,
approximateArea: wz.data.surfaceAreaSqFt || 0,
defaultEstimatedMinutes: wz.data.estimatedMinutes || 0,
defaultRequiresSandblasting: document.getElementById('wz_cat_sandblasting')?.checked ?? false,
defaultRequiresMasking: document.getElementById('wz_cat_masking')?.checked ?? false
};
try {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
const resp = await fetch('/ItemWizard/SaveToCatalog', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': token },
body: JSON.stringify(payload)
});
const result = await resp.json();
if (result.ok) {
// Show brief success state then finalise the item as normal
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Saved!'; }
setTimeout(() => wizardSave(), 800);
} else {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-bookmark-plus me-1"></i>Save to Catalog &amp; Add'; }
alert(result.error || 'Could not save to catalog. Please try again.');
}
} catch {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-bookmark-plus me-1"></i>Save to Catalog &amp; Add'; }
alert('Network error saving to catalog. Please try again.');
}
}
function skipCatalogSave() {
wizardSave();
}
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();
}