00bf8a4cd0
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>
3006 lines
144 KiB
JavaScript
3006 lines
144 KiB
JavaScript
/**
|
||
* 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 & 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 & 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 & 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 & 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 & 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 & Add'; }
|
||
alert('Network error saving to catalog. Please try again.');
|
||
}
|
||
}
|
||
|
||
function skipCatalogSave() {
|
||
wizardSave();
|
||
}
|
||
|
||
function escHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function escAttr(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g,'&').replace(/"/g,'"');
|
||
}
|
||
|
||
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();
|
||
}
|