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