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,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)
|
||||
|
||||
Reference in New Issue
Block a user