4d10175ce3
Previously the quick quote omitted the oven charge entirely, so saved quotes were under-priced relative to full quotes from the same items. Pricing: CalculatePricing now calculates ovenBatchCost = (cycleMin/60) × OvenOperatingCostPerHour using DefaultOvenCycleMinutes (fallback 50 min), then adds it to the total as a quote-level charge matching how PricingCalculationService handles oven costs. Save path: SaveQuickQuoteRequest gains OvenBatchCost + OvenCycleMinutes; the Quote record now stores OvenBatchCost, OvenCycleMinutes, and Total = ItemsSubtotal + OvenBatchCost. Display: results card shows a sub-line under the estimate price: "incl. oven 1 batch 50 min: $12.00" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
/**
|
|
* AI Quick Quote widget — floating panel for generating quick phone/walk-in estimates.
|
|
* Follows the same IIFE + sessionStorage pattern as ai-help-widget.js.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const SESSION_KEY = 'qqWidgetState';
|
|
|
|
const el = {
|
|
widget: document.getElementById('qq-widget'),
|
|
btn: document.getElementById('qq-btn'),
|
|
panel: document.getElementById('qq-panel'),
|
|
closeBtn: document.getElementById('qq-close'),
|
|
token: document.getElementById('qq-token'),
|
|
|
|
// Step 1
|
|
stepInput: document.getElementById('qq-step-input'),
|
|
description: document.getElementById('qq-description'),
|
|
qty: document.getElementById('qq-qty'),
|
|
coats: document.getElementById('qq-coats'),
|
|
analyzeBtn: document.getElementById('qq-analyze-btn'),
|
|
inputError: document.getElementById('qq-input-error'),
|
|
|
|
// Step 2
|
|
stepResults: document.getElementById('qq-step-results'),
|
|
resDesc: document.getElementById('qq-res-description'),
|
|
resSqft: document.getElementById('qq-res-sqft'),
|
|
resComplexity:document.getElementById('qq-res-complexity'),
|
|
resMinutes: document.getElementById('qq-res-minutes'),
|
|
resPrice: document.getElementById('qq-res-price'),
|
|
resOven: document.getElementById('qq-res-oven'),
|
|
resConfidence:document.getElementById('qq-res-confidence'),
|
|
resReasoning: document.getElementById('qq-res-reasoning'),
|
|
powderSection:document.getElementById('qq-powder-section'),
|
|
powderList: document.getElementById('qq-powder-list'),
|
|
reference: document.getElementById('qq-reference'),
|
|
backBtn: document.getElementById('qq-back-btn'),
|
|
saveBtn: document.getElementById('qq-save-btn'),
|
|
saveError: document.getElementById('qq-save-error'),
|
|
|
|
// Shared
|
|
loading: document.getElementById('qq-loading'),
|
|
};
|
|
|
|
if (!el.widget) return; // partial not rendered (unauthenticated)
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────
|
|
|
|
let isOpen = false;
|
|
let lastResult = null; // AiQuickQuoteResult from last successful Analyze call
|
|
|
|
function saveState() {
|
|
try {
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ isOpen }));
|
|
} catch (_) { /* private browsing */ }
|
|
}
|
|
|
|
function restoreState() {
|
|
try {
|
|
const raw = sessionStorage.getItem(SESSION_KEY);
|
|
if (!raw) return;
|
|
const state = JSON.parse(raw);
|
|
if (state.isOpen) openPanel(false);
|
|
} catch (_) { /* corrupt state */ }
|
|
}
|
|
|
|
// ── Panel open/close ─────────────────────────────────────────────────────
|
|
|
|
function openPanel(animate) {
|
|
isOpen = true;
|
|
el.panel.removeAttribute('hidden');
|
|
el.btn.setAttribute('aria-expanded', 'true');
|
|
if (animate) el.panel.style.animation = 'none'; // instant on restore
|
|
saveState();
|
|
}
|
|
|
|
function closePanel() {
|
|
isOpen = false;
|
|
el.panel.setAttribute('hidden', '');
|
|
el.btn.setAttribute('aria-expanded', 'false');
|
|
saveState();
|
|
}
|
|
|
|
el.btn.addEventListener('click', () => isOpen ? closePanel() : openPanel(true));
|
|
el.closeBtn.addEventListener('click', closePanel);
|
|
|
|
// Close on outside click
|
|
document.addEventListener('mousedown', function (e) {
|
|
if (isOpen && !el.widget.contains(e.target)) closePanel();
|
|
});
|
|
|
|
// ── Step navigation ──────────────────────────────────────────────────────
|
|
|
|
function showStep(step) {
|
|
el.stepInput.classList.toggle('d-none', step !== 'input');
|
|
el.stepResults.classList.toggle('d-none', step !== 'results');
|
|
el.loading.classList.add('d-none');
|
|
}
|
|
|
|
el.backBtn.addEventListener('click', () => {
|
|
clearErrors();
|
|
showStep('input');
|
|
});
|
|
|
|
// ── Analyze ──────────────────────────────────────────────────────────────
|
|
|
|
el.analyzeBtn.addEventListener('click', runAnalysis);
|
|
el.description.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && e.ctrlKey) runAnalysis();
|
|
});
|
|
|
|
async function runAnalysis() {
|
|
clearErrors();
|
|
|
|
const description = el.description.value.trim();
|
|
if (!description) {
|
|
showInputError('Please describe what the customer needs.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await post('/AiQuickQuote/Analyze', {
|
|
description,
|
|
quantity: parseInt(el.qty.value, 10) || 1,
|
|
coatCount: parseInt(el.coats.value, 10) || 1
|
|
});
|
|
|
|
if (!response.success) {
|
|
showInputError(response.errorMessage || 'Analysis failed. Please try again.');
|
|
return;
|
|
}
|
|
|
|
lastResult = response;
|
|
populateResults(response);
|
|
showStep('results');
|
|
|
|
} catch (err) {
|
|
showInputError('Could not reach the server. Please try again.');
|
|
console.error('[QuickQuote] Analyze error:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function populateResults(r) {
|
|
el.resDesc.textContent = r.description || '—';
|
|
el.resSqft.textContent = r.surfaceAreaSqFt ? r.surfaceAreaSqFt.toFixed(1) + ' sqft' : '—';
|
|
el.resComplexity.textContent = r.complexity || '—';
|
|
el.resMinutes.textContent = r.estimatedMinutes ? r.estimatedMinutes + ' min' : '—';
|
|
el.resPrice.textContent = formatCurrency(r.estimatedTotal || r.estimatedUnitPrice);
|
|
|
|
const ovenCost = r.breakdown?.ovenCost;
|
|
const ovenMin = r.breakdown?.ovenCycleMinutes;
|
|
if (ovenCost && ovenCost > 0) {
|
|
el.resOven.textContent = `incl. oven 1 batch ${ovenMin ? ovenMin + ' min' : ''}: ${formatCurrency(ovenCost)}`;
|
|
} else {
|
|
el.resOven.textContent = '';
|
|
}
|
|
|
|
el.resReasoning.textContent = r.reasoning || '';
|
|
|
|
// Confidence badge
|
|
const conf = (r.confidence || 'Medium').toLowerCase();
|
|
el.resConfidence.textContent = r.confidence || 'Medium';
|
|
el.resConfidence.className = 'badge ' + (
|
|
conf === 'high' ? 'bg-success' :
|
|
conf === 'medium' ? 'bg-warning text-dark' :
|
|
'bg-danger'
|
|
);
|
|
|
|
// Powder stock
|
|
if (r.powderMatches && r.powderMatches.length > 0) {
|
|
el.powderList.innerHTML = r.powderMatches.map(buildPowderBadge).join('');
|
|
el.powderSection.classList.remove('d-none');
|
|
} else {
|
|
el.powderSection.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
function buildPowderBadge(match) {
|
|
if (match.hasInventoryMatch) {
|
|
const icon = match.isInStock ? '✓' : '✗';
|
|
const cls = match.isInStock ? 'text-success border-success' : 'text-danger border-danger';
|
|
const label = match.isInStock
|
|
? `In stock — ${match.quantityOnHand.toFixed(1)} lbs`
|
|
: 'Not in stock';
|
|
const name = match.inventoryItemName || match.detectedColorName;
|
|
return `<span class="qq-powder-badge ${cls}" title="${escHtml(label)}">
|
|
${icon} ${escHtml(name)}
|
|
<small class="opacity-75">${escHtml(label)}</small>
|
|
</span>`;
|
|
}
|
|
return `<span class="qq-powder-badge text-secondary border-secondary" title="Not found in inventory">
|
|
? ${escHtml(match.detectedColorName)}
|
|
<small class="opacity-75">Not in inventory</small>
|
|
</span>`;
|
|
}
|
|
|
|
// ── Save ─────────────────────────────────────────────────────────────────
|
|
|
|
el.saveBtn.addEventListener('click', runSave);
|
|
|
|
async function runSave() {
|
|
clearErrors();
|
|
if (!lastResult) return;
|
|
|
|
const reference = el.reference.value.trim();
|
|
if (!reference) {
|
|
showSaveError('Enter a reference (caller name or memo) before saving.');
|
|
return;
|
|
}
|
|
|
|
el.saveBtn.disabled = true;
|
|
el.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
|
|
|
|
try {
|
|
const body = {
|
|
reference,
|
|
originalDescription: el.description.value.trim(),
|
|
aiDescription: lastResult.description,
|
|
surfaceAreaSqFt: lastResult.surfaceAreaSqFt,
|
|
complexity: lastResult.complexity,
|
|
estimatedMinutes: lastResult.estimatedMinutes,
|
|
requiresPreheat: lastResult.requiresPreheat,
|
|
preheatMinutes: lastResult.preheatMinutes,
|
|
quantity: parseInt(el.qty.value, 10) || 1,
|
|
coatCount: parseInt(el.coats.value, 10) || 1,
|
|
estimatedUnitPrice: lastResult.estimatedUnitPrice,
|
|
materialCost: lastResult.breakdown?.materialCost ?? 0,
|
|
laborCost: lastResult.breakdown?.laborCost ?? 0,
|
|
ovenBatchCost: lastResult.breakdown?.ovenCost ?? 0,
|
|
ovenCycleMinutes: lastResult.breakdown?.ovenCycleMinutes ?? 50
|
|
};
|
|
|
|
const response = await post('/AiQuickQuote/Save', body);
|
|
|
|
if (response.success && response.redirectUrl) {
|
|
closePanel();
|
|
window.location.href = response.redirectUrl;
|
|
} else {
|
|
showSaveError(response.errorMessage || 'Save failed. Please try again.');
|
|
}
|
|
|
|
} catch (err) {
|
|
showSaveError('Could not reach the server. Please try again.');
|
|
console.error('[QuickQuote] Save error:', err);
|
|
} finally {
|
|
el.saveBtn.disabled = false;
|
|
el.saveBtn.innerHTML = '<i class="bi bi-floppy me-1"></i> Save as Draft Quote';
|
|
}
|
|
}
|
|
|
|
// ── Utilities ────────────────────────────────────────────────────────────
|
|
|
|
async function post(url, data) {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'RequestVerificationToken': el.token.value
|
|
},
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
function setLoading(on) {
|
|
el.loading.classList.toggle('d-none', !on);
|
|
el.analyzeBtn.disabled = on;
|
|
el.analyzeBtn.innerHTML = on
|
|
? '<span class="spinner-border spinner-border-sm me-1"></span> Analyzing…'
|
|
: '<i class="bi bi-lightning-charge-fill me-1"></i> Get Estimate';
|
|
}
|
|
|
|
function showInputError(msg) {
|
|
el.inputError.textContent = msg;
|
|
el.inputError.classList.remove('d-none');
|
|
}
|
|
|
|
function showSaveError(msg) {
|
|
el.saveError.textContent = msg;
|
|
el.saveError.classList.remove('d-none');
|
|
}
|
|
|
|
function clearErrors() {
|
|
el.inputError.classList.add('d-none');
|
|
el.saveError.classList.add('d-none');
|
|
}
|
|
|
|
function formatCurrency(value) {
|
|
if (!value && value !== 0) return '—';
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return (str || '').replace(/[&<>"']/g, c => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[c]));
|
|
}
|
|
|
|
// ── Init ─────────────────────────────────────────────────────────────────
|
|
|
|
showStep('input');
|
|
restoreState();
|
|
|
|
})();
|