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
@@ -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)