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:
2026-04-24 17:02:03 -04:00
parent fc9ddc6d17
commit 8d94013895
18 changed files with 1611 additions and 37 deletions
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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(); });
}
})();