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)
@@ -226,6 +226,8 @@ public static class HelpKnowledgeBase
**Downloading a quote PDF:** Quote Details page "Download PDF" button.
**Changing the customer on a quote:** On the Quote Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears asking you to confirm the change. Click **Save** to apply or **Cancel** to revert to the original. This is especially useful when a quote was created under the "Walk-In / Phone" placeholder and the real customer record is added later.
---
## JOBS
@@ -290,6 +292,8 @@ public static class HelpKnowledgeBase
**Job Templates:** [/JobTemplates](/JobTemplates) Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
@@ -1099,6 +1103,8 @@ public static class HelpKnowledgeBase
10. **AI Help Assistant** That's me! I can answer questions about how the system works.
11. **AI Quick Quote** A floating button (visible on every page) that lets you get an instant rough estimate from a verbal description ideal for phone calls and walk-in customers. Type a description such as "4 wheels, gloss black, need sandblasting", enter quantity and coat count, and the AI returns a price estimate with a confidence score. Detected color names are matched against your inventory so you can see at a glance whether you have the powder in stock. You can then save the quote under a "Walk-In / Phone" customer with one click and reassign it to the real customer record later. Access via the **dark-blue floating button** in the bottom-right corner, just above the AI Help button.
**Plan availability:** AI Photo Quotes and AI Inventory Assist are enabled at the subscription plan level. If you do not see the AI Photo Quote option in the quote wizard or the AI lookup button on inventory items, the feature may not be included in your current plan. Contact your administrator or check [Billing](/Billing) to see your plan details.
The AI Profile (in Company Settings) lets you describe your shop's specialties to improve AI quote estimates. This tab only appears when AI Photo Quotes are enabled for your account.
@@ -1119,6 +1125,9 @@ public static class HelpKnowledgeBase
**Prospect to customer:**
Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job
**Walk-in / phone quote (quick estimate):**
Click the AI Quick Quote button (dark-blue floating button, bottom-right) type description AI returns price estimate Save as draft under "Walk-In / Phone" open the quote reassign the Customer dropdown on Quote Details to the real customer record once you have their info
**Purchase supplies:**
Low stock alert on Dashboard Create PO Submit PO Receive PO Create Bill Pay Bill
+1
View File
@@ -194,6 +194,7 @@ builder.Services.AddScoped<IProfilePhotoService, ProfilePhotoService>();
builder.Services.AddScoped<IJobPhotoService, JobPhotoService>();
builder.Services.AddScoped<IQuotePhotoService, QuotePhotoService>();
builder.Services.AddScoped<IAiQuoteService, AiQuoteService>();
builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
@@ -545,6 +545,37 @@
</div>
</section>
<section id="changing-customer" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-gear text-primary me-2"></i>Changing the Customer
</h2>
<p>
The customer on a job can be changed at any time from the Job Details page — no need to
delete and re-create the job. This is useful when:
</p>
<ul class="mb-3">
<li class="mb-1">A job was created under the <em>Walk-In / Phone</em> placeholder and the real customer is added later.</li>
<li class="mb-1">A job was accidentally assigned to the wrong customer.</li>
<li class="mb-1">A job converted from a quote needs to be moved to a different customer record.</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">How to change the customer</h3>
<ol class="mb-3">
<li class="mb-2">Open the job from <strong>Operations &rsaquo; Jobs</strong> and go to its Details page.</li>
<li class="mb-2">Find the <strong>Customer</strong> field in the job header — it appears as a dropdown showing the current customer.</li>
<li class="mb-2">Select a different customer from the dropdown.</li>
<li class="mb-2">A confirmation banner appears: <em>"Change customer to [Name]?"</em> — click <strong>Save</strong> to confirm or <strong>Cancel</strong> to revert.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
The customer can also be changed on the <strong>Edit Job</strong> page using the Customer
dropdown there. Any invoices or deposits already linked to the job are not automatically
moved — update those separately if needed.
</div>
</div>
</section>
<section id="blank-work-order" class="mb-5">
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
<p>
@@ -611,6 +642,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#shop-display-and-board">Shop Display &amp; Priority Board</a>
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
<a class="nav-link py-1 px-3 small text-body" href="#changing-customer">Changing the Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#blank-work-order">Blank Work Order</a>
</nav>
</div>
@@ -343,6 +343,79 @@
</p>
</section>
<section id="ai-quick-quote" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-lightning-fill text-primary me-2"></i>AI Quick Quote
</h2>
<p>
The <strong>AI Quick Quote</strong> widget lets you get an instant rough estimate from a verbal
description — perfect for phone calls and walk-in customers when you don't have time to open the
full quoting wizard. Look for the dark-blue floating button in the bottom-right corner of any page,
just above the AI Help button.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">How to use it</h3>
<ol class="mb-3">
<li class="mb-2">Click the <strong>AI Quick Quote</strong> floating button (bottom-right, dark blue with a lightning bolt icon).</li>
<li class="mb-2">
Type a description of the work — for example:<br>
<em>"4 motorcycle wheels in Alien Silver with a black base coat, need sandblasting"</em>
</li>
<li class="mb-2">Set the <strong>Quantity</strong> and <strong>Number of Coats</strong>.</li>
<li class="mb-2">Click <strong>Get Estimate</strong>. The AI analyses your description and returns a price estimate, estimated minutes, surface area, complexity rating, and a confidence score in just a few seconds.</li>
<li class="mb-2">
The panel also shows <strong>powder stock status</strong> for any color names detected in your description — a green check means you have it in stock, red means you don't, and a grey question mark means the system couldn't match it.
</li>
<li class="mb-2">If the estimate looks right, enter an optional <strong>Customer Reference</strong> (e.g., the caller's name) and click <strong>Save as Draft Quote</strong>. You land directly on the new quote's Details page where you can adjust anything and assign the real customer.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Quick quotes are saved under a <strong>"Walk-In / Phone"</strong> customer so nothing blocks you from saving immediately. Once you have the customer's information, reassign the quote using the Customer dropdown on the Quote Details page — see <em>Changing the Customer</em> below.
</div>
</div>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
The Quick Quote gives a rough estimate only — it uses your shop's operating costs and the AI's
interpretation of your description. For formal quotes that will be sent to a customer, always
open the quote and verify the details using the full item wizard before sending.
</div>
</div>
</section>
<section id="changing-customer" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-gear text-primary me-2"></i>Changing the Customer
</h2>
<p>
The customer on a quote can be changed at any time from the Quote Details page — no need to
delete and re-create the quote. This is particularly useful when:
</p>
<ul class="mb-3">
<li class="mb-1">A quote was saved under the <em>Walk-In / Phone</em> placeholder and the real customer record is created later.</li>
<li class="mb-1">A quote was accidentally assigned to the wrong customer.</li>
<li class="mb-1">A prospect quote needs to be reassigned after the prospect becomes a customer.</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">How to change the customer</h3>
<ol class="mb-3">
<li class="mb-2">Open the quote from <strong>Operations &rsaquo; Quotes</strong> and go to its Details page.</li>
<li class="mb-2">Find the <strong>Customer</strong> field in the quote header — it appears as a dropdown showing the current customer.</li>
<li class="mb-2">Select a different customer from the dropdown.</li>
<li class="mb-2">A confirmation banner appears: <em>"Change customer to [Name]?"</em> — click <strong>Save</strong> to confirm or <strong>Cancel</strong> to revert to the original.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
The customer can also be changed on the <strong>Edit Quote</strong> page using the Customer
dropdown there. If the quote was originally for a prospect, switching to a customer record
automatically clears the prospect fields.
</div>
</div>
</section>
<section id="pricing-breakdown" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-bar-chart text-primary me-2"></i>Understanding the Pricing Breakdown
@@ -415,6 +488,8 @@
<a class="nav-link py-1 px-3 small text-body" href="#prospect-conversion">Converting a Prospect</a>
<a class="nav-link py-1 px-3 small text-body" href="#customer-approval-portal">Approval Portal</a>
<a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a>
<a class="nav-link py-1 px-3 small text-body" href="#ai-quick-quote">AI Quick Quote</a>
<a class="nav-link py-1 px-3 small text-body" href="#changing-customer">Changing the Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#pricing-breakdown">Pricing Breakdown</a>
</nav>
</div>
+15 -15
View File
@@ -56,23 +56,22 @@
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Customer</label>
<p class="mb-0">
<a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId" class="text-decoration-none">
@if (!string.IsNullOrWhiteSpace(Model.CustomerCompanyName))
@Html.AntiForgeryToken()
<div data-cc-wrap data-cc-id="@Model.Id"
data-cc-url="@Url.Action("ChangeCustomer", "Jobs")">
<select class="form-select form-select-sm cc-select" style="max-width:300px;">
@foreach (var c in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.CustomerSelectList)
{
<strong>@Model.CustomerCompanyName</strong>
@if (!string.IsNullOrWhiteSpace(Model.CustomerContactName))
{
<br />
<small class="text-muted">@Model.CustomerContactName</small>
}
<option value="@c.Value" selected="@(c.Value == Model.CustomerId.ToString() ? "selected" : null)">@c.Text</option>
}
else
{
@Model.CustomerName
}
</a>
</p>
</select>
<div class="cc-confirm-banner d-none mt-2 p-2 bg-light border rounded d-flex align-items-center gap-2 flex-wrap">
<span class="cc-confirm-text small fw-semibold"></span>
<button type="button" class="btn btn-success btn-sm" data-cc-save>Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-cc-cancel>Cancel</button>
</div>
<div class="cc-error text-danger small mt-1 d-none"></div>
</div>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Priority</label>
@@ -2140,6 +2139,7 @@
@section Scripts {
<link rel="stylesheet" href="~/css/job-photos.css" />
<script src="~/js/job-photos.js" asp-append-version="true"></script>
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script>
// ── Inline date editing ──────────────────────────────────────────────
const jobId = @Model.Id;
@@ -80,12 +80,24 @@
else
{
<div class="col-md-12">
<p>
<strong>Customer:</strong>
<a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId">
@Model.CustomerName
</a>
</p>
@Html.AntiForgeryToken()
<strong>Customer:</strong>
<div data-cc-wrap data-cc-id="@Model.Id"
data-cc-url="@Url.Action("ChangeCustomer", "Quotes")"
class="d-inline-block ms-1">
<select class="form-select form-select-sm cc-select" style="max-width:300px;">
@foreach (var c in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.CustomerSelectList)
{
<option value="@c.Value" selected="@(c.Value == Model.CustomerId.ToString() ? "selected" : null)">@c.Text</option>
}
</select>
<div class="cc-confirm-banner d-none mt-2 p-2 bg-light border rounded d-flex align-items-center gap-2 flex-wrap">
<span class="cc-confirm-text small fw-semibold"></span>
<button type="button" class="btn btn-success btn-sm" data-cc-save>Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-cc-cancel>Cancel</button>
</div>
<div class="cc-error text-danger small mt-1 d-none"></div>
</div>
</div>
}
</div>
@@ -2030,6 +2042,7 @@
</div>
@section Scripts {
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script>
function resendQuote(quoteId) {
// Reset modal state
+10 -9
View File
@@ -23,14 +23,13 @@
<input type="hidden" asp-for="QuoteStatusId" />
<partial name="_ValidationSummary" />
<!-- Section 1: Customer / Prospect/Walk-In (Read-Only) -->
<!-- Section 1: Customer / Prospect/Walk-In -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-person-circle me-2"></i>Customer / Prospect/Walk-In</h5>
</div>
<div class="card-body">
<input type="hidden" asp-for="IsForProspect" />
<input type="hidden" asp-for="CustomerId" />
@if (Model.IsForProspect)
{
@@ -78,13 +77,13 @@
}
else
{
<!-- Existing Customer (Read-Only Display) -->
<div class="alert alert-light alert-permanent border mb-0 d-flex align-items-center gap-2">
<i class="bi bi-building text-success fs-5"></i>
<div>
<span class="fw-semibold">@ViewBag.CustomerName</span>
<span class="text-muted ms-2 small">Customer cannot be changed after quote creation.</span>
</div>
<!-- Customer Dropdown (now editable) -->
<div class="col-md-6">
<label asp-for="CustomerId" class="form-label fw-semibold">Customer</label>
<select asp-for="CustomerId" asp-items="ViewBag.Customers" id="customerSelect" class="form-select">
<option value="">-- Select Customer --</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger"></span>
</div>
}
</div>
@@ -637,6 +636,8 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
initTagInput('quoteTags', 'quoteTagsContainer');
var custEl = document.getElementById('customerSelect');
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false });
});
// Discount type toggle
@@ -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")
}
@@ -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(); });
}
})();