Add AI Quick Quote widget and inline customer reassignment
- New AI Quick Quote floating button: staff type a verbal description to get an instant price estimate for phone/walk-in customers; detected color names are fuzzy-matched against inventory for stock status; saves draft quote under a Walk-In / Phone customer with one click - Inline customer change on Quote Details and Job Details: always-visible native select with inline confirmation banner (no TomSelect dependency); ChangeCustomer AJAX endpoints on QuotesController and JobsController - Quote Edit page: customer dropdown is now editable (lock removed) - Fix AutoMapper missing CatalogCategory -> UpdateCategoryDto mapping that caused a crash on the catalog category Edit page - Help docs and AI knowledge base updated for all three features Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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'),
|
||||
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);
|
||||
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
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Inline customer change for Quote Details and Job Details pages.
|
||||
* Uses a plain native <select> (always visible, pre-set to current customer).
|
||||
* When the user picks a different customer, an inline confirmation banner appears.
|
||||
* Confirms → AJAX POST to ChangeCustomer action → success toast.
|
||||
* Cancels → reverts the select to the original value.
|
||||
* No TomSelect dependency — Details pages don't load that library.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
document.querySelectorAll('.cc-select').forEach(function (select) {
|
||||
var wrap = select.closest('[data-cc-wrap]');
|
||||
var banner = wrap.querySelector('.cc-confirm-banner');
|
||||
var bannerMsg = wrap.querySelector('.cc-confirm-text');
|
||||
var saveBtn = wrap.querySelector('[data-cc-save]');
|
||||
var cancelBtn = wrap.querySelector('[data-cc-cancel]');
|
||||
var errorEl = wrap.querySelector('.cc-error');
|
||||
|
||||
var originalValue = select.value;
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
if (select.value === originalValue) {
|
||||
banner.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
var name = select.options[select.selectedIndex].text;
|
||||
bannerMsg.textContent = 'Change customer to “' + name + '”?';
|
||||
banner.classList.remove('d-none');
|
||||
if (errorEl) errorEl.classList.add('d-none');
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
select.value = originalValue;
|
||||
banner.classList.add('d-none');
|
||||
if (errorEl) errorEl.classList.add('d-none');
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async function () {
|
||||
var token = (document.querySelector('input[name="__RequestVerificationToken"]') || {}).value;
|
||||
saveBtn.disabled = true;
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('id', wrap.dataset.ccId);
|
||||
formData.append('customerId', select.value);
|
||||
|
||||
try {
|
||||
var resp = await fetch(wrap.dataset.ccUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': token || '' },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var result = await resp.json();
|
||||
|
||||
if (result.success) {
|
||||
originalValue = select.value;
|
||||
banner.classList.add('d-none');
|
||||
showToast('Customer updated to “' + result.customerName + '”.', 'success');
|
||||
} else {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = result.error || 'Failed to update customer.';
|
||||
errorEl.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = 'Network error. Please try again.';
|
||||
errorEl.classList.remove('d-none');
|
||||
}
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(msg, type) {
|
||||
var t = document.createElement('div');
|
||||
t.className = 'toast align-items-center text-bg-' + type + ' border-0 position-fixed bottom-0 end-0 m-3';
|
||||
t.style.zIndex = '1100';
|
||||
t.setAttribute('role', 'alert');
|
||||
t.innerHTML = '<div class="d-flex"><div class="toast-body">' + msg + '</div>'
|
||||
+ '<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>';
|
||||
document.body.appendChild(t);
|
||||
new bootstrap.Toast(t, { delay: 3500 }).show();
|
||||
t.addEventListener('hidden.bs.toast', function () { t.remove(); });
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user