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,329 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class AiQuickQuoteController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IAiQuickQuoteService _aiService;
private readonly IPricingCalculationService _pricingService;
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<AiQuickQuoteController> _logger;
public AiQuickQuoteController(
IUnitOfWork unitOfWork,
IAiQuickQuoteService aiService,
IPricingCalculationService pricingService,
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
ILogger<AiQuickQuoteController> logger)
{
_unitOfWork = unitOfWork;
_aiService = aiService;
_pricingService = pricingService;
_context = context;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// Analyzes a verbal customer description and returns a quick pricing estimate.
/// Powder color names are extracted by Claude; inventory stock is resolved server-side
/// without sending the inventory list to the model.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> Analyze([FromBody] AiQuickQuoteRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
request.CompanyId = currentUser.CompanyId;
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
if (costs == null)
{
return Json(new AiQuickQuoteResult
{
Success = false,
ErrorMessage = "Operating costs are not configured. Complete your company setup first."
});
}
// Average powder cost — same fallback ($8/lb) used by the photo quote flow
decimal avgPowderCost = 8m;
try
{
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
if (powders.Any())
avgPowderCost = powders.Average(p => p.UnitCost);
}
catch { /* non-fatal, use default */ }
var context = await BuildAiContextAsync(currentUser.CompanyId, costs);
var result = await _aiService.AnalyzeAsync(request, costs, avgPowderCost, context);
if (!result.Success)
return Json(result);
// Resolve inventory stock for each color Claude detected
if (result.PowderMatches.Count > 0)
await ResolveInventoryStockAsync(result.PowderMatches);
return Json(result);
}
/// <summary>
/// Saves the quick quote estimate as a draft Quote under the company's "Walk-In / Phone" customer.
/// Auto-creates the walk-in customer if this is the first quick quote for this company.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanCreateQuotes)]
public async Task<IActionResult> Save([FromBody] SaveQuickQuoteRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var companyId = currentUser.CompanyId;
// Get or create the company-scoped walk-in customer
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
// Draft status — nullable FK, gracefully absent if lookup not seeded
var draftStatus = await _context.QuoteStatusLookups
.Where(s => s.StatusCode == "DRAFT")
.FirstOrDefaultAsync();
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var now = DateTime.UtcNow;
var quote = new Quote
{
CompanyId = companyId,
QuoteNumber = quoteNumber,
CustomerId = walkIn.Id,
PreparedById = currentUser.Id,
QuoteDate = now,
ExpirationDate = now.AddDays(30),
IsCommercial = false,
Description = request.AiDescription,
Notes = $"[Quick Quote] {request.OriginalDescription}",
CustomerPO = request.Reference,
MaterialCosts = request.MaterialCost,
LaborCosts = request.LaborCost,
ItemsSubtotal = request.EstimatedUnitPrice * request.Quantity,
SubTotal = request.EstimatedUnitPrice * request.Quantity,
Total = request.EstimatedUnitPrice * request.Quantity,
TaxPercent = 0,
TaxAmount = 0,
OvenBatches = 1
};
if (draftStatus != null)
quote.QuoteStatusId = draftStatus.Id;
await _unitOfWork.Quotes.AddAsync(quote);
await _unitOfWork.CompleteAsync();
var item = new QuoteItem
{
CompanyId = companyId,
QuoteId = quote.Id,
Description = request.AiDescription,
Quantity = request.Quantity,
SurfaceAreaSqFt = request.SurfaceAreaSqFt,
UnitPrice = request.EstimatedUnitPrice,
TotalPrice = request.EstimatedUnitPrice * request.Quantity,
EstimatedMinutes = request.EstimatedMinutes,
Complexity = request.Complexity,
IsGenericItem = true,
IsAiItem = true,
ManualUnitPrice = request.EstimatedUnitPrice,
ItemMaterialCost = request.MaterialCost,
ItemLaborCost = request.LaborCost
};
await _unitOfWork.QuoteItems.AddAsync(item);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Quick quote {QuoteNumber} saved for company {CompanyId} (reference: {Reference})",
quoteNumber, companyId, request.Reference);
return Json(new { success = true, redirectUrl = Url.Action("Details", "Quotes", new { id = quote.Id }) });
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// <summary>
/// Builds company AI context from the operating cost profile and recent accepted predictions,
/// mirroring the same pattern used in QuotesController for photo quote analysis.
/// </summary>
private async Task<CompanyAiContext?> BuildAiContextAsync(int companyId, CompanyOperatingCosts costs)
{
try
{
var context = new CompanyAiContext { ProfileText = costs.AiContextProfile };
var predictions = await _unitOfWork.AiItemPredictions.FindAsync(
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
context.AcceptedExamples = predictions
.OrderByDescending(p => p.CreatedAt)
.Take(8)
.Select(p => new AiFewShotExample
{
Description = p.Reasoning?.Split('.').FirstOrDefault()?.Trim() ?? "Item",
SurfaceAreaSqFt = p.PredictedSurfaceAreaSqFt,
Complexity = p.PredictedComplexity,
EstimatedMinutes = p.PredictedMinutes,
FinalUnitPrice = p.PredictedUnitPrice,
Tags = p.AiTags
})
.ToList();
return context;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to build AI context for quick quote (non-fatal)");
return null;
}
}
/// <summary>
/// For each detected color name, attempts a case-insensitive fuzzy match against active
/// coating inventory items. Populates stock status in place on the match list.
/// </summary>
private async Task ResolveInventoryStockAsync(List<PowderStockMatch> matches)
{
try
{
var inventory = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive,
false,
i => i.InventoryCategory);
var coatingItems = inventory
.Where(i => i.InventoryCategory?.IsCoating == true ||
(i.Category != null && i.Category.Contains("powder", StringComparison.OrdinalIgnoreCase)))
.ToList();
foreach (var match in matches)
{
var hit = FindBestMatch(match.DetectedColorName, coatingItems);
if (hit != null)
{
match.HasInventoryMatch = true;
match.InventoryItemName = hit.ColorName ?? hit.Name;
match.QuantityOnHand = hit.QuantityOnHand;
match.UnitCost = hit.UnitCost;
match.IsInStock = hit.QuantityOnHand > 0;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Inventory stock resolution failed (non-fatal) — powder matches returned without stock info");
}
}
private static InventoryItem? FindBestMatch(string colorName, List<InventoryItem> items)
{
var lower = colorName.ToLowerInvariant();
// Exact match on ColorName or Name
var exact = items.FirstOrDefault(i =>
string.Equals(i.ColorName, colorName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(i.Name, colorName, StringComparison.OrdinalIgnoreCase));
if (exact != null) return exact;
// Substring match — detected name contains item name or vice versa
return items.FirstOrDefault(i =>
{
var itemName = (i.ColorName ?? i.Name).ToLowerInvariant();
return itemName.Contains(lower) || lower.Contains(itemName);
});
}
/// <summary>
/// Returns the "Walk-In / Phone" customer for this company, creating it on first use.
/// This mirrors the QuickBooks pattern of grouping walk-in estimates under a placeholder customer.
/// </summary>
private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId)
{
var existing = (await _unitOfWork.Customers.FindAsync(
c => c.CompanyName == "Walk-In / Phone" && c.IsActive))
.FirstOrDefault();
if (existing != null) return existing;
var walkIn = new Customer
{
CompanyId = companyId,
CompanyName = "Walk-In / Phone",
IsActive = true,
IsCommercial = false,
Country = "USA",
NotifyByEmail = false,
NotifyBySms = false,
GeneralNotes = "Auto-created for quick phone and walk-in estimates."
};
await _unitOfWork.Customers.AddAsync(walkIn);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Created Walk-In / Phone customer for company {CompanyId}", companyId);
return walkIn;
}
/// <summary>
/// Generates the next sequential quote number in PREFIX-YYMM-#### format.
/// Uses IgnoreQueryFilters so soft-deleted quotes are counted, preventing number reuse.
/// </summary>
private async Task<string> GenerateQuoteNumberAsync(int companyId)
{
var now = DateTime.UtcNow;
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
.Select(p => new { p.QuoteNumberPrefix })
.FirstOrDefaultAsync();
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
var lastQuoteNumber = await _context.Quotes
.IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix))
.OrderByDescending(q => q.QuoteNumber)
.Select(q => q.QuoteNumber)
.FirstOrDefaultAsync();
int nextNumber = 1;
if (lastQuoteNumber != null)
{
var lastNumberStr = lastQuoteNumber.Substring(prefix.Length + 1);
if (int.TryParse(lastNumberStr, out var lastNumber))
nextNumber = lastNumber + 1;
}
return $"{prefix}-{nextNumber:D4}";
}
}
@@ -521,6 +521,20 @@ public class JobsController : Controller
ViewBag.JobPhotoUsed = photoUsed;
ViewBag.JobPhotoMax = photoMax;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = !string.IsNullOrWhiteSpace(c.CompanyName)
? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim()
})
.OrderBy(c => c.Text)
.ToList();
return View(jobDto);
}
catch (Exception ex)
@@ -531,6 +545,30 @@ public class JobsController : Controller
}
}
/// <summary>
/// Reassigns a job to a different customer.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ChangeCustomer(int id, int customerId)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(id);
if (job == null) return NotFound();
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null)
return Json(new { success = false, error = "Customer not found." });
job.CustomerId = customerId;
await _unitOfWork.CompleteAsync();
var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
? customer.CompanyName
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
return Json(new { success = true, customerName, customerId = customer.Id });
}
// ── Shop Floor QR Status Bump ────────────────────────────────────────────
/// <summary>
@@ -466,6 +466,20 @@ public class QuotesController : Controller
.ToListAsync();
ViewBag.Deposits = quoteDeposits;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = !string.IsNullOrWhiteSpace(c.CompanyName)
? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim()
})
.OrderBy(c => c.Text)
.ToList();
return View(quoteDto);
}
catch (Exception ex)
@@ -476,6 +490,40 @@ public class QuotesController : Controller
}
}
/// <summary>
/// Reassigns a quote to a different customer. Clears any prospect fields so the
/// quote is treated as a real-customer quote after reassignment.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ChangeCustomer(int id, int customerId)
{
var quote = await _unitOfWork.Quotes.GetByIdAsync(id);
if (quote == null) return NotFound();
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null)
return Json(new { success = false, error = "Customer not found." });
quote.CustomerId = customerId;
quote.ProspectCompanyName = null;
quote.ProspectContactName = null;
quote.ProspectEmail = null;
quote.ProspectPhone = null;
quote.ProspectAddress = null;
quote.ProspectCity = null;
quote.ProspectState = null;
quote.ProspectZipCode = null;
await _unitOfWork.CompleteAsync();
var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
? customer.CompanyName
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
return Json(new { success = true, customerName, customerId = customer.Id });
}
/// <summary>
/// Generates and streams the quote PDF.
/// When <paramref name="inline"/> is true the browser displays it in a viewer tab;
@@ -1299,13 +1347,8 @@ public class QuotesController : Controller
return NotFound();
}
_logger.LogInformation("Loaded quote {QuoteNumber}, Original CustomerId: {CustomerId}", quote.QuoteNumber, quote.CustomerId);
// Preserve original customer/prospect assignment (cannot be changed after creation)
dto.CustomerId = quote.CustomerId;
dto.IsForProspect = !quote.CustomerId.HasValue;
_logger.LogInformation("After preservation - CustomerId: {CustomerId}, IsForProspect: {IsForProspect}", dto.CustomerId, dto.IsForProspect);
// IsForProspect derives from whether a customer was selected in the form
dto.IsForProspect = !dto.CustomerId.HasValue;
// Validate at least one quote item exists
if (dto.QuoteItems == null || dto.QuoteItems.Count == 0)