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,247 @@
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
var token = Antiforgery.GetAndStoreTokens(Context).RequestToken;
}
<!-- AI Quick Quote Widget -->
<div id="qq-widget" class="qq-widget" aria-live="polite" aria-label="AI Quick Quote">
<!-- Trigger button -->
<button id="qq-btn" class="qq-trigger" title="Get a quick phone estimate" aria-label="Open AI Quick Quote">
<i class="bi bi-lightning-charge-fill fs-5"></i>
<span class="qq-label">Quick Quote</span>
</button>
<!-- Panel -->
<div id="qq-panel" class="qq-panel" role="dialog" aria-modal="true" aria-label="AI Quick Quote" hidden>
<div class="qq-header">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-lightning-charge-fill text-warning"></i>
<span class="fw-semibold">AI Quick Quote</span>
<span class="badge bg-secondary" style="font-size:0.65rem;">Beta</span>
</div>
<button id="qq-close" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Close" aria-label="Close Quick Quote">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Step 1: Input -->
<div id="qq-step-input" class="qq-body">
<p class="text-muted mb-3" style="font-size:0.82rem;">
Describe what the customer needs and get an instant estimate.
</p>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:0.85rem;" for="qq-description">
What does the customer have?
</label>
<textarea id="qq-description"
class="form-control form-control-sm"
rows="4"
maxlength="600"
placeholder="e.g. 4 car wheels done in Alexandrite with an Alien Silver base, about 18 inch diameter…"></textarea>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label" style="font-size:0.82rem;" for="qq-qty">Quantity</label>
<input type="number" id="qq-qty" class="form-control form-control-sm" value="1" min="1" max="999" />
</div>
<div class="col-6">
<label class="form-label" style="font-size:0.82rem;" for="qq-coats">Coats</label>
<input type="number" id="qq-coats" class="form-control form-control-sm" value="1" min="1" max="5" />
</div>
</div>
<div id="qq-input-error" class="alert alert-danger alert-permanent d-none py-2" style="font-size:0.82rem;"></div>
<button id="qq-analyze-btn" class="btn btn-primary w-100">
<i class="bi bi-lightning-charge-fill me-1"></i> Get Estimate
</button>
</div>
<!-- Step 2: Results -->
<div id="qq-step-results" class="qq-body d-none">
<!-- AI estimates -->
<div id="qq-result-card" class="card border-0 bg-light mb-3">
<div class="card-body p-3">
<div class="fw-semibold mb-2" id="qq-res-description" style="font-size:0.9rem;"></div>
<div class="row g-2 text-center mb-2">
<div class="col-4">
<div class="small text-muted">Sq Ft</div>
<div class="fw-semibold" id="qq-res-sqft"></div>
</div>
<div class="col-4">
<div class="small text-muted">Complexity</div>
<div class="fw-semibold" id="qq-res-complexity"></div>
</div>
<div class="col-4">
<div class="small text-muted">Labor</div>
<div class="fw-semibold" id="qq-res-minutes"></div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center border-top pt-2">
<div>
<div class="small text-muted">Estimate</div>
<div class="fs-5 fw-bold text-success" id="qq-res-price"></div>
</div>
<div class="text-end">
<div class="small text-muted">Confidence</div>
<span id="qq-res-confidence" class="badge"></span>
</div>
</div>
<div id="qq-res-reasoning" class="mt-2 text-muted" style="font-size:0.78rem;"></div>
</div>
</div>
<!-- Powder / color stock status -->
<div id="qq-powder-section" class="d-none mb-3">
<div class="fw-semibold mb-1" style="font-size:0.82rem;">Powder Stock</div>
<div id="qq-powder-list"></div>
</div>
<!-- Save inputs -->
<div class="mb-2">
<label class="form-label" style="font-size:0.82rem;" for="qq-reference">
Reference <span class="text-muted">(caller name or memo)</span>
</label>
<input type="text" id="qq-reference" class="form-control form-control-sm"
placeholder="e.g. John 4 wheels" maxlength="100" />
</div>
<div id="qq-save-error" class="alert alert-danger alert-permanent d-none py-2 mb-2" style="font-size:0.82rem;"></div>
<div class="d-flex gap-2">
<button id="qq-back-btn" class="btn btn-outline-secondary btn-sm flex-shrink-0">
<i class="bi bi-arrow-left"></i>
</button>
<button id="qq-save-btn" class="btn btn-success btn-sm flex-grow-1">
<i class="bi bi-floppy me-1"></i> Save as Draft Quote
</button>
</div>
</div>
<!-- Typing indicator (shared between steps) -->
<div id="qq-loading" class="qq-loading d-none">
<div class="qq-typing-dot"></div>
<div class="qq-typing-dot"></div>
<div class="qq-typing-dot"></div>
<span class="ms-2 text-muted" style="font-size:0.82rem;">Analyzing…</span>
</div>
</div>
</div>
<input type="hidden" id="qq-token" value="@token" />
<script src="~/js/ai-quick-quote.js" asp-append-version="true"></script>
<style>
.qq-widget {
position: fixed;
bottom: 134px; /* sits above the AI Help button at 80px */
right: 24px;
z-index: 1050;
font-family: inherit;
}
.qq-trigger {
display: flex;
align-items: center;
gap: 6px;
background: #1e3a8a;
color: #fff;
border: none;
border-radius: 50px;
padding: 10px 18px;
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.qq-trigger:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
background: #1d4ed8;
color: #fff;
}
.qq-panel {
position: absolute;
bottom: 60px;
right: 0;
width: 380px;
display: flex;
flex-direction: column;
background: var(--bs-body-bg, #fff);
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
overflow: hidden;
}
.qq-panel[hidden] { display: none !important; }
.qq-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bs-secondary-bg, #f8f9fa);
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
flex-shrink: 0;
}
.qq-body {
padding: 14px;
overflow-y: auto;
max-height: 480px;
}
.qq-loading {
display: flex;
align-items: center;
padding: 12px 14px;
border-top: 1px solid var(--bs-border-color, #dee2e6);
flex-shrink: 0;
}
.qq-typing-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--bs-secondary-color, #6c757d);
animation: qq-bounce 1.2s infinite ease-in-out;
flex-shrink: 0;
}
.qq-typing-dot:nth-child(2) { animation-delay: 0.2s; margin: 0 4px; }
.qq-typing-dot:nth-child(3) { animation-delay: 0.4s; }
@@keyframes qq-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
.qq-powder-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
padding: 3px 8px;
border-radius: 20px;
border: 1px solid;
margin: 2px;
}
@@media (max-width: 480px) {
.qq-widget { bottom: 80px; right: 16px; }
.qq-panel { width: calc(100vw - 32px); right: 0; bottom: 54px; }
.qq-label { display: none; }
.qq-trigger { padding: 10px 14px; }
}
</style>
@@ -2089,6 +2089,7 @@
@if (User.Identity?.IsAuthenticated == true)
{
@await Html.PartialAsync("_AiQuickQuoteWidget")
@await Html.PartialAsync("_AiHelpWidget")
}