Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/ai-quick-quote.js
T
spouliot 4d10175ce3 Add oven batch cost to AI Quick Quote (1 batch, DefaultOvenCycleMinutes or 50 min)
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>
2026-05-06 15:20:10 -04:00

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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// ── Init ─────────────────────────────────────────────────────────────────
showStep('input');
restoreState();
})();